From 563bbfff385c18d300694938bc99b4d4298ee155 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 14:06:20 -0600 Subject: [PATCH 01/42] initial testable flows --- src/seedsigner/views/seed_views.py | 39 +++++++++---- src/seedsigner/views/view.py | 32 +++++++--- tests/test_controller.py | 20 ++----- tests/test_flows.py | 93 ++++++++++++++++++++++++++++++ tests/utils.py | 15 +++++ 5 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 tests/test_flows.py create mode 100644 tests/utils.py diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index aca803ba..8154bcac 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -6,14 +6,14 @@ from binascii import hexlify from embit import bip39 from embit.descriptor import Descriptor from embit.networks import NETWORKS -from typing import List +from typing import List, Type from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import BaseScreen, LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -71,6 +71,9 @@ class SeedsMenuView(View): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): + Screen_cls: Type[BaseScreen] = ButtonListScreen + + def run(self): SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) @@ -83,11 +86,11 @@ class LoadSeedView(View): CREATE, ] - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( title="Load A Seed", is_button_text_centered=False, button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -202,6 +205,9 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): + Screen_cls: Type[BaseScreen] = seed_screens.SeedFinalizeScreen + + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -218,10 +224,12 @@ class SeedFinalizeView(View): if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: button_data.append(PASSPHRASE) - selected_menu_num = seed_screens.SeedFinalizeScreen( + selected_menu_num = self.run_screen( fingerprint=self.fingerprint, button_data=button_data, - ).display() + ) + + print(f"selected_menu_num: {selected_menu_num}") if button_data[selected_menu_num] == FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() @@ -233,13 +241,16 @@ class SeedFinalizeView(View): class SeedAddPassphraseView(View): + Screen_cls: Type[BaseScreen] = seed_screens.SeedAddPassphraseScreen + + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - ret = seed_screens.SeedAddPassphraseScreen(passphrase=self.seed.passphrase).display() + ret = self.run_screen(passphrase=self.seed.passphrase) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -257,6 +268,9 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ + Screen_cls: Type[BaseScreen] = seed_screens.SeedReviewPassphraseScreen + + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -277,13 +291,13 @@ class SeedReviewPassphraseView(View): # Because we have ane explicit "Edit" button, we disable "BACK" to keep the # routing options sane. - selected_menu_num = seed_screens.SeedReviewPassphraseScreen( + selected_menu_num = self.run_screen( fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, button_data=button_data, show_back_button=False, - ).display() + ) if button_data[selected_menu_num] == EDIT: return Destination(SeedAddPassphraseView) @@ -338,6 +352,9 @@ class SeedDiscardView(View): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): + Screen_cls: Type[BaseScreen] = seed_screens.SeedOptionsScreen + + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -394,11 +411,11 @@ class SeedOptionsView(View): button_data.append(DISCARD) - selected_menu_num = seed_screens.SeedOptionsScreen( + selected_menu_num = self.run_screen( button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: # Force BACK to always return to the Main Menu diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 02543c4d..b49fa140 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -from typing import List +from typing import List, Type, Union -from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants -from seedsigner.gui.screens import RET_CODE__POWER_BUTTON -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen +from seedsigner.gui.components import FontAwesomeIconConstants +from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen from seedsigner.models.threads import BaseThread from seedsigner.models import Settings @@ -42,6 +42,10 @@ class BackStackView: "Cancel" - End task and return to entry point (destructive) """ class View: + # Explicitly specifying the Screen class allows us to mock out its behavior in the test suite + Screen_cls: Type[BaseScreen] = None + + def __init__(self) -> None: # Import here to avoid circular imports from seedsigner.controller import Controller @@ -57,11 +61,21 @@ class View: self.canvas_height = self.renderer.canvas_height self.buttons = self.controller.buttons + + + def run_screen(self, **kwargs) -> Union[int,str]: + """ + Instantiates the View class' Screen_cls and runs its interactive display. + Returns the user's input upon completion. + """ + if not self.Screen_cls: + raise Exception("Screen_cls not defined") + return self.Screen_cls(**kwargs).display() def run(self, **kwargs): if hasattr(self, "screen"): - self.screen.display() + self.run_screen() else: raise Exception("Must implement in the child class") @@ -73,10 +87,10 @@ class Destination: Basic struct to pass back to the Controller to tell it which View the user should be presented with next. """ - View_cls: View # The target View to route to - view_args: dict = None # The input args required to instantiate the target View - skip_current_view: bool = False # The current View is just forwarding; omit current View from history - clear_history: bool = False # Optionally clears the back_stack to prevent "back" + View_cls: Type[View] # The target View to route to + view_args: dict = None # The input args required to instantiate the target View + skip_current_view: bool = False # The current View is just forwarding; omit current View from history + clear_history: bool = False # Optionally clears the back_stack to prevent "back" def __repr__(self): diff --git a/tests/test_controller.py b/tests/test_controller.py index bd8b2c18..b51b822b 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,26 +1,14 @@ -import configparser import pytest -from mock import MagicMock + +# Must import this before the Controller +from utils import reset_controller + from seedsigner.hardware.microsd import MicroSD from seedsigner.controller import Controller from seedsigner.models.settings_definition import SettingsConstants -@pytest.fixture() -def reset_controller(): - """fixture to setup, then yield to run test, then tear down""" - - # setup - Controller.configure_instance(disable_hardware=True) - - # yield to run a single test - yield - - # tear down - MicroSD._instance = None - Controller._instance = None - def test_singleton_init_fails(reset_controller): """ The Controller should not allow any code to instantiate it via Controller() """ diff --git a/tests/test_flows.py b/tests/test_flows.py new file mode 100644 index 00000000..39750f44 --- /dev/null +++ b/tests/test_flows.py @@ -0,0 +1,93 @@ +import sys +from typing import Any, List, Tuple, Type, Union +from mock import patch, MagicMock + +# sys.modules['seedsigner.gui.screens.seed_screens'] = MagicMock() + +# Must import this before the Controller +from utils import reset_controller + +from seedsigner.controller import Controller +from seedsigner.models.seed import Seed +from seedsigner.views.view import Destination + + + +def flow_sequence(initial_destination: Destination, return_values: List[Union[int, str]]) -> Destination: + """ + Runs the given sequence from the initial_destination, mocking out the associated + Screen and the scenario's return_value, receiving the resulting new Destination, + and repeating until the sequence is complete. + + Returns the final Destination. + """ + next_destination = initial_destination + for return_value in return_values: + if next_destination.View_cls.Screen_cls is None: + raise Exception(f"Screen_cls not specified in {next_destination.View_cls}") + qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) + with patch(qualname + ".run_screen") as mock_run_screen: + mock_run_screen.return_value = return_value + next_destination = next_destination.run() + + return next_destination + + + +class TestFlows: + + + def setup_method(self): + reset_controller() + + + def test_addressexplorer_seedfinalizescreen_flow(self): + """ + Finalizing a seed during the Address Explorer flow should return to the next + Address Explorer step upon completion. + """ + from seedsigner.views import seed_views + + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + + # Default behavior should land at the SeedOptionsView + sequence = [ + 0, # SeedFinalizeView: ret DONE + ] + destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + assert destination.View_cls == seed_views.SeedOptionsView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Now set the flow and try again + controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + + # Finalize the new seed w/out passphrase + sequence = [ + 0, # SeedFinalizeView: ret DONE + None, # SeedOptionsView should auto-route away + ] + destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Finalize the new seed w/passphrase + sequence = [ + 1, # SeedFinalizeView: ret PASSPHRASE + "mypassphrase", # SeedPassphraseView: ret passphrase str + 1, # SeedReviewPassphraseView: ret DONE + None, # SeedOptionsView should auto-route away + ] + destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..6a428429 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,15 @@ +import sys +from mock import MagicMock + +# Prevent importing modules w/Raspi hardware dependencies +sys.modules['seedsigner.gui.renderer'] = MagicMock() +sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.hardware.buttons'] = MagicMock() +sys.modules['seedsigner.hardware.microsd'] = MagicMock() + +from seedsigner.controller import Controller + + +def reset_controller(): + Controller._instance = None + Controller.configure_instance() From 3824fd3e61dfa6fe1591405eb600170047d05054 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 19:46:15 -0600 Subject: [PATCH 02/42] run_before View injection; full basic MainMenuView test flow --- src/seedsigner/helpers/qr.py | 2 +- src/seedsigner/views/scan_views.py | 22 ++++--- src/seedsigner/views/view.py | 22 +++++-- tests/test_controller.py | 100 ++++++++++++++++------------- tests/test_flows.py | 57 ++++++++++++---- tests/utils.py | 2 + 6 files changed, 132 insertions(+), 73 deletions(-) diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index b986c6d7..333910b0 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -48,6 +48,6 @@ class QR: # if qrencode fails, fall back to only encoder if rv != 0: return self.qrimage(data,width,height,border) - img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") + img = Image.open("/tmp/qrcode.png").resize((width,height), Image.Resampling.NEAREST).convert("RGBA") return img diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 5af972c8..aba74a9b 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -1,9 +1,11 @@ import json import re +from typing import Type from embit.descriptor import Descriptor -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen +from seedsigner.gui.screens import scan_screens from seedsigner.models import DecodeQR, Seed from seedsigner.models.settings import SettingsConstants @@ -12,19 +14,23 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest class ScanView(View): + Screen_cls: Type[BaseScreen] = scan_screens.ScanScreen + + def __init__(self): + super().__init__() + self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) + self.decoder = DecodeQR(wordlist_language_code=self.wordlist_language_code) + + def run(self): - from seedsigner.gui.screens.scan_screens import ScanScreen - - wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code) - # Start the live preview and background QR reading - ScanScreen(decoder=self.decoder).display() + self.run_screen(decoder=self.decoder) # Handle the results if self.decoder.is_complete: if self.decoder.is_seed: seed_mnemonic = self.decoder.get_seed_phrase() + if not seed_mnemonic: # seed is not valid, Exit if not valid with message raise Exception("Not yet implemented!") @@ -33,7 +39,7 @@ class ScanView(View): # pending (might set a passphrase, SeedXOR, etc) until finalized. from .seed_views import SeedFinalizeView self.controller.storage.set_pending_seed( - Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code) + Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code) ) if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED: from seedsigner.views.seed_views import SeedAddPassphraseView diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index b49fa140..e16ea373 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Type, Union +from typing import Callable, List, Type, Union from seedsigner.gui.components import FontAwesomeIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON @@ -107,12 +107,20 @@ class Destination: return out - def run(self): + def run(self, run_before: Callable = None): if not self.view_args: # Can't unpack (**) None so we replace with an empty dict self.view_args = {} - # Instantiate the `View_cls` and run() it with the `view_args` dict - return self.View_cls(**self.view_args).run() + + # Instantiate the `View_cls` with the `view_args` dict + view = self.View_cls(**self.view_args) + + # Enable test suite modifications to the View before running it + if run_before: + run_before(view) + + # and run() it + return view.run() def __eq__(self, obj): @@ -135,12 +143,13 @@ class Destination: # ######################################################################################### class MainMenuView(View): + Screen_cls: Type[BaseScreen] = LargeButtonScreen + def run(self): from .seed_views import SeedsMenuView from .settings_views import SettingsMenuView from .scan_views import ScanView from .tools_views import ToolsMenuView - from seedsigner.gui.screens import LargeButtonScreen menu_items = [ (("Scan", FontAwesomeIconConstants.QRCODE), ScanView), (("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView), @@ -148,14 +157,13 @@ class MainMenuView(View): (("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView), ] - screen = LargeButtonScreen( + selected_menu_num = self.run_screen( title="Home", title_font_size=26, button_data=[entry[0] for entry in menu_items], show_back_button=False, show_power_button=True, ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) diff --git a/tests/test_controller.py b/tests/test_controller.py index b51b822b..51511807 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -3,61 +3,75 @@ import pytest # Must import this before the Controller from utils import reset_controller -from seedsigner.hardware.microsd import MicroSD from seedsigner.controller import Controller from seedsigner.models.settings_definition import SettingsConstants +class TestController: + + def setup_method(self): + reset_controller() -def test_singleton_init_fails(reset_controller): - """ The Controller should not allow any code to instantiate it via Controller() """ - with pytest.raises(Exception): - c = Controller() + + def test_reset_controller(self): + """ The reset_controller util should completely reset the Controller singleton """ + controller = Controller.get_instance() + controller.address_explorer_data = "foo" + + reset_controller() + controller = Controller.get_instance() + assert controller.address_explorer_data is None -def test_singleton_get_instance_preserves_state(reset_controller): - """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - - # Initialize the instance and verify that it read the config settings - controller = Controller.get_instance() - assert controller.unverified_address is None - - # Change a value in the instance... - controller.unverified_address = "123abc" - - # ...get a new copy of the instance and confirm change - controller = Controller.get_instance() - assert controller.unverified_address == "123abc" + def test_singleton_init_fails(self): + """ The Controller should not allow any code to instantiate it via Controller() """ + with pytest.raises(Exception): + c = Controller() -def test_missing_settings_get_defaults(reset_controller): - """ Should gracefully handle all missing fields from `settings.json` """ + def test_singleton_get_instance_preserves_state(self): + """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - controller = Controller.get_instance() + # Initialize the instance and verify that it read the config settings + controller = Controller.get_instance() + assert controller.unverified_address is None - # Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH - assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH - assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED - assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"] - assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD + # Change a value in the instance... + controller.unverified_address = "123abc" - # Advanced Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET - assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM - assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] - assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT] - assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__0 - assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED - assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + # ...get a new copy of the instance and confirm change + controller = Controller.get_instance() + assert controller.unverified_address == "123abc" - # Hidden Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189 + + def test_missing_settings_get_defaults(self): + """ Should gracefully handle all missing fields from `settings.json` """ + + controller = Controller.get_instance() + + # Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"] + assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD + + # Advanced Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET + assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] + assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT] + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__0 + assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + + # Hidden Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189 diff --git a/tests/test_flows.py b/tests/test_flows.py index 39750f44..c5325816 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,19 +1,22 @@ -import sys -from typing import Any, List, Tuple, Type, Union -from mock import patch, MagicMock - -# sys.modules['seedsigner.gui.screens.seed_screens'] = MagicMock() +from typing import List, Type, Union +from mock import patch # Must import this before the Controller from utils import reset_controller from seedsigner.controller import Controller from seedsigner.models.seed import Seed -from seedsigner.views.view import Destination +from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views import seed_views, scan_views -def flow_sequence(initial_destination: Destination, return_values: List[Union[int, str]]) -> Destination: +RUN_BEFORE = "run_before" +RETURN_VALUE = "return_value" + + + +def run_flow_sequence(initial_destination: Destination, return_values: List[Union[int, str, dict]]) -> Destination: """ Runs the given sequence from the initial_destination, mocking out the associated Screen and the scenario's return_value, receiving the resulting new Destination, @@ -25,10 +28,20 @@ def flow_sequence(initial_destination: Destination, return_values: List[Union[in for return_value in return_values: if next_destination.View_cls.Screen_cls is None: raise Exception(f"Screen_cls not specified in {next_destination.View_cls}") + + # Optionally modify the View_cls instance before run_screen is called + run_before = None + if type(return_value) == dict: + if RUN_BEFORE in return_value: + run_before = return_value[RUN_BEFORE] + + # Patch the run_screen so we don't actually instantiated and execute the Screen_cls qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: mock_run_screen.return_value = return_value - next_destination = next_destination.run() + + # Now we can run the View and grab its resulting next Destination + next_destination = next_destination.run(run_before=run_before) return next_destination @@ -36,18 +49,34 @@ def flow_sequence(initial_destination: Destination, return_values: List[Union[in class TestFlows: - def setup_method(self): reset_controller() + def test_home_scan_seedoptions_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the + Finalize Seed flow and end at the SeedOptionsView. + """ + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + sequence = [ + 0, # MainMenuView: ret SCAN + {RUN_BEFORE: load_seed_into_decoder, RETURN_VALUE: None}, # ScanView: read SeedQR + 0, # SeedFinalizeView: ret DONE + ] + destination = run_flow_sequence(Destination(MainMenuView), return_values=sequence) + + # Should land on SeedOptionsView + assert destination.View_cls == seed_views.SeedOptionsView + + def test_addressexplorer_seedfinalizescreen_flow(self): """ Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ - from seedsigner.views import seed_views - controller = Controller.get_instance() seed = Seed(mnemonic=["abandon "* 11 + "about"]) controller.storage.set_pending_seed(seed) @@ -56,7 +85,7 @@ class TestFlows: sequence = [ 0, # SeedFinalizeView: ret DONE ] - destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) assert destination.View_cls == seed_views.SeedOptionsView # Reset @@ -71,7 +100,7 @@ class TestFlows: 0, # SeedFinalizeView: ret DONE None, # SeedOptionsView should auto-route away ] - destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) # Flow should resume at Script Type selection assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView @@ -87,7 +116,7 @@ class TestFlows: 1, # SeedReviewPassphraseView: ret DONE None, # SeedOptionsView should auto-route away ] - destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) # Flow should resume at Script Type selection assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/utils.py b/tests/utils.py index 6a428429..f0813665 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,11 +5,13 @@ from mock import MagicMock sys.modules['seedsigner.gui.renderer'] = MagicMock() sys.modules['seedsigner.gui.screens.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 def reset_controller(): + """ Wipe and re-initialize the Controller singleton for each test run """ Controller._instance = None Controller.configure_instance() From 5a1c4b73de1d419e8942eff4a45bcd126da8b998 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 20:15:40 -0600 Subject: [PATCH 03/42] Simplification - require Screen class as first arg in View.run_screen() --- src/seedsigner/views/scan_views.py | 4 +--- src/seedsigner/views/seed_views.py | 19 ++++--------------- src/seedsigner/views/view.py | 13 +++---------- tests/test_flows.py | 5 +---- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index aba74a9b..95903040 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -14,8 +14,6 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest class ScanView(View): - Screen_cls: Type[BaseScreen] = scan_screens.ScanScreen - def __init__(self): super().__init__() self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) @@ -24,7 +22,7 @@ class ScanView(View): def run(self): # Start the live preview and background QR reading - self.run_screen(decoder=self.decoder) + self.run_screen(scan_screens.ScanScreen, decoder=self.decoder) # Handle the results if self.decoder.is_complete: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 8154bcac..dd3a72b0 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -205,9 +205,6 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): - Screen_cls: Type[BaseScreen] = seed_screens.SeedFinalizeScreen - - def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -225,12 +222,11 @@ class SeedFinalizeView(View): button_data.append(PASSPHRASE) selected_menu_num = self.run_screen( + seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, button_data=button_data, ) - print(f"selected_menu_num: {selected_menu_num}") - if button_data[selected_menu_num] == FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) @@ -241,16 +237,13 @@ class SeedFinalizeView(View): class SeedAddPassphraseView(View): - Screen_cls: Type[BaseScreen] = seed_screens.SeedAddPassphraseScreen - - def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - ret = self.run_screen(passphrase=self.seed.passphrase) + ret = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -268,9 +261,6 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ - Screen_cls: Type[BaseScreen] = seed_screens.SeedReviewPassphraseScreen - - def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -292,6 +282,7 @@ class SeedReviewPassphraseView(View): # Because we have ane explicit "Edit" button, we disable "BACK" to keep the # routing options sane. selected_menu_num = self.run_screen( + seed_screens.SeedReviewPassphraseScreen, fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, @@ -352,9 +343,6 @@ class SeedDiscardView(View): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): - Screen_cls: Type[BaseScreen] = seed_screens.SeedOptionsScreen - - def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -412,6 +400,7 @@ class SeedOptionsView(View): button_data.append(DISCARD) selected_menu_num = self.run_screen( + seed_screens.SeedOptionsScreen, button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index e16ea373..7245aa9b 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -42,10 +42,6 @@ class BackStackView: "Cancel" - End task and return to entry point (destructive) """ class View: - # Explicitly specifying the Screen class allows us to mock out its behavior in the test suite - Screen_cls: Type[BaseScreen] = None - - def __init__(self) -> None: # Import here to avoid circular imports from seedsigner.controller import Controller @@ -63,14 +59,12 @@ class View: self.buttons = self.controller.buttons - def run_screen(self, **kwargs) -> Union[int,str]: + def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> Union[int,str]: """ Instantiates the View class' Screen_cls and runs its interactive display. Returns the user's input upon completion. """ - if not self.Screen_cls: - raise Exception("Screen_cls not defined") - return self.Screen_cls(**kwargs).display() + return Screen_cls(**kwargs).display() def run(self, **kwargs): @@ -143,8 +137,6 @@ class Destination: # ######################################################################################### class MainMenuView(View): - Screen_cls: Type[BaseScreen] = LargeButtonScreen - def run(self): from .seed_views import SeedsMenuView from .settings_views import SettingsMenuView @@ -158,6 +150,7 @@ class MainMenuView(View): ] selected_menu_num = self.run_screen( + LargeButtonScreen, title="Home", title_font_size=26, button_data=[entry[0] for entry in menu_items], diff --git a/tests/test_flows.py b/tests/test_flows.py index c5325816..80dc2fdc 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -26,16 +26,13 @@ def run_flow_sequence(initial_destination: Destination, return_values: List[Unio """ next_destination = initial_destination for return_value in return_values: - if next_destination.View_cls.Screen_cls is None: - raise Exception(f"Screen_cls not specified in {next_destination.View_cls}") - # Optionally modify the View_cls instance before run_screen is called run_before = None if type(return_value) == dict: if RUN_BEFORE in return_value: run_before = return_value[RUN_BEFORE] - # Patch the run_screen so we don't actually instantiated and execute the Screen_cls + # Patch the run_screen so we don't actually instantiate and execute the Screen_cls qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: mock_run_screen.return_value = return_value From 1aeef3f7eeaf170ef42c5fba424757fa46ddca0c Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 21:08:38 -0600 Subject: [PATCH 04/42] Updating to latest approach --- src/seedsigner/views/seed_views.py | 4 +--- tests/test_flows.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index dd3a72b0..36dba57f 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -71,9 +71,6 @@ class SeedsMenuView(View): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): - Screen_cls: Type[BaseScreen] = ButtonListScreen - - def run(self): SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) @@ -87,6 +84,7 @@ class LoadSeedView(View): ] selected_menu_num = self.run_screen( + ButtonListScreen, title="Load A Seed", is_button_text_centered=False, button_data=button_data diff --git a/tests/test_flows.py b/tests/test_flows.py index 80dc2fdc..d5685722 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -32,7 +32,7 @@ def run_flow_sequence(initial_destination: Destination, return_values: List[Unio if RUN_BEFORE in return_value: run_before = return_value[RUN_BEFORE] - # Patch the run_screen so we don't actually instantiate and execute the Screen_cls + # Patch the run_screen so we don't actually instantiate and execute the Screen qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: mock_run_screen.return_value = return_value From cd0f98b4d47cbf5903efc2d3746b4bdfdb121cbc Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 21:16:39 -0600 Subject: [PATCH 05/42] Update view.py --- src/seedsigner/views/view.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 7245aa9b..bc21aa95 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -68,10 +68,7 @@ class View: def run(self, **kwargs): - if hasattr(self, "screen"): - self.run_screen() - else: - raise Exception("Must implement in the child class") + raise Exception("Must implement in the child class") From c61de8522fac04f5886fb016f25bb9e3288f1585 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 4 Mar 2023 14:21:15 -0600 Subject: [PATCH 06/42] New BaseTest structure; new FlowStep sequence testing --- src/seedsigner/models/settings_definition.py | 4 +- src/seedsigner/views/seed_views.py | 21 +-- src/seedsigner/views/settings_views.py | 36 +++-- src/seedsigner/views/view.py | 8 +- tests/base.py | 87 ++++++++++ tests/test_controller.py | 10 +- tests/test_flows.py | 119 -------------- tests/test_flows_seed.py | 143 +++++++++++++++++ tests/test_flows_settings.py | 157 +++++++++++++++++++ tests/test_flows_tools.py | 83 ++++++++++ tests/utils.py | 17 -- 11 files changed, 512 insertions(+), 173 deletions(-) create mode 100644 tests/base.py delete mode 100644 tests/test_flows.py create mode 100644 tests/test_flows_seed.py create mode 100644 tests/test_flows_settings.py create mode 100644 tests/test_flows_tools.py delete mode 100644 tests/utils.py diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 6c2af745..df02858b 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, List +from typing import Any, List, Tuple, Union @@ -217,7 +217,7 @@ class SettingsEntry: visibility: str = SettingsConstants.VISIBILITY__GENERAL type: str = SettingsConstants.TYPE__ENABLED_DISABLED help_text: str = None - selection_options: List[str] = None + selection_options: List[Tuple[Union[str,int],str]] = None default_value: Any = None def __post_init__(self): diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 36dba57f..0112267b 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -6,14 +6,14 @@ from binascii import hexlify from embit import bip39 from embit.descriptor import Descriptor from embit.networks import NETWORKS -from typing import List, Type +from typing import List from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import BaseScreen, LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -120,22 +120,16 @@ class SeedMnemonicEntryView(View): def run(self): - ret = seed_screens.SeedMnemonicEntryScreen( + ret = self.run_screen( + seed_screens.SeedMnemonicEntryScreen, title=f"Seed Word #{self.cur_word_index + 1}", # Human-readable 1-indexing! initial_letters=list(self.cur_word) if self.cur_word else ["a"], wordlist=Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)), - ).display() + ) if ret == RET_CODE__BACK_BUTTON: if self.cur_word_index > 0: return Destination(BackStackView) - # return Destination( - # SeedMnemonicEntryView, - # view_args={ - # "cur_word_index": self.cur_word_index - 1, - # "is_calc_final_word": self.is_calc_final_word - # } - # ) else: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) @@ -185,13 +179,14 @@ class SeedMnemonicInvalidView(View): DISCARD = ("Discard", None, None, "red") button_data = [EDIT, DISCARD] - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, button_data=button_data, - ).display() + ) if button_data[selected_menu_num] == EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 8a13b43d..b458ea0c 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,7 +1,6 @@ -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants -from seedsigner.models.decode_qr import DecodeQR +from seedsigner.gui.components import SeedSignerCustomIconConstants -from .view import View, Destination, BackStackView, MainMenuView +from .view import View, Destination, MainMenuView from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens) from seedsigner.models.settings import SettingsConstants, SettingsDefinition @@ -56,17 +55,17 @@ class SettingsMenuView(View): title = "Dev Options" next = None - screen = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title=title, is_button_text_centered=False, button_data=button_data, selected_button=selected_button, scroll_y_initial_offset=self.initial_scroll, ) - selected_menu_num = screen.display() # Preserve our scroll position in this Screen so we can return - initial_scroll = screen.buttons[0].scroll_y + initial_scroll = self.screen.buttons[0].scroll_y if selected_menu_num == RET_CODE__BACK_BUTTON: if self.visibility == SettingsConstants.VISIBILITY__GENERAL: @@ -100,6 +99,9 @@ class SettingsEntryUpdateSelectionView(View): self.settings_entry = SettingsDefinition.get_settings_entry(attr_name) self.selected_button = None self.parent_initial_scroll = parent_initial_scroll + + # hook for test suite to override recursion in run() + self.is_test = False def run(self): @@ -123,14 +125,18 @@ class SettingsEntryUpdateSelectionView(View): if not self.selected_button: self.selected_button = 0 - ret_value = settings_screens.SettingsEntryUpdateSelectionScreen( + screen_args = dict( display_name=self.settings_entry.display_name, help_text=self.settings_entry.help_text, button_data=button_data, selected_button=self.selected_button, checked_buttons=checked_buttons, - settings_entry_type=self.settings_entry.type, - ).display() + settings_entry_type=self.settings_entry.type, + ) + ret_value = self.run_screen( + settings_screens.SettingsEntryUpdateSelectionScreen, + **screen_args + ) destination = None settings_menu_view_destination = Destination( @@ -178,7 +184,13 @@ class SettingsEntryUpdateSelectionView(View): # All selects stay in place; re-initialize where in the list we left off self.selected_button = ret_value - return self.run() + + if self.is_test: + # Test suite can't handle the recursive call. Fake it by returning us here + # via a Destination back to this View. + return Destination(self.__class__, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll)) + else: + return self.run() @@ -187,7 +199,7 @@ class SettingsEntryUpdateSelectionView(View): ****************************************************************************""" class IOTestView(View): def run(self): - settings_screens.IOTestScreen().display() + self.run_screen(settings_screens.IOTestScreen) return Destination(SettingsMenuView) @@ -195,6 +207,6 @@ class IOTestView(View): class DonateView(View): def run(self): - settings_screens.DonateScreen().display() + self.run_screen(settings_screens.DonateScreen) return Destination(SettingsMenuView) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index bc21aa95..4e7e5db9 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -57,17 +57,19 @@ class View: self.canvas_height = self.renderer.canvas_height self.buttons = self.controller.buttons + self.screen = None def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> Union[int,str]: """ - Instantiates the View class' Screen_cls and runs its interactive display. + Instantiates the provided Screen_cls and runs its interactive display. Returns the user's input upon completion. """ - return Screen_cls(**kwargs).display() + self.screen = Screen_cls(**kwargs) + return self.screen.display() - def run(self, **kwargs): + def run(self, **kwargs) -> 'Destination': raise Exception("Must implement in the child class") diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 00000000..dfcac8f3 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,87 @@ +import sys +from dataclasses import dataclass +from mock import MagicMock, patch +from typing import Callable, List, Type, Union + +# Prevent importing modules w/Raspi hardware dependencies. +# These must precede any SeedSigner imports. +sys.modules['seedsigner.gui.renderer'] = MagicMock() +sys.modules['seedsigner.gui.screens.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 +from seedsigner.models import Settings +from seedsigner.views.view import Destination, View + + + +class BaseTest: + + def reset_controller(self): + """ Wipe and re-initialize the Controller singleton for each test run """ + Controller._instance = None + Controller.configure_instance() + + + def setup_class(self): + Settings.SETTINGS_FILENAME = "settings-test.json" + + + def teardown_class(self): + """ If settings were written to disk, delete """ + import os + try: + print(f"attempting to remove {Settings.SETTINGS_FILENAME}") + os.remove(Settings.SETTINGS_FILENAME) + print(f"Removed {Settings.SETTINGS_FILENAME}") + except: + print(f"{Settings.SETTINGS_FILENAME} not found to be removed") + + + def setup_method(self): + self.reset_controller() + self.controller = Controller.get_instance() + self.settings = Settings.get_instance() + + + +@dataclass +class FlowStep: + expected_view: Type[View] = None + run_before: Callable = None + screen_return_value: Union[int, str] = None + + + +class FlowTest(BaseTest): + def run_sequence(self, initial_destination: Destination, sequence: List[FlowStep]) -> Destination: + """ + Runs the given sequence from the initial_destination, mocking out the associated + Screen and the scenario's return_value, receiving the resulting new Destination, + and repeating until the sequence is complete. + + Returns the final Destination. + """ + next_destination = initial_destination + for flow_step in sequence: + try: + # Validate that the next Destination's View is what we expected + if flow_step.expected_view: + assert next_destination.View_cls == flow_step.expected_view + + # Patch the `View.run_screen()` so we don't actually instantiate the Screen + qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) + with patch(qualname + ".run_screen") as mock_run_screen: + mock_run_screen.return_value = flow_step.screen_return_value + + # Now we can run the View and grab its resulting next Destination + next_destination = next_destination.run(run_before=flow_step.run_before) + except Exception as e: + import traceback + traceback.print_exc() + print(f"{next_destination} | {flow_step}") + raise e + + return next_destination diff --git a/tests/test_controller.py b/tests/test_controller.py index 51511807..fb76b533 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,25 +1,21 @@ import pytest # Must import this before the Controller -from utils import reset_controller +from base import BaseTest from seedsigner.controller import Controller from seedsigner.models.settings_definition import SettingsConstants -class TestController: - - def setup_method(self): - reset_controller() - +class TestController(BaseTest): def test_reset_controller(self): """ The reset_controller util should completely reset the Controller singleton """ controller = Controller.get_instance() controller.address_explorer_data = "foo" - reset_controller() + self.reset_controller() controller = Controller.get_instance() assert controller.address_explorer_data is None diff --git a/tests/test_flows.py b/tests/test_flows.py deleted file mode 100644 index d5685722..00000000 --- a/tests/test_flows.py +++ /dev/null @@ -1,119 +0,0 @@ -from typing import List, Type, Union -from mock import patch - -# Must import this before the Controller -from utils import reset_controller - -from seedsigner.controller import Controller -from seedsigner.models.seed import Seed -from seedsigner.views.view import Destination, MainMenuView, View -from seedsigner.views import seed_views, scan_views - - - -RUN_BEFORE = "run_before" -RETURN_VALUE = "return_value" - - - -def run_flow_sequence(initial_destination: Destination, return_values: List[Union[int, str, dict]]) -> Destination: - """ - Runs the given sequence from the initial_destination, mocking out the associated - Screen and the scenario's return_value, receiving the resulting new Destination, - and repeating until the sequence is complete. - - Returns the final Destination. - """ - next_destination = initial_destination - for return_value in return_values: - # Optionally modify the View_cls instance before run_screen is called - run_before = None - if type(return_value) == dict: - if RUN_BEFORE in return_value: - run_before = return_value[RUN_BEFORE] - - # Patch the run_screen so we don't actually instantiate and execute the Screen - qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) - with patch(qualname + ".run_screen") as mock_run_screen: - mock_run_screen.return_value = return_value - - # Now we can run the View and grab its resulting next Destination - next_destination = next_destination.run(run_before=run_before) - - return next_destination - - - -class TestFlows: - - def setup_method(self): - reset_controller() - - - def test_home_scan_seedoptions_flow(self): - """ - Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the - Finalize Seed flow and end at the SeedOptionsView. - """ - def load_seed_into_decoder(view: scan_views.ScanView): - view.decoder.add_data("0000" * 11 + "0003") - - sequence = [ - 0, # MainMenuView: ret SCAN - {RUN_BEFORE: load_seed_into_decoder, RETURN_VALUE: None}, # ScanView: read SeedQR - 0, # SeedFinalizeView: ret DONE - ] - destination = run_flow_sequence(Destination(MainMenuView), return_values=sequence) - - # Should land on SeedOptionsView - assert destination.View_cls == seed_views.SeedOptionsView - - - def test_addressexplorer_seedfinalizescreen_flow(self): - """ - Finalizing a seed during the Address Explorer flow should return to the next - Address Explorer step upon completion. - """ - controller = Controller.get_instance() - seed = Seed(mnemonic=["abandon "* 11 + "about"]) - controller.storage.set_pending_seed(seed) - - # Default behavior should land at the SeedOptionsView - sequence = [ - 0, # SeedFinalizeView: ret DONE - ] - destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) - assert destination.View_cls == seed_views.SeedOptionsView - - # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) - - # Now set the flow and try again - controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER - - # Finalize the new seed w/out passphrase - sequence = [ - 0, # SeedFinalizeView: ret DONE - None, # SeedOptionsView should auto-route away - ] - destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) - - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView - - # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) - - # Finalize the new seed w/passphrase - sequence = [ - 1, # SeedFinalizeView: ret PASSPHRASE - "mypassphrase", # SeedPassphraseView: ret passphrase str - 1, # SeedReviewPassphraseView: ret DONE - None, # SeedOptionsView should auto-route away - ] - destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) - - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py new file mode 100644 index 00000000..220e384b --- /dev/null +++ b/tests/test_flows_seed.py @@ -0,0 +1,143 @@ + +# Must import this before the Controller +from base import FlowTest, FlowStep + +from seedsigner.views.view import Destination, MainMenuView +from seedsigner.views import seed_views, scan_views + + + +class TestSeedFlows(FlowTest): + + def test_scan_seedqr_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the + Finalize Seed flow and end at the SeedOptionsView. + """ + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=0, # ret SCAN + ), + FlowStep( + expected_view=scan_views.ScanView, + run_before=load_seed_into_decoder, # simulate read SeedQR + screen_return_value=None, + ), + FlowStep( + expected_view=seed_views.SeedFinalizeView, + screen_return_value=0, # ret DONE + ), + ] + ) + assert destination.View_cls == seed_views.SeedOptionsView + + + def test_mnemonic_entry_flow(self): + """ + Manually entering a mnemonic should land at the Finalize Seed flow and end at + the SeedOptionsView. + """ + def test_with_mnemonic(mnemonic): + sequence = [ + FlowStep( + screen_return_value=1, # ret SEEDS + ), + FlowStep( + expected_view=seed_views.SeedsMenuView, + # Nothing to return; auto-forwards to LoadSeedView + ), + FlowStep( + expected_view=seed_views.LoadSeedView, + screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + ), + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value=word + ) + ) + + # With the mnemonic completely entered, we land on the SeedFinalizeView + sequence.append( + FlowStep( + expected_view=seed_views.SeedFinalizeView, + screen_return_value=0, # ret DONE + ) + ) + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=sequence + ) + assert destination.View_cls == seed_views.SeedOptionsView + + # Test data from iancoleman.io; 12- and 24-word mnemonic + test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) + + self.reset_controller() + + test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split()) + + + def test_invalid_mnemonic(self): + """ Should be able to go back and edit or discard an invalid mnemonic """ + # Test data from iancoleman.io + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + sequence = [ + FlowStep( + screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + ), + ] + for word in mnemonic[:-1]: + sequence.append( + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value=word + ) + ) + + sequence += [ + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value="zoo" # But finish with an INVALID checksum word + ), + FlowStep( + expected_view=seed_views.SeedMnemonicInvalidView, + screen_return_value=0 # ret EDIT + ), + ] + + # Restarts from first word + for word in mnemonic[:-1]: + sequence.append( + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value=word + ) + ) + + sequence += [ + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value="zebra" # provide yet another invalid checksum word + ), + FlowStep( + expected_view=seed_views.SeedMnemonicInvalidView, + screen_return_value=1 # ret DISCARD; this time we give up + ), + ] + + destination = self.run_sequence( + Destination(seed_views.LoadSeedView), + sequence + ) + assert destination.View_cls == MainMenuView \ No newline at end of file diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py new file mode 100644 index 00000000..33c8e7a9 --- /dev/null +++ b/tests/test_flows_settings.py @@ -0,0 +1,157 @@ +from mock import MagicMock +from typing import Type + +# Must import this before any seedsigner imports +from base import FlowTest, FlowStep + +from seedsigner.models import SettingsDefinition +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views import settings_views + + + +class TestSettingsFlows(FlowTest): + + def test_persistent_settings(self): + """ Basic flow from MainMenuView to enable/disable persistent settings """ + # Which option are we testing? + settings_entries = SettingsDefinition.get_settings_entries() + nth_entry = [s.attr_name for s in settings_entries].index(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret persistent settings + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + screen_return_value=[s[0] for s in settings_entry.selection_options].index(SettingsConstants.OPTION__ENABLED), + ), + ] + ) + assert destination.View_cls == settings_views.SettingsMenuView + + + def test_multiselect(self): + """ Multiselect Settings options should stay in-place; requires BACK to exit. """ + # Which option are we testing? + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) + nth_entry = SettingsDefinition.get_settings_entries().index(settings_entry) + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + def disable_recursion(view: Type[View]): + # The recursion in SettingsEntryUpdateSelectionView for multiselect settings + # won't work with our FlowTest sequence structure. + view.is_test = True + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret Coordinator software + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + run_before=disable_recursion, + screen_return_value=0, # select/deselect first option + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + run_before=disable_recursion, + screen_return_value=1, # select/deselect second option + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + run_before=disable_recursion, + screen_return_value=1, # select/deselect second option + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + screen_return_value=RET_CODE__BACK_BUTTON, # BACK to exit + ), + ] + ) + assert destination.View_cls == settings_views.SettingsMenuView + + + def test_io_test(self): + """ Basic flow from MainMenuView to I/O Test View """ + # I/O Test will be the nth_entry after we include the submenu to "Advanced" + nth_entry = len(SettingsDefinition.get_settings_entries()) + 1 + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret I/O Test + ), + FlowStep( + expected_view=settings_views.IOTestView, + screen_return_value=None, # ret value is ignored + ), + ] + ) + # Exiting IOTestView should return us to the main SettingsMenuView + assert destination.View_cls == settings_views.SettingsMenuView + + + def test_donate(self): + """ Basic flow from MainMenuView to Donate View """ + # DonateView will be the nth_entry after we include the submenu to "Advanced" + # and the I/O Test + nth_entry = len(SettingsDefinition.get_settings_entries()) + 2 + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret Donate + ), + FlowStep( + expected_view=settings_views.DonateView, + screen_return_value=None, # ret value is ignored + ), + ] + ) + # Exiting IOTestView should return us to the main SettingsMenuView + assert destination.View_cls == settings_views.SettingsMenuView diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py new file mode 100644 index 00000000..efa8529f --- /dev/null +++ b/tests/test_flows_tools.py @@ -0,0 +1,83 @@ + +# Must import this before the Controller +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller +from seedsigner.models.seed import Seed +from seedsigner.views.view import Destination, MainMenuView +from seedsigner.views import seed_views, scan_views + + + +class TestToolsFlows(FlowTest): + + def test_addressexplorer_seedfinalizescreen_flow(self): + """ + Finalizing a seed during the Address Explorer flow should return to the next + Address Explorer step upon completion. + """ + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + + # Default behavior should land at the SeedOptionsView + destination = self.run_sequence( + Destination(seed_views.SeedFinalizeView), + sequence=[ + FlowStep( + screen_return_value=0, # ret DONE + ), + ] + ) + assert destination.View_cls == seed_views.SeedOptionsView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Now set the flow and try again + controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + + # Finalize the new seed w/out passphrase + destination = self.run_sequence( + Destination(seed_views.SeedFinalizeView), + sequence=[ + FlowStep( + screen_return_value=0, # ret DONE + ), + FlowStep( + expected_view=seed_views.SeedOptionsView, + screen_return_value=None, # should auto-route away w/out a selection + ), + ] + ) + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Finalize the new seed w/passphrase + destination = self.run_sequence( + Destination(seed_views.SeedFinalizeView), + sequence=[ + FlowStep( + screen_return_value=1, # ret PASSPHRASE + ), + FlowStep( + expected_view=seed_views.SeedAddPassphraseView, + screen_return_value="mypassphrase", + ), + FlowStep( + expected_view=seed_views.SeedReviewPassphraseView, + screen_return_value=1, # ret DONE + ), + FlowStep( + expected_view=seed_views.SeedOptionsView, + screen_return_value=None, # SeedOptionsView should auto-route away + ), + ] + ) + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index f0813665..00000000 --- a/tests/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys -from mock import MagicMock - -# Prevent importing modules w/Raspi hardware dependencies -sys.modules['seedsigner.gui.renderer'] = MagicMock() -sys.modules['seedsigner.gui.screens.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 - - -def reset_controller(): - """ Wipe and re-initialize the Controller singleton for each test run """ - Controller._instance = None - Controller.configure_instance() From b03222cdc603c73f9a28d0dc0314ff347286a71f Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 08:32:40 -0600 Subject: [PATCH 07/42] Update test_flows_tools.py --- tests/test_flows_tools.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index efa8529f..602c53af 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -16,26 +16,12 @@ class TestToolsFlows(FlowTest): Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ + # Set up pending seed leading up to SeedFinalizeView controller = Controller.get_instance() seed = Seed(mnemonic=["abandon "* 11 + "about"]) controller.storage.set_pending_seed(seed) - - # Default behavior should land at the SeedOptionsView - destination = self.run_sequence( - Destination(seed_views.SeedFinalizeView), - sequence=[ - FlowStep( - screen_return_value=0, # ret DONE - ), - ] - ) - assert destination.View_cls == seed_views.SeedOptionsView - # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) - - # Now set the flow and try again + # Set the Address Explorer flow to change routing behavior controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER # Finalize the new seed w/out passphrase @@ -47,7 +33,7 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedOptionsView, - screen_return_value=None, # should auto-route away w/out a selection + # should auto-route away w/out a selection ), ] ) @@ -75,7 +61,7 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedOptionsView, - screen_return_value=None, # SeedOptionsView should auto-route away + # SeedOptionsView should auto-route away ), ] ) From 2d9bf13b306c1d26a6b515c756cafa1f80375783 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 12:45:31 -0600 Subject: [PATCH 08/42] More extensive tools_view testing; BaseTest fixes --- src/seedsigner/views/seed_views.py | 5 +- src/seedsigner/views/tools_views.py | 84 ++++++++++++++------------ tests/base.py | 34 ++++++----- tests/test_flows_tools.py | 92 +++++++++++++++++++++++++---- 4 files changed, 149 insertions(+), 66 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0112267b..8f16bfac 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -520,12 +520,13 @@ class SeedExportXpubScriptTypeView(View): if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: title = "Address Explorer" - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title=title, is_button_text_centered=False, button_data=button_data, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: # If previous view is SeedOptionsView then that should be where resume_main_flow started (otherwise it would have been skipped). diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index e7778bdf..20b7309b 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -7,8 +7,6 @@ from embit.descriptor import Descriptor from PIL import Image from PIL.ImageOps import autocontrast from seedsigner.controller import Controller -from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen - from seedsigner.hardware.camera import Camera from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerCustomIconConstants from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen) @@ -31,12 +29,13 @@ class ToolsMenuView(View): KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] - screen = ButtonListScreen( + + selected_menu_num = self.run_screen( + ButtonListScreen, title="Tools", is_button_text_centered=False, button_data=button_data ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -440,12 +439,13 @@ class ToolsAddressExplorerSelectSourceView(View): button_data.append(TYPE_12WORD) button_data.append(TYPE_24WORD) - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Address Explorer", button_data=button_data, is_button_text_centered=False, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -538,13 +538,14 @@ class ToolsAddressExplorerAddressTypeView(View): script_type = data["script_type"] if "script_type" in data else None - selected_menu_num = ToolsAddressExplorerAddressTypeScreen( + selected_menu_num = self.run_screen( + ToolsAddressExplorerAddressTypeScreen, button_data=button_data, fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, wallet_descriptor_display_name=wallet_descriptor_display_name, script_type=script_type, custom_derivation_path=self.custom_derivation, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -565,21 +566,23 @@ class ToolsAddressExplorerAddressListView(View): def run(self): self.loading_screen = None - try: - addresses = [] - button_data = [] - data = self.controller.address_explorer_data - addrs_per_screen = 10 - addr_storage_key = "receive_addrs" - if self.is_change: - addr_storage_key = "change_addrs" + addresses = [] + button_data = [] + data = self.controller.address_explorer_data + addrs_per_screen = 10 - if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: - # We already calculated this range addresses; just retrieve them - addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] + addr_storage_key = "receive_addrs" + if self.is_change: + addr_storage_key = "change_addrs" - else: + if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: + # We already calculated this range addresses; just retrieve them + addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] + + else: + try: + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Calculating addrs...") self.loading_screen.start() @@ -608,9 +611,15 @@ class ToolsAddressExplorerAddressListView(View): else: raise Exception("Single sig descriptors not yet supported") + finally: + # Everything is set. Stop the loading screen + self.loading_screen.stop() for i, address in enumerate(addresses): cur_index = i + self.start_index + + # Adjust the trailing addr display length based on available room + # (the index number will push it out on each order of magnitude) if cur_index < 10: end_digits = -6 elif cur_index < 100: @@ -621,22 +630,17 @@ class ToolsAddressExplorerAddressListView(View): button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - screen = ButtonListScreen( - title="{} Addrs".format("Receive" if not self.is_change else "Change"), - button_data=button_data, - button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, - is_button_text_centered=False, - is_bottom_list=True, - selected_button=self.selected_button_index, - scroll_y_initial_offset=self.initial_scroll, - ) - finally: - # Everything is set. Stop the loading screen - if self.loading_screen: - self.loading_screen.stop() - - selected_menu_num = screen.display() + selected_menu_num = self.run_screen( + ButtonListScreen, + title="{} Addrs".format("Receive" if not self.is_change else "Change"), + button_data=button_data, + button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, + button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, + is_button_text_centered=False, + is_bottom_list=True, + selected_button=self.selected_button_index, + scroll_y_initial_offset=self.initial_scroll, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -646,7 +650,7 @@ class ToolsAddressExplorerAddressListView(View): return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index + addrs_per_screen)) # Preserve the list's current scroll so we can return to the same spot - initial_scroll = screen.buttons[0].scroll_y + initial_scroll = self.screen.buttons[0].scroll_y index = selected_menu_num + self.start_index return Destination(ToolsAddressExplorerAddressView, view_args=dict(index=index, address=addresses[selected_menu_num], is_change=self.is_change, start_index=self.start_index, parent_initial_scroll=initial_scroll), skip_current_view=True) @@ -664,10 +668,12 @@ class ToolsAddressExplorerAddressView(View): def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen qr_encoder = EncodeQR(qr_type=QRType.BITCOIN_ADDRESS, bitcoin_address=self.address) - QRDisplayScreen( + self.run_screen( + QRDisplayScreen, qr_encoder=qr_encoder, - ).display() + ) # Exiting/Cancelling the QR display screen always returns to the list return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index, selected_button_index=self.index - self.start_index, initial_scroll=self.parent_initial_scroll), skip_current_view=True) diff --git a/tests/base.py b/tests/base.py index dfcac8f3..0a08823a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -19,27 +19,31 @@ from seedsigner.views.view import Destination, View class BaseTest: + @classmethod + def setup_class(cls): + # Ensure there are no on-disk artifacts after running tests. + Settings.SETTINGS_FILENAME = "settings-test.json" + + # Mock out the loading screen so it can't spawn. + patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() + + + @classmethod + def teardown_class(cls): + """ If settings were written to disk, delete """ + import os + try: + os.remove(Settings.SETTINGS_FILENAME) + except: + print(f"{Settings.SETTINGS_FILENAME} not found to be removed") + + def reset_controller(self): """ Wipe and re-initialize the Controller singleton for each test run """ Controller._instance = None Controller.configure_instance() - def setup_class(self): - Settings.SETTINGS_FILENAME = "settings-test.json" - - - def teardown_class(self): - """ If settings were written to disk, delete """ - import os - try: - print(f"attempting to remove {Settings.SETTINGS_FILENAME}") - os.remove(Settings.SETTINGS_FILENAME) - print(f"Removed {Settings.SETTINGS_FILENAME}") - except: - print(f"{Settings.SETTINGS_FILENAME} not found to be removed") - - def setup_method(self): self.reset_controller() self.controller = Controller.get_instance() diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 602c53af..4a834f2c 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -1,34 +1,105 @@ # Must import this before the Controller +from typing import Type + +from mock import MagicMock from base import FlowTest, FlowStep from seedsigner.controller import Controller from seedsigner.models.seed import Seed -from seedsigner.views.view import Destination, MainMenuView -from seedsigner.views import seed_views, scan_views +from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views import scan_views, seed_views, tools_views class TestToolsFlows(FlowTest): - def test_addressexplorer_seedfinalizescreen_flow(self): + def test_addressexplorer_flow(self): + """ + Test the simplest AddressExplorer flow when a seed is already loaded. + """ + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + controller.storage.finalize_pending_seed() + + def mock_addrlistview_screen(view: Type[View]): + # ToolsAddressExplorerAddressListView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=2 # ret TOOLS + ), + FlowStep( + expected_view=tools_views.ToolsMenuView, + screen_return_value=3 # ret EXPLORER + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerSelectSourceView, + screen_return_value=0 # ret 1st onboard seed + ), + FlowStep( + expected_view=seed_views.SeedExportXpubScriptTypeView, + screen_return_value=0 # ret NATIVE_SEGWIT (1st script type option) + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressTypeView, + screen_return_value=0 # ret RECEIVE addrs + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressListView, + screen_return_value=10 # ret NEXT page of addrs + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressListView, + run_before=mock_addrlistview_screen, + screen_return_value=4 # ret a specific addr from the list + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressView, + # QRDisplayScreen runs until dismissed; no ret value + ), + ] + ) + # After dismissing QRDisplayScreen, should return to the addr list view + assert destination.View_cls == tools_views.ToolsAddressExplorerAddressListView + + + def test_addressexplorer_loadseed_sideflow(self): """ Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ - # Set up pending seed leading up to SeedFinalizeView controller = Controller.get_instance() - seed = Seed(mnemonic=["abandon "* 11 + "about"]) - controller.storage.set_pending_seed(seed) - # Set the Address Explorer flow to change routing behavior - controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") # Finalize the new seed w/out passphrase destination = self.run_sequence( - Destination(seed_views.SeedFinalizeView), + Destination(MainMenuView), sequence=[ FlowStep( + screen_return_value=2 # ret TOOLS + ), + FlowStep( + expected_view=tools_views.ToolsMenuView, + screen_return_value=3 # ret EXPLORER + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerSelectSourceView, + screen_return_value=0 # ret SCAN SeedQR + ), + FlowStep( + expected_view=scan_views.ScanView, + run_before=load_seed_into_decoder, # simulate read SeedQR + screen_return_value=None, + ), + FlowStep( + expected_view=seed_views.SeedFinalizeView, screen_return_value=0, # ret DONE ), FlowStep( @@ -39,10 +110,11 @@ class TestToolsFlows(FlowTest): ) # Flow should resume at Script Type selection assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER # Reset controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) + controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) # Finalize the new seed w/passphrase destination = self.run_sequence( From 4f1df7ac428bbe2de7917872fd6441410102a011 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 13:06:41 -0600 Subject: [PATCH 09/42] Slight BaseTest reorg; more comments --- tests/base.py | 37 +++++++++++++++++++++++++++++++------ tests/test_controller.py | 2 +- tests/test_flows_seed.py | 4 ++-- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/base.py b/tests/base.py index 0a08823a..0010cef7 100644 --- a/tests/base.py +++ b/tests/base.py @@ -30,6 +30,17 @@ class BaseTest: @classmethod def teardown_class(cls): + pass + + + @classmethod + def reset_settings(cls): + """ Wipe and re-initialize the Settings singleton """ + Settings._instance = None + + + @classmethod + def remove_settings(cls): """ If settings were written to disk, delete """ import os try: @@ -38,21 +49,29 @@ class BaseTest: print(f"{Settings.SETTINGS_FILENAME} not found to be removed") - def reset_controller(self): - """ Wipe and re-initialize the Controller singleton for each test run """ + @classmethod + def reset_controller(cls): + """ Wipe and re-initialize the Controller singleton """ Controller._instance = None Controller.configure_instance() def setup_method(self): - self.reset_controller() + """ Guarantee a clean/default Controller and Settings state for each test case """ + BaseTest.reset_controller() + BaseTest.reset_settings() self.controller = Controller.get_instance() self.settings = Settings.get_instance() + + + def teardown_method(self): + BaseTest.remove_settings() @dataclass class FlowStep: + """ Trivial helper class to express FlowTest sequences below """ expected_view: Type[View] = None run_before: Callable = None screen_return_value: Union[int, str] = None @@ -60,11 +79,17 @@ class FlowStep: class FlowTest(BaseTest): + """ Base class for any tests that do flow-based testing """ + def run_sequence(self, initial_destination: Destination, sequence: List[FlowStep]) -> Destination: """ - Runs the given sequence from the initial_destination, mocking out the associated - Screen and the scenario's return_value, receiving the resulting new Destination, - and repeating until the sequence is complete. + Runs the given sequence of FlowSteps starting from the initial_destination: + * verifies that we landed on the expected_view (if provided). + * mocks out the `View.run_screen()` to prevent the associated Screen class from instantiating. + * patches in the FlowStep's screen_return_value (as if it came from user interaction). + * optional `run_before` method modifies the View when necessary to be compatible w/test suite limitations. + * Runs the View and receives the resulting next Destination. + * then repeats the process on the next Destination until the sequence is complete. Returns the final Destination. """ diff --git a/tests/test_controller.py b/tests/test_controller.py index fb76b533..df6c37ec 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -15,7 +15,7 @@ class TestController(BaseTest): controller = Controller.get_instance() controller.address_explorer_data = "foo" - self.reset_controller() + BaseTest.reset_controller() controller = Controller.get_instance() assert controller.address_explorer_data is None diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 220e384b..942c9710 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,6 @@ # Must import this before the Controller -from base import FlowTest, FlowStep +from base import BaseTest, FlowTest, FlowStep from seedsigner.views.view import Destination, MainMenuView from seedsigner.views import seed_views, scan_views @@ -83,7 +83,7 @@ class TestSeedFlows(FlowTest): # Test data from iancoleman.io; 12- and 24-word mnemonic test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) - self.reset_controller() + BaseTest.reset_controller() test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split()) From 25cb92a31fd8f42d22fcc3b4931c1ab7975a996e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 19:48:50 -0600 Subject: [PATCH 10/42] Improved integration w/View.button_data options * Modifies Views to make button_data and its options available to external caller (FlowTests). * Adds button_data_selection for tighter integration with Views and better human-readability of FlowStep sequences. * Removes the unique recursive run() call in SettingsEntryUpdateSelectionView. --- src/seedsigner/views/seed_views.py | 94 ++++++++++---------- src/seedsigner/views/settings_views.py | 113 ++++++++++++------------- src/seedsigner/views/tools_views.py | 76 +++++++++-------- src/seedsigner/views/view.py | 56 +++++++----- tests/base.py | 32 +++++-- tests/test_flows_seed.py | 18 ++-- tests/test_flows_settings.py | 43 ++++------ tests/test_flows_tools.py | 24 +++--- tests/test_settings.py | 16 ++++ 9 files changed, 254 insertions(+), 218 deletions(-) create mode 100644 tests/test_settings.py diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 8f16bfac..9e45a1f7 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -71,41 +71,41 @@ class SeedsMenuView(View): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): - def run(self): - SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) - button_data=[ - SEED_QR, - TYPE_12WORD, - TYPE_24WORD, - CREATE, - ] + SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) + button_data=[ + SEED_QR, + TYPE_12WORD, + TYPE_24WORD, + CREATE, + ] + def run(self): selected_menu_num = self.run_screen( ButtonListScreen, title="Load A Seed", is_button_text_centered=False, - button_data=button_data + button_data=self.button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SEED_QR: + if self.button_data[selected_menu_num] == self.SEED_QR: from .scan_views import ScanView return Destination(ScanView) - elif button_data[selected_menu_num] == TYPE_12WORD: + elif self.button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == TYPE_24WORD: + elif self.button_data[selected_menu_num] == self.TYPE_24WORD: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == CREATE: + elif self.button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -169,62 +169,60 @@ class SeedMnemonicEntryView(View): class SeedMnemonicInvalidView(View): + EDIT = "Review & Edit" + DISCARD = ("Discard", None, None, "red") + button_data = [EDIT, DISCARD] + def __init__(self): super().__init__() self.mnemonic: List[str] = self.controller.storage.pending_mnemonic def run(self): - EDIT = "Review & Edit" - DISCARD = ("Discard", None, None, "red") - button_data = [EDIT, DISCARD] - selected_menu_num = self.run_screen( WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, - button_data=button_data, + button_data=self.button_data, ) - if button_data[selected_menu_num] == EDIT: + if self.button_data[selected_menu_num] == self.EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) - elif button_data[selected_menu_num] == DISCARD: + elif self.button_data[selected_menu_num] == self.DISCARD: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) class SeedFinalizeView(View): + FINALIZE = "Done" + PASSPHRASE = "BIP-39 Passphrase" + button_data = [FINALIZE] + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: + self.button_data.append(self.PASSPHRASE) + def run(self): - FINALIZE = "Done" - PASSPHRASE = "BIP-39 Passphrase" - button_data = [] - - button_data.append(FINALIZE) - - if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - button_data.append(PASSPHRASE) - selected_menu_num = self.run_screen( seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, - button_data=button_data, + button_data=self.button_data, ) - if button_data[selected_menu_num] == FINALIZE: + if self.button_data[selected_menu_num] == self.FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - elif button_data[selected_menu_num] == PASSPHRASE: + elif self.button_data[selected_menu_num] == self.PASSPHRASE: return Destination(SeedAddPassphraseView) @@ -254,16 +252,16 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ + EDIT = "Edit passphrase" + DONE = "Done" + button_data = [EDIT, DONE] + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - EDIT = "Edit passphrase" - DONE = "Done" - button_data = [EDIT, DONE] - # Get the before/after fingerprints network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) passphrase = self.seed.passphrase @@ -279,14 +277,14 @@ class SeedReviewPassphraseView(View): fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, - button_data=button_data, + button_data=self.button_data, show_back_button=False, ) - if button_data[selected_menu_num] == EDIT: + if self.button_data[selected_menu_num] == self.EDIT: return Destination(SeedAddPassphraseView) - elif button_data[selected_menu_num] == DONE: + elif self.button_data[selected_menu_num] == self.DONE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) @@ -498,6 +496,10 @@ class SeedExportXpubScriptTypeView(View): self.seed_num = seed_num self.sig_type = sig_type + self.button_data = [] + for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): + self.button_data.append(script_type) + def run(self): from .tools_views import ToolsAddressExplorerAddressTypeView @@ -511,10 +513,6 @@ class SeedExportXpubScriptTypeView(View): return Destination(ToolsAddressExplorerAddressTypeView, view_args=args, skip_current_view=True) else: return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True) - - button_data = [] - for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): - button_data.append(script_type) title = "Export Xpub" if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: @@ -524,7 +522,7 @@ class SeedExportXpubScriptTypeView(View): ButtonListScreen, title=title, is_button_text_centered=False, - button_data=button_data, + button_data=self.button_data, is_bottom_list=True, ) @@ -536,7 +534,7 @@ class SeedExportXpubScriptTypeView(View): else: script_types_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) - selected_display_name = button_data[selected_menu_num] + selected_display_name = self.button_data[selected_menu_num] args["script_type"] = script_types_settings_entry.get_selection_option_value_by_display_name(selected_display_name) if args["script_type"] == SettingsConstants.CUSTOM_DERIVATION: diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index b458ea0c..929c3c81 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -8,6 +8,9 @@ from seedsigner.models.settings import SettingsConstants, SettingsDefinition class SettingsMenuView(View): + IO_TEST = "I/O test" + DONATE = "Donate" + def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): super().__init__() self.visibility = visibility @@ -16,51 +19,48 @@ class SettingsMenuView(View): # Used to preserve the rendering position in the list self.initial_scroll = initial_scroll - - def run(self): - IO_TEST = "I/O test" - DONATE = "Donate" - - settings_entries = SettingsDefinition.get_settings_entries( + self.settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) - button_data=[e.display_name for e in settings_entries] + self.button_data=[e.display_name for e in self.settings_entries] - selected_button = 0 + self.selected_button = 0 if self.selected_attr: - for i, entry in enumerate(settings_entries): + for i, entry in enumerate(self.settings_entries): if entry.attr_name == self.selected_attr: - selected_button = i + self.selected_button = i break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: - title = "Settings" + self.title = "Settings" # Set up the next nested level of menuing - button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) + self.button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - button_data.append(IO_TEST) - button_data.append(DONATE) + self.button_data.append(self.IO_TEST) + self.button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: - title = "Advanced" + self.title = "Advanced" # So far there are no real Developer options; disabling for now - # button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - # next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) - next = None + # self.button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + # self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) + self.next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: - title = "Dev Options" - next = None + self.title = "Dev Options" + self.next_destination = None + + def run(self): selected_menu_num = self.run_screen( ButtonListScreen, - title=title, + title=self.title, is_button_text_centered=False, - button_data=button_data, - selected_button=selected_button, + button_data=self.button_data, + selected_button=self.selected_button, scroll_y_initial_offset=self.initial_scroll, ) @@ -75,17 +75,17 @@ class SettingsMenuView(View): else: return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - elif selected_menu_num == len(settings_entries): - return next + elif selected_menu_num == len(self.settings_entries): + return self.next_destination - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST: + elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == DONATE: + elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) else: - return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) @@ -94,48 +94,44 @@ class SettingsEntryUpdateSelectionView(View): Handles changes to all selection-type settings (Multiselect, SELECT_1, Enabled/Disabled, etc). """ - def __init__(self, attr_name: str, parent_initial_scroll: int = 0): + def __init__(self, attr_name: str, parent_initial_scroll: int = 0, selected_button: int = None): super().__init__() self.settings_entry = SettingsDefinition.get_settings_entry(attr_name) - self.selected_button = None + self.selected_button = selected_button self.parent_initial_scroll = parent_initial_scroll - - # hook for test suite to override recursion in run() - self.is_test = False - - def run(self): - initial_value = self.settings.get_value(self.settings_entry.attr_name) - button_data = [] - checked_buttons = [] + self.button_data = [] + self.checked_buttons = [] + self.initial_value = self.settings.get_value(self.settings_entry.attr_name) for i, value in enumerate(self.settings_entry.selection_options): if type(value) == tuple: value, display_name = value else: display_name = value - button_data.append(display_name) - if (type(initial_value) == list and value in initial_value) or value == initial_value: - checked_buttons.append(i) + + self.button_data.append(display_name) + + if (type(self.initial_value) == list and value in self.initial_value) or value == self.initial_value: + self.checked_buttons.append(i) if self.selected_button is None: # Highlight the selection (for multiselect highlight the first # selected option). self.selected_button = i - if not self.selected_button: + if self.selected_button is None: self.selected_button = 0 - screen_args = dict( - display_name=self.settings_entry.display_name, - help_text=self.settings_entry.help_text, - button_data=button_data, - selected_button=self.selected_button, - checked_buttons=checked_buttons, - settings_entry_type=self.settings_entry.type, - ) + + def run(self): ret_value = self.run_screen( settings_screens.SettingsEntryUpdateSelectionScreen, - **screen_args + display_name=self.settings_entry.display_name, + help_text=self.settings_entry.help_text, + button_data=self.button_data, + selected_button=self.selected_button, + checked_buttons=self.checked_buttons, + settings_entry_type=self.settings_entry.type, ) destination = None @@ -158,8 +154,8 @@ class SettingsEntryUpdateSelectionView(View): destination = settings_menu_view_destination elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT: - updated_value = list(initial_value) - if ret_value not in checked_buttons: + updated_value = list(self.initial_value) + if ret_value not in self.checked_buttons: # This is a new selection to add updated_value.append(value) else: @@ -168,7 +164,7 @@ class SettingsEntryUpdateSelectionView(View): else: # All other types are single selects (e.g. Enabled/Disabled, SELECT_1) - if value == initial_value: + if value == self.initial_value: # No change, return to menu return settings_menu_view_destination else: @@ -185,12 +181,7 @@ class SettingsEntryUpdateSelectionView(View): # All selects stay in place; re-initialize where in the list we left off self.selected_button = ret_value - if self.is_test: - # Test suite can't handle the recursive call. Fake it by returning us here - # via a Destination back to this View. - return Destination(self.__class__, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll)) - else: - return self.run() + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll, selected_button=self.selected_button), skip_current_view=True) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 20b7309b..572b996a 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -23,33 +23,38 @@ from .view import View, Destination, BackStackView class ToolsMenuView(View): + IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) + DICE = ("New seed", FontAwesomeIconConstants.DICE) + KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) + EXPLORER = "Address Explorer" + + def __init__(self): + super().__init__() + self.button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] + + def run(self): - IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) - DICE = ("New seed", FontAwesomeIconConstants.DICE) - KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) - EXPLORER = "Address Explorer" - button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] selected_menu_num = self.run_screen( ButtonListScreen, title="Tools", is_button_text_centered=False, - button_data=button_data + button_data=self.button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == IMAGE: + elif self.button_data[selected_menu_num] == self.IMAGE: return Destination(ToolsImageEntropyLivePreviewView) - elif button_data[selected_menu_num] == DICE: + elif self.button_data[selected_menu_num] == self.DICE: return Destination(ToolsDiceEntropyMnemonicLengthView) - elif button_data[selected_menu_num] == KEYBOARD: + elif self.button_data[selected_menu_num] == self.KEYBOARD: return Destination(ToolsCalcFinalWordNumWordsView) - elif button_data[selected_menu_num] == EXPLORER: + elif self.button_data[selected_menu_num] == self.EXPLORER: return Destination(ToolsAddressExplorerSelectSourceView) @@ -422,27 +427,27 @@ class ToolsCalcFinalWordDoneView(View): Address Explorer Views ****************************************************************************""" class ToolsAddressExplorerSelectSourceView(View): - def run(self): - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - SCAN_DESCRIPTOR = ("Scan wallet descriptor", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] + SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) + SCAN_DESCRIPTOR = ("Scan wallet descriptor", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + button_data = [SCAN_SEED, SCAN_DESCRIPTOR, TYPE_12WORD, TYPE_24WORD] - seeds = self.controller.storage.seeds - for seed in seeds: + def __init__(self): + super().__init__() + self.seeds = self.controller.storage.seeds + seed_buttons =[] + for seed in self.seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + seed_buttons.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + self.button_data = seed_buttons + self.button_data - button_data.append(SCAN_SEED) - button_data.append(SCAN_DESCRIPTOR) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) + def run(self): selected_menu_num = self.run_screen( ButtonListScreen, title="Address Explorer", - button_data=button_data, + button_data=self.button_data, is_button_text_centered=False, is_bottom_list=True, ) @@ -455,7 +460,7 @@ class ToolsAddressExplorerSelectSourceView(View): # knows to re-route us once the side flow is complete. self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER - if len(seeds) > 0 and selected_menu_num < len(seeds): + if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): # User selected one of the n seeds return Destination( SeedExportXpubScriptTypeView, @@ -465,13 +470,13 @@ class ToolsAddressExplorerSelectSourceView(View): ) ) - elif button_data[selected_menu_num] in [SCAN_SEED, SCAN_DESCRIPTOR]: + elif self.button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: + elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: + if self.button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -480,6 +485,11 @@ class ToolsAddressExplorerSelectSourceView(View): class ToolsAddressExplorerAddressTypeView(View): + RECEIVE = "Receive Addresses" + CHANGE = "Change Addresses" + button_data = [RECEIVE, CHANGE] + + def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None): """ If the explorer source is a seed, `seed_num` and `script_type` must be @@ -528,10 +538,6 @@ class ToolsAddressExplorerAddressTypeView(View): def run(self): data = self.controller.address_explorer_data - RECEIVE = "Receive Addresses" - CHANGE = "Change Addresses" - button_data = [RECEIVE, CHANGE] - wallet_descriptor_display_name = None if "wallet_descriptor" in data: wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "") @@ -540,7 +546,7 @@ class ToolsAddressExplorerAddressTypeView(View): selected_menu_num = self.run_screen( ToolsAddressExplorerAddressTypeScreen, - button_data=button_data, + button_data=self.button_data, fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, wallet_descriptor_display_name=wallet_descriptor_display_name, script_type=script_type, @@ -550,8 +556,8 @@ class ToolsAddressExplorerAddressTypeView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] in [RECEIVE, CHANGE]: - return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == CHANGE)) + elif self.button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.button_data[selected_menu_num] == self.CHANGE)) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 4e7e5db9..495d5c05 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -56,7 +56,6 @@ class View: self.canvas_width = self.renderer.canvas_width self.canvas_height = self.renderer.canvas_height - self.buttons = self.controller.buttons self.screen = None @@ -100,20 +99,22 @@ class Destination: return out - def run(self, run_before: Callable = None): + def _instantiate_view(self): if not self.view_args: # Can't unpack (**) None so we replace with an empty dict self.view_args = {} # Instantiate the `View_cls` with the `view_args` dict - view = self.View_cls(**self.view_args) + self.view = self.View_cls(**self.view_args) + - # Enable test suite modifications to the View before running it - if run_before: - run_before(view) + def _run_view(self): + return self.view.run() - # and run() it - return view.run() + + def run(self): + self._instantiate_view() + return self._run_view() def __eq__(self, obj): @@ -136,23 +137,22 @@ class Destination: # ######################################################################################### class MainMenuView(View): - def run(self): - from .seed_views import SeedsMenuView - from .settings_views import SettingsMenuView - from .scan_views import ScanView - from .tools_views import ToolsMenuView - menu_items = [ - (("Scan", FontAwesomeIconConstants.QRCODE), ScanView), - (("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView), - (("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView), - (("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView), - ] + SCAN = ("Scan", FontAwesomeIconConstants.QRCODE) + SEEDS = ("Seeds", FontAwesomeIconConstants.KEY) + TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH) + SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR) + def __init__(self): + super().__init__() + self.button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] + + + def run(self): selected_menu_num = self.run_screen( LargeButtonScreen, title="Home", title_font_size=26, - button_data=[entry[0] for entry in menu_items], + button_data=self.button_data, show_back_button=False, show_power_button=True, ) @@ -160,7 +160,21 @@ class MainMenuView(View): if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) - return Destination(menu_items[selected_menu_num][1]) + if self.button_data[selected_menu_num] == self.SCAN: + from .scan_views import ScanView + return Destination(ScanView) + + elif self.button_data[selected_menu_num] == self.SEEDS: + from .seed_views import SeedsMenuView + return Destination(SeedsMenuView) + + elif self.button_data[selected_menu_num] == self.TOOLS: + from .tools_views import ToolsMenuView + return Destination(ToolsMenuView) + + elif self.button_data[selected_menu_num] == self.SETTINGS: + from .settings_views import SettingsMenuView + return Destination(SettingsMenuView) diff --git a/tests/base.py b/tests/base.py index 0010cef7..48e97738 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,7 +1,7 @@ import sys from dataclasses import dataclass from mock import MagicMock, patch -from typing import Callable, List, Type, Union +from typing import Callable, List, Tuple, Type, Union # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. @@ -37,6 +37,7 @@ class BaseTest: def reset_settings(cls): """ Wipe and re-initialize the Settings singleton """ Settings._instance = None + BaseTest.remove_settings() @classmethod @@ -71,10 +72,22 @@ class BaseTest: @dataclass class FlowStep: - """ Trivial helper class to express FlowTest sequences below """ + """ + Trivial helper class to express FlowTest sequences below. + + * expected_view: verify that the next step in the sequence ended up at the right View. + * run_before: function that takes a View instance as an arg and modifies it before running the View. + * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. + * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. + """ expected_view: Type[View] = None run_before: Callable = None - screen_return_value: Union[int, str] = None + screen_return_value: Union[int,str] = None + button_data_selection: Union[str,Tuple] = None + + def __post_init__(self): + if self.screen_return_value is not None and self.button_data_selection is not None: + raise Exception("Should only specify screen_return_value or button_data_selection") @@ -87,6 +100,7 @@ class FlowTest(BaseTest): * verifies that we landed on the expected_view (if provided). * mocks out the `View.run_screen()` to prevent the associated Screen class from instantiating. * patches in the FlowStep's screen_return_value (as if it came from user interaction). + * OR retrieves the index number of the specified button_data_selection and provides that as the Screen return value. * optional `run_before` method modifies the View when necessary to be compatible w/test suite limitations. * Runs the View and receives the resulting next Destination. * then repeats the process on the next Destination until the sequence is complete. @@ -103,10 +117,18 @@ class FlowTest(BaseTest): # Patch the `View.run_screen()` so we don't actually instantiate the Screen qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: - mock_run_screen.return_value = flow_step.screen_return_value + next_destination._instantiate_view() + + if flow_step.run_before: + flow_step.run_before(next_destination.view) + + if flow_step.button_data_selection: + mock_run_screen.return_value = next_destination.view.button_data.index(flow_step.button_data_selection) + else: + mock_run_screen.return_value = flow_step.screen_return_value # Now we can run the View and grab its resulting next Destination - next_destination = next_destination.run(run_before=flow_step.run_before) + next_destination = next_destination._run_view() except Exception as e: import traceback traceback.print_exc() diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 942c9710..17303cf9 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -21,16 +21,16 @@ class TestSeedFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=0, # ret SCAN + button_data_selection=MainMenuView.SCAN ), FlowStep( expected_view=scan_views.ScanView, run_before=load_seed_into_decoder, # simulate read SeedQR - screen_return_value=None, + # ret value is ignored ), FlowStep( expected_view=seed_views.SeedFinalizeView, - screen_return_value=0, # ret DONE + button_data_selection=seed_views.SeedFinalizeView.FINALIZE ), ] ) @@ -45,7 +45,7 @@ class TestSeedFlows(FlowTest): def test_with_mnemonic(mnemonic): sequence = [ FlowStep( - screen_return_value=1, # ret SEEDS + button_data_selection=MainMenuView.SEEDS ), FlowStep( expected_view=seed_views.SeedsMenuView, @@ -53,7 +53,7 @@ class TestSeedFlows(FlowTest): ), FlowStep( expected_view=seed_views.LoadSeedView, - screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, ), ] @@ -70,7 +70,7 @@ class TestSeedFlows(FlowTest): sequence.append( FlowStep( expected_view=seed_views.SeedFinalizeView, - screen_return_value=0, # ret DONE + button_data_selection=seed_views.SeedFinalizeView.FINALIZE ) ) @@ -94,7 +94,7 @@ class TestSeedFlows(FlowTest): mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() sequence = [ FlowStep( - screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, ), ] for word in mnemonic[:-1]: @@ -112,7 +112,7 @@ class TestSeedFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedMnemonicInvalidView, - screen_return_value=0 # ret EDIT + button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT ), ] @@ -132,7 +132,7 @@ class TestSeedFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedMnemonicInvalidView, - screen_return_value=1 # ret DISCARD; this time we give up + button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD ), ] diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 33c8e7a9..fd373ff1 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -17,30 +17,31 @@ class TestSettingsFlows(FlowTest): def test_persistent_settings(self): """ Basic flow from MainMenuView to enable/disable persistent settings """ # Which option are we testing? - settings_entries = SettingsDefinition.get_settings_entries() - nth_entry = [s.attr_name for s in settings_entries].index(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) def mock_settingsmenuview_screen(view: Type[View]): # SettingsMenuView reaches into its `self.screen` so we need to mock it out view.screen = MagicMock() - + destination = self.run_sequence( Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret persistent settings + button_data_selection=settings_entry.display_name ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=[s[0] for s in settings_entry.selection_options].index(SettingsConstants.OPTION__ENABLED), + button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED), ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + screen_return_value=RET_CODE__BACK_BUTTON + ) ] ) assert destination.View_cls == settings_views.SettingsMenuView @@ -50,41 +51,32 @@ class TestSettingsFlows(FlowTest): """ Multiselect Settings options should stay in-place; requires BACK to exit. """ # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - nth_entry = SettingsDefinition.get_settings_entries().index(settings_entry) def mock_settingsmenuview_screen(view: Type[View]): # SettingsMenuView reaches into its `self.screen` so we need to mock it out view.screen = MagicMock() - def disable_recursion(view: Type[View]): - # The recursion in SettingsEntryUpdateSelectionView for multiselect settings - # won't work with our FlowTest sequence structure. - view.is_test = True - destination = self.run_sequence( Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret Coordinator software + button_data_selection=settings_entry.display_name ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - run_before=disable_recursion, screen_return_value=0, # select/deselect first option ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - run_before=disable_recursion, screen_return_value=1, # select/deselect second option ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - run_before=disable_recursion, screen_return_value=1, # select/deselect second option ), FlowStep( @@ -98,9 +90,6 @@ class TestSettingsFlows(FlowTest): def test_io_test(self): """ Basic flow from MainMenuView to I/O Test View """ - # I/O Test will be the nth_entry after we include the submenu to "Advanced" - nth_entry = len(SettingsDefinition.get_settings_entries()) + 1 - def mock_settingsmenuview_screen(view: Type[View]): # SettingsMenuView reaches into its `self.screen` so we need to mock it out view.screen = MagicMock() @@ -109,16 +98,16 @@ class TestSettingsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS, ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret I/O Test + button_data_selection=settings_views.SettingsMenuView.IO_TEST ), FlowStep( expected_view=settings_views.IOTestView, - screen_return_value=None, # ret value is ignored + # ret value is ignored ), ] ) @@ -140,16 +129,16 @@ class TestSettingsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS, ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret Donate + button_data_selection=settings_views.SettingsMenuView.DONATE ), FlowStep( expected_view=settings_views.DonateView, - screen_return_value=None, # ret value is ignored + # ret value is ignored ), ] ) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 4a834f2c..39f07961 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -2,11 +2,12 @@ # Must import this before the Controller from typing import Type -from mock import MagicMock +from mock import MagicMock, Mock from base import FlowTest, FlowStep from seedsigner.controller import Controller from seedsigner.models.seed import Seed +from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views.view import Destination, MainMenuView, View from seedsigner.views import scan_views, seed_views, tools_views @@ -31,11 +32,11 @@ class TestToolsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=2 # ret TOOLS + button_data_selection=MainMenuView.TOOLS ), FlowStep( expected_view=tools_views.ToolsMenuView, - screen_return_value=3 # ret EXPLORER + button_data_selection=tools_views.ToolsMenuView.EXPLORER ), FlowStep( expected_view=tools_views.ToolsAddressExplorerSelectSourceView, @@ -43,11 +44,11 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedExportXpubScriptTypeView, - screen_return_value=0 # ret NATIVE_SEGWIT (1st script type option) + button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT) ), FlowStep( expected_view=tools_views.ToolsAddressExplorerAddressTypeView, - screen_return_value=0 # ret RECEIVE addrs + button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE ), FlowStep( expected_view=tools_views.ToolsAddressExplorerAddressListView, @@ -83,24 +84,23 @@ class TestToolsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=2 # ret TOOLS + button_data_selection=MainMenuView.TOOLS ), FlowStep( expected_view=tools_views.ToolsMenuView, - screen_return_value=3 # ret EXPLORER + button_data_selection=tools_views.ToolsMenuView.EXPLORER ), FlowStep( expected_view=tools_views.ToolsAddressExplorerSelectSourceView, - screen_return_value=0 # ret SCAN SeedQR + button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED ), FlowStep( expected_view=scan_views.ScanView, run_before=load_seed_into_decoder, # simulate read SeedQR - screen_return_value=None, ), FlowStep( expected_view=seed_views.SeedFinalizeView, - screen_return_value=0, # ret DONE + button_data_selection=seed_views.SeedFinalizeView.FINALIZE ), FlowStep( expected_view=seed_views.SeedOptionsView, @@ -121,7 +121,7 @@ class TestToolsFlows(FlowTest): Destination(seed_views.SeedFinalizeView), sequence=[ FlowStep( - screen_return_value=1, # ret PASSPHRASE + button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE ), FlowStep( expected_view=seed_views.SeedAddPassphraseView, @@ -129,7 +129,7 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedReviewPassphraseView, - screen_return_value=1, # ret DONE + button_data_selection=seed_views.SeedReviewPassphraseView.DONE ), FlowStep( expected_view=seed_views.SeedOptionsView, diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..891cce1d --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,16 @@ +from base import BaseTest +from seedsigner.models.settings import Settings +from seedsigner.models.settings_definition import SettingsConstants + + +class TestSettings(BaseTest): + + def test_reset_settings(self): + """ BaseTest.reset_settings() should wipe out any previous Settings changes """ + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED) + assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED + + BaseTest.reset_settings() + settings = Settings.get_instance() + assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED From 1ff844062df1b315f984b27781eba8fb49371501 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 12:59:12 -0500 Subject: [PATCH 11/42] Cleanup, beginning to split requirements.txt --- requirements-raspi.txt | 3 +++ requirements.txt | 3 --- src/seedsigner/views/seed_views.py | 2 +- tests/base.py | 10 +++++++--- tests/test_flows_settings.py | 26 +------------------------- 5 files changed, 12 insertions(+), 32 deletions(-) create mode 100644 requirements-raspi.txt diff --git a/requirements-raspi.txt b/requirements-raspi.txt new file mode 100644 index 00000000..8053eeab --- /dev/null +++ b/requirements-raspi.txt @@ -0,0 +1,3 @@ +picamera==1.13 +RPi.GPIO==0.7.0 +spidev==3.5 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 34cf5663..e24650d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,7 @@ embit==0.7.0 numpy==1.21.1 -picamera==1.13 Pillow==8.2.0 -e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar qrcode==7.3.1 -RPi.GPIO==0.7.0 six==1.16.0 -spidev==3.5 urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 9e45a1f7..f796a93f 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -270,7 +270,7 @@ class SeedReviewPassphraseView(View): fingerprint_without = self.seed.get_fingerprint(network=network) self.seed.set_passphrase(passphrase) - # Because we have ane explicit "Edit" button, we disable "BACK" to keep the + # Because we have an explicit "Edit" button, we disable "BACK" to keep the # routing options sane. selected_menu_num = self.run_screen( seed_screens.SeedReviewPassphraseScreen, diff --git a/tests/base.py b/tests/base.py index 48e97738..568f8d30 100644 --- a/tests/base.py +++ b/tests/base.py @@ -119,14 +119,18 @@ class FlowTest(BaseTest): with patch(qualname + ".run_screen") as mock_run_screen: next_destination._instantiate_view() - if flow_step.run_before: - flow_step.run_before(next_destination.view) - + # A few Views need to reference their Screen; need to mock it out + # since we're not actually instantiating it. + next_destination.view.screen = MagicMock() + if flow_step.button_data_selection: mock_run_screen.return_value = next_destination.view.button_data.index(flow_step.button_data_selection) else: mock_run_screen.return_value = flow_step.screen_return_value + if flow_step.run_before: + flow_step.run_before(next_destination.view) + # Now we can run the View and grab its resulting next Destination next_destination = next_destination._run_view() except Exception as e: diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index fd373ff1..8c68d36a 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -19,10 +19,6 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -31,7 +27,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_entry.display_name ), FlowStep( @@ -52,10 +47,6 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -64,7 +55,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_entry.display_name ), FlowStep( @@ -90,10 +80,6 @@ class TestSettingsFlows(FlowTest): def test_io_test(self): """ Basic flow from MainMenuView to I/O Test View """ - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -102,7 +88,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_views.SettingsMenuView.IO_TEST ), FlowStep( @@ -116,15 +101,7 @@ class TestSettingsFlows(FlowTest): def test_donate(self): - """ Basic flow from MainMenuView to Donate View """ - # DonateView will be the nth_entry after we include the submenu to "Advanced" - # and the I/O Test - nth_entry = len(SettingsDefinition.get_settings_entries()) + 2 - - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - + """ Basic flow from MainMenuView to Donate View """ destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -133,7 +110,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_views.SettingsMenuView.DONATE ), FlowStep( From 320a7990cdb06579050a23fe201cfe2790985994 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 13:07:46 -0500 Subject: [PATCH 12/42] Documentation update for splitting dependencies --- docs/manual_installation.md | 1 + tests/README.md | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/manual_installation.md b/docs/manual_installation.md index bec76f2c..9f253cf0 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -196,6 +196,7 @@ where `pr_123` is any name you want to give to the new branch in your local repo ### Install Python `pip` dependencies: ```bash pip install -r requirements.txt +pip install -r requirements-raspi.txt ``` #### `pyzbar` diff --git a/tests/README.md b/tests/README.md index ae8679c6..fe2181fe 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,7 +1,13 @@ # Running Tests -You must install the test-runner dependencies: -``` +The tests are designed to be run on non-Raspi hardware. + +On your testing machine you'll have to install: +```bash +# general dependencies +pip3 install -r requirements.txt + +# test suite dependencies pip3 install -r tests/requirements.txt ``` From 6f2f68368b426aca692e37599d2d2b84e253fa27 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 13:46:29 -0500 Subject: [PATCH 13/42] Removing Pillow 9.x change --- src/seedsigner/helpers/qr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index 333910b0..b986c6d7 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -48,6 +48,6 @@ class QR: # if qrencode fails, fall back to only encoder if rv != 0: return self.qrimage(data,width,height,border) - img = Image.open("/tmp/qrcode.png").resize((width,height), Image.Resampling.NEAREST).convert("RGBA") + img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") return img From b46a0be92b72f924a41f37d7866e59e30da0681e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 30 Mar 2023 20:54:24 -0500 Subject: [PATCH 14/42] Flow-based tests now incorporate the Controller's main loop --- src/seedsigner/controller.py | 39 ++++++++- src/seedsigner/views/seed_views.py | 114 +++++++++++++------------ src/seedsigner/views/tools_views.py | 28 +++--- src/seedsigner/views/view.py | 21 +++-- tests/base.py | 120 ++++++++++++++++---------- tests/test_flows.py | 79 +++++++++++++++++ tests/test_flows_seed.py | 109 ++++++------------------ tests/test_flows_settings.py | 127 +++++++++------------------- tests/test_flows_tools.py | 124 +++++++-------------------- 9 files changed, 366 insertions(+), 395 deletions(-) create mode 100644 tests/test_flows.py diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 4976d6f7..44509227 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -32,6 +32,15 @@ class BackStack(List[Destination]): +class StopControllerCommand(Exception): + """ + This is a special exception that is raised by the test suite to stop the + Controller's main loop. It is not meant to be raised by any other code. + """ + pass + + + class Controller(Singleton): """ The Controller is a globally available singleton that maintains SeedSigner state. @@ -185,7 +194,13 @@ class Controller(Singleton): self.back_stack = BackStack() - def start(self) -> None: + def start(self, initial_destination: Destination = None) -> None: + """ + The main loop of the application. + + * initial_destination: The first View to run. If None, the MainMenuView is + used. Only used by the test suite. + """ from .views import MainMenuView, BackStackView from .views.screensaver import OpeningSplashScreen @@ -218,7 +233,10 @@ class Controller(Singleton): View_cls(**init_args).run() """ try: - next_destination = Destination(MainMenuView) + if initial_destination: + next_destination = initial_destination + else: + next_destination = Destination(MainMenuView) while True: # Destination(None) is a special case; render the Home screen if next_destination.View_cls is None: @@ -240,10 +258,27 @@ class Controller(Singleton): print(f"back_stack: {self.back_stack}") try: + # Instantiate the View class and run it print(f"Executing {next_destination}") next_destination = next_destination.run() + + except StopControllerCommand: + # This is a special exception that is only raised by the test suite + # to stop the Controller loop and exit the test. + print("StopControllerCommand received; ending Controller loop.") + return + + except AssertionError as e: + # Only raised by the test suite via FlowDidNotExpectView + print(f"Test suite caught {e.__class__.__name__}: {e}") + + # Re-raise so that the test suite can catch it + raise e + except Exception as e: # Display user-friendly error screen w/debugging info + import traceback + traceback.print_exc() next_destination = self.handle_exception(e) if not next_destination: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index f796a93f..0da13470 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -334,65 +334,66 @@ class SeedDiscardView(View): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): + SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE) + VERIFY_ADDRESS = "Verify Addr" + EXPORT_XPUB = "Export Xpub" + EXPLORER = "Address Explorer" + BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT) + BIP85_CHILD_SEED = "BIP-85 Child Seed" + DISCARD = ("Discard Seed", None, None, "red") + + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num self.seed = self.controller.get_seed(self.seed_num) + self.button_data = [] + + if self.controller.unverified_address: + addr = self.controller.unverified_address["address"][:7] + self.VERIFY_ADDRESS += f" {addr}" + self.button_data.append(self.VERIFY_ADDRESS) + + self.button_data.append(self.SCAN_PSBT) + + if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: + self.button_data.append(self.EXPORT_XPUB) + + self.button_data.append(self.EXPLORER) + self.button_data.append(self.BACKUP) + + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: + self.button_data.append(self.BIP85_CHILD_SEED) + + self.button_data.append(self.DISCARD) def run(self): from seedsigner.views.psbt_views import PSBTOverviewView - SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE) - VERIFY_ADDRESS = "Verify Addr" - EXPORT_XPUB = "Export Xpub" - EXPLORER = "Address Explorer" - BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT) - BIP85_CHILD_SEED = "BIP-85 Child Seed" - DISCARD = ("Discard Seed", None, None, "red") - - button_data = [] - - if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: - # Jump straight back into the address explorer script type selection flow - # But do ont cancel the `resume_main_flow` as we'll still need that after - # derivation path is specified. - return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True) - if self.controller.unverified_address: if self.controller.resume_main_flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: # Jump straight back into the single sig addr verification flow self.controller.resume_main_flow = None return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num), skip_current_view=True) - addr = self.controller.unverified_address["address"][:7] - VERIFY_ADDRESS += f" {addr}" - button_data.append(VERIFY_ADDRESS) - + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + # Jump straight back into the address explorer script type selection flow + # But don't cancel the `resume_main_flow` as we'll still need that after + # derivation path is specified. + return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True) + if self.controller.psbt: - if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): - if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: - # Re-route us directly back to the start of the PSBT flow - self.controller.resume_main_flow = None - self.controller.psbt_seed = self.seed - return Destination(PSBTOverviewView, skip_current_view=True) - - button_data.append(SCAN_PSBT) - - if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: - button_data.append(EXPORT_XPUB) - - button_data.append(EXPLORER) - button_data.append(BACKUP) - - if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: - button_data.append(BIP85_CHILD_SEED) - - button_data.append(DISCARD) + if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): + if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: + # Re-route us directly back to the start of the PSBT flow + self.controller.resume_main_flow = None + self.controller.psbt_seed = self.seed + return Destination(PSBTOverviewView, skip_current_view=True) selected_menu_num = self.run_screen( seed_screens.SeedOptionsScreen, - button_data=button_data, + button_data=self.button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, ) @@ -401,33 +402,37 @@ class SeedOptionsView(View): # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if button_data[selected_menu_num] == SCAN_PSBT: + if self.button_data[selected_menu_num] == self.SCAN_PSBT: from seedsigner.views.scan_views import ScanView self.controller.psbt_seed = self.controller.get_seed(self.seed_num) return Destination(ScanView) - elif button_data[selected_menu_num] == VERIFY_ADDRESS: + elif self.button_data[selected_menu_num] == self.VERIFY_ADDRESS: return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == EXPORT_XPUB: + elif self.button_data[selected_menu_num] == self.EXPORT_XPUB: return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == EXPLORER: + elif self.button_data[selected_menu_num] == self.EXPLORER: self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG)) - elif button_data[selected_menu_num] == BACKUP: + elif self.button_data[selected_menu_num] == self.BACKUP: return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == BIP85_CHILD_SEED: + elif self.button_data[selected_menu_num] == self.BIP85_CHILD_SEED: return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_num] == DISCARD: + elif self.button_data[selected_menu_num] == self.DISCARD: return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num)) class SeedBackupView(View): + VIEW_WORDS = "View Seed Words" + EXPORT_SEEDQR = "Export as SeedQR" + button_data = [VIEW_WORDS, EXPORT_SEEDQR] + def __init__(self, seed_num): super().__init__() self.seed_num = seed_num @@ -435,23 +440,20 @@ class SeedBackupView(View): def run(self): - VIEW_WORDS = "View Seed Words" - EXPORT_SEEDQR = "Export as SeedQR" - button_data = [VIEW_WORDS, EXPORT_SEEDQR] - - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Backup Seed", - button_data=button_data, + button_data=self.button_data, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == VIEW_WORDS: + elif self.button_data[selected_menu_num] == self.VIEW_WORDS: return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_num] == EXPORT_SEEDQR: + elif self.button_data[selected_menu_num] == self.EXPORT_SEEDQR: return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 572b996a..b453bebc 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -27,14 +27,9 @@ class ToolsMenuView(View): DICE = ("New seed", FontAwesomeIconConstants.DICE) KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" - - def __init__(self): - super().__init__() - self.button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] - + button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] def run(self): - selected_menu_num = self.run_screen( ButtonListScreen, title="Tools", @@ -236,28 +231,29 @@ class ToolsDiceEntropyEntryView(View): Calc final word Views ****************************************************************************""" class ToolsCalcFinalWordNumWordsView(View): - def run(self): - TWELVE = "12 words" - TWENTY_FOUR = "24 words" - - button_data = [TWELVE, TWENTY_FOUR] - selected_menu_num = ButtonListScreen( + TWELVE = "12 words" + TWENTY_FOUR = "24 words" + button_data = [TWELVE, TWENTY_FOUR] + + def run(self): + selected_menu_num = self.run_screen( + ButtonListScreen, title="Mnemonic Length", is_bottom_list=True, is_button_text_centered=True, - button_data=button_data, - ).display() + button_data=self.button_data, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == TWELVE: + elif self.button_data[selected_menu_num] == self.TWELVE: self.controller.storage.init_pending_mnemonic(12) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - elif button_data[selected_menu_num] == TWENTY_FOUR: + elif self.button_data[selected_menu_num] == self.TWENTY_FOUR: self.controller.storage.init_pending_mnemonic(24) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 495d5c05..eb638158 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -141,10 +141,7 @@ class MainMenuView(View): SEEDS = ("Seeds", FontAwesomeIconConstants.KEY) TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH) SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR) - - def __init__(self): - super().__init__() - self.button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] + button_data = [SCAN, SEEDS, TOOLS, SETTINGS] def run(self): @@ -179,23 +176,25 @@ class MainMenuView(View): class PowerOptionsView(View): + RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) + POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) + def run(self): - RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) - POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) - button_data = [RESET, POWER_OFF] - selected_menu_num = LargeButtonScreen( + button_data = [PowerOptionsView.RESET, PowerOptionsView.POWER_OFF] + selected_menu_num = self.run_screen( + LargeButtonScreen, title="Reset / Power", show_back_button=True, button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == RESET: + elif button_data[selected_menu_num] == PowerOptionsView.RESET: return Destination(RestartView) - elif button_data[selected_menu_num] == POWER_OFF: + elif button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: return Destination(PowerOffView) diff --git a/tests/base.py b/tests/base.py index 568f8d30..be45ac97 100644 --- a/tests/base.py +++ b/tests/base.py @@ -7,13 +7,14 @@ from typing import Callable, List, Tuple, Type, Union # These must precede any SeedSigner imports. sys.modules['seedsigner.gui.renderer'] = MagicMock() sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +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 +from seedsigner.controller import Controller, StopControllerCommand from seedsigner.models import Settings -from seedsigner.views.view import Destination, View +from seedsigner.views.view import Destination, MainMenuView, View @@ -45,7 +46,8 @@ class BaseTest: """ If settings were written to disk, delete """ import os try: - os.remove(Settings.SETTINGS_FILENAME) + if os.path.exists(Settings.SETTINGS_FILENAME): + os.remove(Settings.SETTINGS_FILENAME) except: print(f"{Settings.SETTINGS_FILENAME} not found to be removed") @@ -75,68 +77,96 @@ class FlowStep: """ Trivial helper class to express FlowTest sequences below. - * expected_view: verify that the next step in the sequence ended up at the right View. + * expected_view: verify that the current step in the sequence instantiates the right View. * run_before: function that takes a View instance as an arg and modifies it before running the View. * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. """ - expected_view: Type[View] = None + expected_view: type[View] = None run_before: Callable = None screen_return_value: Union[int,str] = None button_data_selection: Union[str,Tuple] = None + is_redirect: bool = False def __post_init__(self): if self.screen_return_value is not None and self.button_data_selection is not None: - raise Exception("Should only specify screen_return_value or button_data_selection") + raise Exception("Can't specify both `screen_return_value` and `button_data_selection`") + + + +class FlowDidNotExpectView(AssertionError): + """ Raised when the FlowTest sequence does not match the View that was run. """ + pass class FlowTest(BaseTest): """ Base class for any tests that do flow-based testing """ - def run_sequence(self, initial_destination: Destination, sequence: List[FlowStep]) -> Destination: + def run_sequence(self, sequence: list[FlowStep], initial_destination_view_args: dict = None) -> None: """ - Runs the given sequence of FlowSteps starting from the initial_destination: - * verifies that we landed on the expected_view (if provided). - * mocks out the `View.run_screen()` to prevent the associated Screen class from instantiating. - * patches in the FlowStep's screen_return_value (as if it came from user interaction). - * OR retrieves the index number of the specified button_data_selection and provides that as the Screen return value. - * optional `run_before` method modifies the View when necessary to be compatible w/test suite limitations. - * Runs the View and receives the resulting next Destination. - * then repeats the process on the next Destination until the sequence is complete. - - Returns the final Destination. + Run a pre-set sequence of Views w/manually-specified return values in order to test + the Controller's flow control logic and the routing from View to View. """ - next_destination = initial_destination - for flow_step in sequence: - try: - # Validate that the next Destination's View is what we expected - if flow_step.expected_view: - assert next_destination.View_cls == flow_step.expected_view + def verify_next_View_cls_and_run_view(destination: Destination, *args, **kwargs): + if len(sequence) == 0: + # We've reached the end of the sequence, so raise StopControllerCommand + # to stop the Controller and exit the test. + raise StopControllerCommand() - # Patch the `View.run_screen()` so we don't actually instantiate the Screen - qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) - with patch(qualname + ".run_screen") as mock_run_screen: - next_destination._instantiate_view() + # Verify that the View class specified in the test sequence matches the + # View class that is being run. + if destination.View_cls != sequence[0].expected_view: + raise FlowDidNotExpectView(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") - # A few Views need to reference their Screen; need to mock it out - # since we're not actually instantiating it. - next_destination.view.screen = MagicMock() + # Run the optional pre-run function to modify the View. + if sequence[0].run_before: + sequence[0].run_before(destination.view) - if flow_step.button_data_selection: - mock_run_screen.return_value = next_destination.view.button_data.index(flow_step.button_data_selection) - else: - mock_run_screen.return_value = flow_step.screen_return_value + if sequence[0].is_redirect: + # The current View is going to auto-redirect without calling run_screen(), + # so we need to remove the current step from the sequence before the + # View.run() call below. + sequence.pop(0) + + # Some Views reach into their Screen's variables directly (e.g. + # Screen.buttons to preserve the scroll position), so we need to mock out the + # Screen instance that is created by the View. + destination.view.screen = MagicMock() + + # Run the View (we're mocking out View._run_view() so the Destination + # won't actually run the View unless we explicitly do so here). + return destination.view.run() + + + def next_return_value(view: View, *args, **kwargs): + # Return the return value specified in the test sequence and + # remove the completed test step from the sequence. + flow_step = sequence.pop(0) + if flow_step.button_data_selection: + return view.button_data.index(flow_step.button_data_selection) + elif type(flow_step.screen_return_value) == StopControllerCommand: + raise flow_step.screen_return_value + return flow_step.screen_return_value + + + with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_run_view: + # Mock out the View._run_view() method so we can verify the View class + # that is specified in the test sequence and then run the View. + mock_run_view.side_effect = verify_next_View_cls_and_run_view + + with patch("seedsigner.views.view.View.run_screen", autospec=True) as mock_run_screen: + # Mock out the View.run_screen() method so we can provide the + # return value that is specified in the test sequence. + mock_run_screen.side_effect = next_return_value + + # Start the Controller with the first View_cls specified in the test sequence + if sequence[0].expected_view != MainMenuView: + initial_destination = Destination(sequence[0].expected_view, view_args=initial_destination_view_args) + else: + initial_destination = None + + # Start the Controller and run the sequence + Controller.get_instance().start(initial_destination=initial_destination) - if flow_step.run_before: - flow_step.run_before(next_destination.view) - # Now we can run the View and grab its resulting next Destination - next_destination = next_destination._run_view() - except Exception as e: - import traceback - traceback.print_exc() - print(f"{next_destination} | {flow_step}") - raise e - - return next_destination diff --git a/tests/test_flows.py b/tests/test_flows.py new file mode 100644 index 00000000..974b6368 --- /dev/null +++ b/tests/test_flows.py @@ -0,0 +1,79 @@ +import pytest +from mock import patch + +# Must import this before any seedsigner imports +from base import FlowTest, FlowStep, FlowDidNotExpectView +from seedsigner.controller import Controller, StopControllerCommand + +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON +from seedsigner.models.seed import Seed +from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView +from seedsigner.views.view import MainMenuView, PowerOptionsView +from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView + + + +class TestFlowTest(FlowTest): + + def test_simple_flow(self): + """ + Basic test to ensure the FlowTest can flow through a sequence of Views. + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE), + FlowStep(SeedMnemonicEntryView, screen_return_value=StopControllerCommand()), + ]) + + + def test_wrong_View_cls(self): + """ + Ensure that the FlowTest will raise an AssertionError if the next View in the + sequence is not the expected View. + """ + with pytest.raises(FlowDidNotExpectView): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), + FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. + ]) + + + def test_back_button_flow(self): + """ + Ensure that the FlowTest works correctly with the Controller and its BackStack. + """ + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + ]) + + + def test_initial_destination(self): + """ + Ensure that the FlowTest can start from a View other than MainMenuView. + """ + # Don't have to start at the MainMenuView; can jump straight in + self.run_sequence([ + FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=StopControllerCommand()), + ]) + + # And again, but this time with a View that requires input view_args + self.reset_controller() + + # Load a seed into the Controller + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), + FlowStep(SeedBackupView, screen_return_value=StopControllerCommand()), + ] + ) + + diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 17303cf9..6d69a4ac 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,7 @@ # Must import this before the Controller from base import BaseTest, FlowTest, FlowStep +from seedsigner.controller import StopControllerCommand from seedsigner.views.view import Destination, MainMenuView from seedsigner.views import seed_views, scan_views @@ -17,24 +18,12 @@ class TestSeedFlows(FlowTest): def load_seed_into_decoder(view: scan_views.ScanView): view.decoder.add_data("0000" * 11 + "0003") - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SCAN - ), - FlowStep( - expected_view=scan_views.ScanView, - run_before=load_seed_into_decoder, # simulate read SeedQR - # ret value is ignored - ), - FlowStep( - expected_view=seed_views.SeedFinalizeView, - button_data_selection=seed_views.SeedFinalizeView.FINALIZE - ), - ] - ) - assert destination.View_cls == seed_views.SeedOptionsView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + ]) def test_mnemonic_entry_flow(self): @@ -44,41 +33,24 @@ class TestSeedFlows(FlowTest): """ def test_with_mnemonic(mnemonic): sequence = [ - FlowStep( - button_data_selection=MainMenuView.SEEDS - ), - FlowStep( - expected_view=seed_views.SeedsMenuView, - # Nothing to return; auto-forwards to LoadSeedView - ), - FlowStep( - expected_view=seed_views.LoadSeedView, - button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, - ), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD), ] # Now add each manual word entry step for word in mnemonic: sequence.append( - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value=word - ) + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word) ) # With the mnemonic completely entered, we land on the SeedFinalizeView - sequence.append( - FlowStep( - expected_view=seed_views.SeedFinalizeView, - button_data_selection=seed_views.SeedFinalizeView.FINALIZE - ) - ) - - destination = self.run_sequence( - Destination(MainMenuView), - sequence=sequence - ) - assert destination.View_cls == seed_views.SeedOptionsView + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + ] + + self.run_sequence(sequence) # Test data from iancoleman.io; 12- and 24-word mnemonic test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) @@ -93,51 +65,26 @@ class TestSeedFlows(FlowTest): # Test data from iancoleman.io mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() sequence = [ - FlowStep( - button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, - ), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD), ] for word in mnemonic[:-1]: - sequence.append( - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value=word - ) - ) + sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)) sequence += [ - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value="zoo" # But finish with an INVALID checksum word - ), - FlowStep( - expected_view=seed_views.SeedMnemonicInvalidView, - button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT - ), + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zoo"), # But finish with an INVALID checksum word + FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT), ] # Restarts from first word for word in mnemonic[:-1]: - sequence.append( - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value=word - ) - ) + sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)) sequence += [ - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value="zebra" # provide yet another invalid checksum word - ), - FlowStep( - expected_view=seed_views.SeedMnemonicInvalidView, - button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD - ), + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word + FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), ] - destination = self.run_sequence( - Destination(seed_views.LoadSeedView), - sequence - ) - assert destination.View_cls == MainMenuView \ No newline at end of file + self.run_sequence(sequence) diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 8c68d36a..9603453f 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,13 +1,14 @@ -from mock import MagicMock -from typing import Type +import os # Must import this before any seedsigner imports from base import FlowTest, FlowStep +from seedsigner.controller import StopControllerCommand from seedsigner.models import SettingsDefinition +from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON -from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views.view import MainMenuView from seedsigner.views import settings_views @@ -19,27 +20,19 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_entry.display_name - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED), - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=RET_CODE__BACK_BUTTON - ) - ] - ) - assert destination.View_cls == settings_views.SettingsMenuView + # No settings file should exist before we enable persistent settings + assert os.path.exists(Settings.SETTINGS_FILENAME) == False + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) + + # Settings file should now exist + assert os.path.exists(Settings.SETTINGS_FILENAME) == True def test_multiselect(self): @@ -47,76 +40,32 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_entry.display_name - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=0, # select/deselect first option - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=1, # select/deselect second option - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=1, # select/deselect second option - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=RET_CODE__BACK_BUTTON, # BACK to exit - ), - ] - ) - assert destination.View_cls == settings_views.SettingsMenuView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) def test_io_test(self): """ Basic flow from MainMenuView to I/O Test View """ - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS, - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_views.SettingsMenuView.IO_TEST - ), - FlowStep( - expected_view=settings_views.IOTestView, - # ret value is ignored - ), - ] - ) - # Exiting IOTestView should return us to the main SettingsMenuView - assert destination.View_cls == settings_views.SettingsMenuView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), + FlowStep(settings_views.IOTestView, screen_return_value=None), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) def test_donate(self): """ Basic flow from MainMenuView to Donate View """ - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS, - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_views.SettingsMenuView.DONATE - ), - FlowStep( - expected_view=settings_views.DonateView, - # ret value is ignored - ), - ] - ) - # Exiting IOTestView should return us to the main SettingsMenuView - assert destination.View_cls == settings_views.SettingsMenuView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), + FlowStep(settings_views.DonateView, screen_return_value=None), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 39f07961..5ef82e4b 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -2,13 +2,12 @@ # Must import this before the Controller from typing import Type -from mock import MagicMock, Mock from base import FlowTest, FlowStep -from seedsigner.controller import Controller +from seedsigner.controller import Controller, StopControllerCommand from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition -from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views.view import Destination, MainMenuView from seedsigner.views import scan_views, seed_views, tools_views @@ -24,49 +23,17 @@ class TestToolsFlows(FlowTest): controller.storage.set_pending_seed(seed) controller.storage.finalize_pending_seed() - def mock_addrlistview_screen(view: Type[View]): - # ToolsAddressExplorerAddressListView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.TOOLS - ), - FlowStep( - expected_view=tools_views.ToolsMenuView, - button_data_selection=tools_views.ToolsMenuView.EXPLORER - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerSelectSourceView, - screen_return_value=0 # ret 1st onboard seed - ), - FlowStep( - expected_view=seed_views.SeedExportXpubScriptTypeView, - button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT) - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressTypeView, - button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressListView, - screen_return_value=10 # ret NEXT page of addrs - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressListView, - run_before=mock_addrlistview_screen, - screen_return_value=4 # ret a specific addr from the list - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressView, - # QRDisplayScreen runs until dismissed; no ret value - ), - ] - ) - # After dismissing QRDisplayScreen, should return to the addr list view - assert destination.View_cls == tools_views.ToolsAddressExplorerAddressListView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT)), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list + FlowStep(tools_views.ToolsAddressExplorerAddressView, screen_return_value=None), # QRDisplayScreen runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=StopControllerCommand()), + ]) def test_addressexplorer_loadseed_sideflow(self): @@ -80,36 +47,16 @@ class TestToolsFlows(FlowTest): view.decoder.add_data("0000" * 11 + "0003") # Finalize the new seed w/out passphrase - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.TOOLS - ), - FlowStep( - expected_view=tools_views.ToolsMenuView, - button_data_selection=tools_views.ToolsMenuView.EXPLORER - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerSelectSourceView, - button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED - ), - FlowStep( - expected_view=scan_views.ScanView, - run_before=load_seed_into_decoder, # simulate read SeedQR - ), - FlowStep( - expected_view=seed_views.SeedFinalizeView, - button_data_selection=seed_views.SeedFinalizeView.FINALIZE - ), - FlowStep( - expected_view=seed_views.SeedOptionsView, - # should auto-route away w/out a selection - ), - ] - ) - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), + FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), + ]) + assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER # Reset @@ -117,25 +64,12 @@ class TestToolsFlows(FlowTest): controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) # Finalize the new seed w/passphrase - destination = self.run_sequence( - Destination(seed_views.SeedFinalizeView), + self.run_sequence( sequence=[ - FlowStep( - button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE - ), - FlowStep( - expected_view=seed_views.SeedAddPassphraseView, - screen_return_value="mypassphrase", - ), - FlowStep( - expected_view=seed_views.SeedReviewPassphraseView, - button_data_selection=seed_views.SeedReviewPassphraseView.DONE - ), - FlowStep( - expected_view=seed_views.SeedOptionsView, - # SeedOptionsView should auto-route away - ), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), + FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="mypassphrase"), + FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), ] ) - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView From eee5b5e9ce1d2f4982b1ab2bfa686ea4db96388a Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 31 Mar 2023 17:12:35 -0500 Subject: [PATCH 15/42] Indentation level bugfix --- src/seedsigner/views/tools_views.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index b453bebc..de4d5e03 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -579,7 +579,7 @@ class ToolsAddressExplorerAddressListView(View): addr_storage_key = "change_addrs" if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: - # We already calculated this range addresses; just retrieve them + # We already calculated this range of addresses; just retrieve them addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] else: @@ -617,20 +617,20 @@ class ToolsAddressExplorerAddressListView(View): # Everything is set. Stop the loading screen self.loading_screen.stop() - for i, address in enumerate(addresses): - cur_index = i + self.start_index + for i, address in enumerate(addresses): + cur_index = i + self.start_index - # Adjust the trailing addr display length based on available room - # (the index number will push it out on each order of magnitude) - if cur_index < 10: - end_digits = -6 - elif cur_index < 100: - end_digits = -5 - else: - end_digits = -4 - button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") + # Adjust the trailing addr display length based on available room + # (the index number will push it out on each order of magnitude) + if cur_index < 10: + end_digits = -6 + elif cur_index < 100: + end_digits = -5 + else: + end_digits = -4 + button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") - button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) selected_menu_num = self.run_screen( ButtonListScreen, From c2f3c944606844606a41ff3ec9cea40940a955b0 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 31 Mar 2023 18:32:21 -0500 Subject: [PATCH 16/42] Adds test flows for view.py; cleanup --- src/seedsigner/views/view.py | 24 ++++++------ tests/base.py | 2 +- tests/test_flows_settings.py | 4 +- tests/test_flows_tools.py | 2 +- tests/test_flows_view.py | 76 ++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 tests/test_flows_view.py diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index eb638158..6b699e23 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -178,23 +178,23 @@ class MainMenuView(View): class PowerOptionsView(View): RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) + button_data = [RESET, POWER_OFF] def run(self): - button_data = [PowerOptionsView.RESET, PowerOptionsView.POWER_OFF] selected_menu_num = self.run_screen( LargeButtonScreen, title="Reset / Power", show_back_button=True, - button_data=button_data + button_data=self.button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == PowerOptionsView.RESET: + elif self.button_data[selected_menu_num] == PowerOptionsView.RESET: return Destination(RestartView) - elif button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: + elif self.button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: return Destination(PowerOffView) @@ -203,7 +203,7 @@ class RestartView(View): def run(self): thread = RestartView.DoResetThread() thread.start() - ResetScreen().display() + self.run_screen(ResetScreen) class DoResetThread(BaseThread): @@ -227,12 +227,12 @@ class RestartView(View): class PowerOffView(View): def run(self): if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - PowerOffNotRequiredScreen().display() + self.run_screen(PowerOffNotRequiredScreen) return Destination(BackStackView) else: thread = PowerOffView.PowerOffThread() thread.start() - PowerOffScreen().display() + self.run_screen(PowerOffScreen) class PowerOffThread(BaseThread): @@ -250,12 +250,13 @@ class NotYetImplementedView(View): Temporary View to use during dev. """ def run(self): - WarningScreen( + self.run_screen( + WarningScreen, title="Work In Progress", status_headline="Not Yet Implemented", text="This is still on our to-do list!", button_data=["Back to Main Menu"], - ).display() + ) return Destination(MainMenuView) @@ -267,13 +268,14 @@ class UnhandledExceptionView(View): def run(self): - DireWarningScreen( + self.run_screen( + DireWarningScreen, title="System Error", status_headline=self.error[0], text=self.error[1] + "\n" + self.error[2], button_data=["OK"], show_back_button=False, allow_text_overflow=True, # Fit what we can, let the rest go off the edges - ).display() + ) return Destination(MainMenuView, clear_history=True) diff --git a/tests/base.py b/tests/base.py index be45ac97..ed99519c 100644 --- a/tests/base.py +++ b/tests/base.py @@ -145,7 +145,7 @@ class FlowTest(BaseTest): flow_step = sequence.pop(0) if flow_step.button_data_selection: return view.button_data.index(flow_step.button_data_selection) - elif type(flow_step.screen_return_value) == StopControllerCommand: + elif type(flow_step.screen_return_value) in [StopControllerCommand, Exception]: raise flow_step.screen_return_value return flow_step.screen_return_value diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 9603453f..824aee1d 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -56,7 +56,7 @@ class TestSettingsFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), - FlowStep(settings_views.IOTestView, screen_return_value=None), + FlowStep(settings_views.IOTestView), FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), ]) @@ -66,6 +66,6 @@ class TestSettingsFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), - FlowStep(settings_views.DonateView, screen_return_value=None), + FlowStep(settings_views.DonateView), FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 5ef82e4b..d698eba9 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -31,7 +31,7 @@ class TestToolsFlows(FlowTest): FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list - FlowStep(tools_views.ToolsAddressExplorerAddressView, screen_return_value=None), # QRDisplayScreen runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressView), # QRDisplayScreen runs until dismissed; no ret value FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=StopControllerCommand()), ]) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py new file mode 100644 index 00000000..2c42d02d --- /dev/null +++ b/tests/test_flows_view.py @@ -0,0 +1,76 @@ +import pytest +from mock import patch + +# Must import this before any seedsigner imports +from base import FlowTest, FlowStep + +from seedsigner.controller import StopControllerCommand +from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON +from seedsigner.models.settings import Settings +from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView +from seedsigner.views.view import MainMenuView, NotYetImplementedView, PowerOptionsView, PowerOffView, RestartView, UnhandledExceptionView, View + + + +class TestViewFlows(FlowTest): + + def test_restart_flow(self): + """ + Basic flow from MainMenuView to RestartView + """ + with patch('seedsigner.views.view.RestartView.DoResetThread'): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), + FlowStep(RestartView, screen_return_value=StopControllerCommand()), + ]) + + + def test_power_off_flow(self): + """ + Basic flow from MainMenuView to PowerOffView + """ + with patch('seedsigner.views.view.PowerOffView.PowerOffThread'): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView, screen_return_value=StopControllerCommand()), + ]) + + # And again, but this time as if we were in the SeedSigner OS + Settings.HOSTNAME = Settings.SEEDSIGNER_OS + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView), # returns BackStackView + FlowStep(PowerOptionsView, screen_return_value=StopControllerCommand()), + ]) + + + def test_not_yet_implemented_flow(self): + """ + Run an incomplete View that returns None and ensure that we get the NotYetImplementedView + """ + class IncompleteTestView(View): + def run(self): + self.run_screen(None) + return None + + self.run_sequence([ + FlowStep(IncompleteTestView), + FlowStep(NotYetImplementedView), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + ]) + + + def test_unhandled_exception_flow(self): + """ + Basic flow from any arbitrary View to the UnhandledExceptionView + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception + FlowStep(UnhandledExceptionView), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + ]) From 9c18e97063873fe7b521f6bc864d93f052b2d023 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 13 Apr 2023 08:08:51 -0500 Subject: [PATCH 17/42] Light cleanup; additional core FlowTest tests --- src/seedsigner/models/settings_definition.py | 4 +-- src/seedsigner/views/scan_views.py | 3 +- tests/base.py | 8 ++--- tests/test_flows.py | 37 +++++++++++++++++--- tests/test_flows_seed.py | 7 ++-- tests/test_flows_settings.py | 2 +- tests/test_flows_tools.py | 7 ++-- tests/test_flows_view.py | 7 ++-- 8 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index df02858b..619ea579 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, List, Tuple, Union +from typing import Any, List @@ -217,7 +217,7 @@ class SettingsEntry: visibility: str = SettingsConstants.VISIBILITY__GENERAL type: str = SettingsConstants.TYPE__ENABLED_DISABLED help_text: str = None - selection_options: List[Tuple[Union[str,int],str]] = None + selection_options: list[tuple[str | int], str] = None default_value: Any = None def __post_init__(self): diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 95903040..cd297ead 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -1,10 +1,9 @@ import json import re -from typing import Type from embit.descriptor import Descriptor -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON from seedsigner.gui.screens import scan_screens from seedsigner.models import DecodeQR, Seed from seedsigner.models.settings import SettingsConstants diff --git a/tests/base.py b/tests/base.py index ed99519c..6598a03c 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,7 +1,7 @@ import sys from dataclasses import dataclass from mock import MagicMock, patch -from typing import Callable, List, Tuple, Type, Union +from typing import Callable # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. @@ -83,9 +83,9 @@ class FlowStep: * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. """ expected_view: type[View] = None - run_before: Callable = None - screen_return_value: Union[int,str] = None - button_data_selection: Union[str,Tuple] = None + run_before: Callable[[View], None] = None + screen_return_value: int | str = None + button_data_selection: str | tuple = None is_redirect: bool = False def __post_init__(self): diff --git a/tests/test_flows.py b/tests/test_flows.py index 974b6368..da7d19e5 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,14 +1,13 @@ import pytest -from mock import patch -# Must import this before any seedsigner imports +# Must import test base before the Controller from base import FlowTest, FlowStep, FlowDidNotExpectView -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, StopControllerCommand from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models.seed import Seed from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView -from seedsigner.views.view import MainMenuView, PowerOptionsView +from seedsigner.views.view import MainMenuView, PowerOptionsView, UnhandledExceptionView from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView @@ -17,7 +16,8 @@ class TestFlowTest(FlowTest): def test_simple_flow(self): """ - Basic test to ensure the FlowTest can flow through a sequence of Views. + Basic test to ensure the FlowTest can flow through a sequence of Views and + terminate via the StopControllerCommand. """ self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), @@ -37,6 +37,22 @@ class TestFlowTest(FlowTest): FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. ]) + + + def test_run_before_executes(self): + """ + Ensure that the FlowTest can execute a function before running a View. + """ + def break_button_data(view): + # Intentionally break the FlowTest's integration with the View's expected button_data + view.button_data = [] + + self.run_sequence([ + # Offering a button_data_selection that doesn't exist in the View's button_data + # will cause the FlowTest to raise an Exception. + FlowStep(MainMenuView, run_before=break_button_data, button_data_selection=MainMenuView.TOOLS), + FlowStep(UnhandledExceptionView), + ]) def test_back_button_flow(self): @@ -75,5 +91,16 @@ class TestFlowTest(FlowTest): FlowStep(SeedBackupView, screen_return_value=StopControllerCommand()), ] ) + + def test_raise_exception_via_screen_return_value(self): + """ + Ensure that the FlowTest can raise an exception via the screen_return_value. + """ + # A generic Exception should be caught by the Controller and routed to the + # UnhandledExceptionView. + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=Exception("Test exception")), + FlowStep(UnhandledExceptionView), + ]) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 6d69a4ac..0cdc1d2a 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,9 +1,8 @@ - -# Must import this before the Controller +# Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand -from seedsigner.views.view import Destination, MainMenuView +from seedsigner.controller import StopControllerCommand +from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 824aee1d..3ab8a2fd 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,6 +1,6 @@ import os -# Must import this before any seedsigner imports +# Must import test base before the Controller from base import FlowTest, FlowStep from seedsigner.controller import StopControllerCommand diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index d698eba9..c283df01 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -1,13 +1,10 @@ - -# Must import this before the Controller -from typing import Type - +# Must import test base before the Controller from base import FlowTest, FlowStep from seedsigner.controller import Controller, StopControllerCommand from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition -from seedsigner.views.view import Destination, MainMenuView +from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, tools_views diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 2c42d02d..b033da90 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -1,7 +1,6 @@ -import pytest from mock import patch -# Must import this before any seedsigner imports +# Must import test base before the Controller from base import FlowTest, FlowStep from seedsigner.controller import StopControllerCommand @@ -51,13 +50,13 @@ class TestViewFlows(FlowTest): """ Run an incomplete View that returns None and ensure that we get the NotYetImplementedView """ - class IncompleteTestView(View): + class IncompleteView(View): def run(self): self.run_screen(None) return None self.run_sequence([ - FlowStep(IncompleteTestView), + FlowStep(IncompleteView), FlowStep(NotYetImplementedView), FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), ]) From d1119623812f75b77ccbe7730a1f774e16692093 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 14 Apr 2023 14:52:39 -0500 Subject: [PATCH 18/42] Updates after PR Review Club --- src/seedsigner/controller.py | 18 ++++++----- src/seedsigner/views/seed_views.py | 19 ++++++------ tests/base.py | 33 +++++++++----------- tests/test_flows.py | 18 +++++------ tests/test_flows_seed.py | 50 +++++++++++++++++++++++++++--- tests/test_flows_settings.py | 10 +++--- tests/test_flows_tools.py | 12 +++---- tests/test_flows_view.py | 12 +++---- 8 files changed, 106 insertions(+), 66 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 44509227..10053f43 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -32,7 +32,7 @@ class BackStack(List[Destination]): -class StopControllerCommand(Exception): +class StopFlowBasedTest(Exception): """ This is a special exception that is raised by the test suite to stop the Controller's main loop. It is not meant to be raised by any other code. @@ -40,6 +40,10 @@ class StopControllerCommand(Exception): pass +class FlowBasedTestUnexpectedViewError(Exception): + pass + + class Controller(Singleton): """ @@ -262,17 +266,15 @@ class Controller(Singleton): print(f"Executing {next_destination}") next_destination = next_destination.run() - except StopControllerCommand: + except StopFlowBasedTest: # This is a special exception that is only raised by the test suite # to stop the Controller loop and exit the test. - print("StopControllerCommand received; ending Controller loop.") return - except AssertionError as e: - # Only raised by the test suite via FlowDidNotExpectView - print(f"Test suite caught {e.__class__.__name__}: {e}") - - # Re-raise so that the test suite can catch it + except FlowBasedTestUnexpectedViewError as e: + # This is a special exception that is only raised by the test suite + # when a flow-based test fails to follow the expected sequence. + # Re-raise so the test suite can handle it. raise e except Exception as e: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0da13470..2b13a98c 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -462,6 +462,10 @@ class SeedBackupView(View): Export Xpub flow ****************************************************************************""" class SeedExportXpubSigTypeView(View): + SINGLE_SIG = "Single Sig" + MULTISIG = "Multisig" + button_data=[SINGLE_SIG, MULTISIG] + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -472,22 +476,19 @@ class SeedExportXpubSigTypeView(View): # Nothing to select; skip this screen return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True) - SINGLE_SIG = "Single Sig" - MULTISIG = "Multisig" - button_data=[SINGLE_SIG, MULTISIG] - - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", - button_data=button_data - ).display() + button_data=self.button_data + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SINGLE_SIG: + if self.button_data[selected_menu_num] == self.SINGLE_SIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG}) - elif button_data[selected_menu_num] == MULTISIG: + elif self.button_data[selected_menu_num] == self.MULTISIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) diff --git a/tests/base.py b/tests/base.py index 6598a03c..ebf520fe 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,7 +12,7 @@ sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError, StopFlowBasedTest from seedsigner.models import Settings from seedsigner.views.view import Destination, MainMenuView, View @@ -78,12 +78,12 @@ class FlowStep: Trivial helper class to express FlowTest sequences below. * expected_view: verify that the current step in the sequence instantiates the right View. - * run_before: function that takes a View instance as an arg and modifies it before running the View. + * before_run: function that takes a View instance as an arg and modifies it before running the View. * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. """ expected_view: type[View] = None - run_before: Callable[[View], None] = None + before_run: Callable[[View], None] = None screen_return_value: int | str = None button_data_selection: str | tuple = None is_redirect: bool = False @@ -94,12 +94,6 @@ class FlowStep: -class FlowDidNotExpectView(AssertionError): - """ Raised when the FlowTest sequence does not match the View that was run. """ - pass - - - class FlowTest(BaseTest): """ Base class for any tests that do flow-based testing """ @@ -109,19 +103,20 @@ class FlowTest(BaseTest): the Controller's flow control logic and the routing from View to View. """ def verify_next_View_cls_and_run_view(destination: Destination, *args, **kwargs): - if len(sequence) == 0: - # We've reached the end of the sequence, so raise StopControllerCommand - # to stop the Controller and exit the test. - raise StopControllerCommand() - # Verify that the View class specified in the test sequence matches the # View class that is being run. if destination.View_cls != sequence[0].expected_view: - raise FlowDidNotExpectView(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") + raise FlowBasedTestUnexpectedViewError(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") + print(f"Confirmed {sequence[0].expected_view} is the next View") + + if len(sequence) == 1: + # The last entry in the sequence just needs to confirm that we got the + # right View class; we can now stop the Controller and exit the test. + raise StopFlowBasedTest() # Run the optional pre-run function to modify the View. - if sequence[0].run_before: - sequence[0].run_before(destination.view) + if sequence[0].before_run: + sequence[0].before_run(destination.view) if sequence[0].is_redirect: # The current View is going to auto-redirect without calling run_screen(), @@ -145,8 +140,10 @@ class FlowTest(BaseTest): flow_step = sequence.pop(0) if flow_step.button_data_selection: return view.button_data.index(flow_step.button_data_selection) - elif type(flow_step.screen_return_value) in [StopControllerCommand, Exception]: + + elif type(flow_step.screen_return_value) in [StopFlowBasedTest, Exception]: raise flow_step.screen_return_value + return flow_step.screen_return_value diff --git a/tests/test_flows.py b/tests/test_flows.py index da7d19e5..3cc29e24 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,9 +1,9 @@ import pytest # Must import test base before the Controller -from base import FlowTest, FlowStep, FlowDidNotExpectView +from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models.seed import Seed from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView @@ -23,7 +23,7 @@ class TestFlowTest(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE), - FlowStep(SeedMnemonicEntryView, screen_return_value=StopControllerCommand()), + FlowStep(SeedMnemonicEntryView), ]) @@ -32,14 +32,14 @@ class TestFlowTest(FlowTest): Ensure that the FlowTest will raise an AssertionError if the next View in the sequence is not the expected View. """ - with pytest.raises(FlowDidNotExpectView): + with pytest.raises(FlowBasedTestUnexpectedViewError): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. ]) - def test_run_before_executes(self): + def test_before_run_executes(self): """ Ensure that the FlowTest can execute a function before running a View. """ @@ -50,7 +50,7 @@ class TestFlowTest(FlowTest): self.run_sequence([ # Offering a button_data_selection that doesn't exist in the View's button_data # will cause the FlowTest to raise an Exception. - FlowStep(MainMenuView, run_before=break_button_data, button_data_selection=MainMenuView.TOOLS), + FlowStep(MainMenuView, before_run=break_button_data, button_data_selection=MainMenuView.TOOLS), FlowStep(UnhandledExceptionView), ]) @@ -62,7 +62,7 @@ class TestFlowTest(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, screen_return_value=RET_CODE__BACK_BUTTON), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView), ]) @@ -72,7 +72,7 @@ class TestFlowTest(FlowTest): """ # Don't have to start at the MainMenuView; can jump straight in self.run_sequence([ - FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=StopControllerCommand()), + FlowStep(ToolsCalcFinalWordNumWordsView), ]) # And again, but this time with a View that requires input view_args @@ -88,7 +88,7 @@ class TestFlowTest(FlowTest): initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), - FlowStep(SeedBackupView, screen_return_value=StopControllerCommand()), + FlowStep(SeedBackupView), ] ) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 0cdc1d2a..74fd5dab 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand +from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -19,9 +19,9 @@ class TestSeedFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), - FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), - FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedOptionsView), ]) @@ -46,7 +46,7 @@ class TestSeedFlows(FlowTest): # With the mnemonic completely entered, we land on the SeedFinalizeView sequence += [ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), - FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedOptionsView), ] self.run_sequence(sequence) @@ -83,7 +83,47 @@ class TestSeedFlows(FlowTest): sequence += [ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView), ] self.run_sequence(sequence) + + + def test_export_xpub_flow(self): + """ + Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the SeedOptionsView. + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + + def test_export_xpub_skip_sig_type_flow(self): + """ + + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + # TODO: Test is incomplete... diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 3ab8a2fd..4bf61212 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -3,7 +3,7 @@ import os # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand +from seedsigner.controller import StopFlowBasedTest from seedsigner.models import SettingsDefinition from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants @@ -28,7 +28,7 @@ class TestSettingsFlows(FlowTest): FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopFlowBasedTest()), ]) # Settings file should now exist @@ -47,7 +47,7 @@ class TestSettingsFlows(FlowTest): FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView), ]) @@ -57,7 +57,7 @@ class TestSettingsFlows(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), FlowStep(settings_views.IOTestView), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView), ]) @@ -67,5 +67,5 @@ class TestSettingsFlows(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), FlowStep(settings_views.DonateView), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView), ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index c283df01..ba5abe8c 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views.view import MainMenuView @@ -28,8 +28,8 @@ class TestToolsFlows(FlowTest): FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list - FlowStep(tools_views.ToolsAddressExplorerAddressView), # QRDisplayScreen runs until dismissed; no ret value - FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=StopControllerCommand()), + FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressListView), ]) @@ -48,10 +48,10 @@ class TestToolsFlows(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), - FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), - FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedExportXpubScriptTypeView), ]) assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER @@ -67,6 +67,6 @@ class TestToolsFlows(FlowTest): FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="mypassphrase"), FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), - FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedExportXpubScriptTypeView), ] ) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index b033da90..43c7b9f0 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -3,7 +3,7 @@ from mock import patch # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand +from seedsigner.controller import StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON from seedsigner.models.settings import Settings from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView @@ -21,7 +21,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), - FlowStep(RestartView, screen_return_value=StopControllerCommand()), + FlowStep(RestartView, screen_return_value=StopFlowBasedTest()), ]) @@ -33,7 +33,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), - FlowStep(PowerOffView, screen_return_value=StopControllerCommand()), + FlowStep(PowerOffView, screen_return_value=StopFlowBasedTest()), ]) # And again, but this time as if we were in the SeedSigner OS @@ -42,7 +42,7 @@ class TestViewFlows(FlowTest): FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), FlowStep(PowerOffView), # returns BackStackView - FlowStep(PowerOptionsView, screen_return_value=StopControllerCommand()), + FlowStep(PowerOptionsView, screen_return_value=StopFlowBasedTest()), ]) @@ -58,7 +58,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(IncompleteView), FlowStep(NotYetImplementedView), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), ]) @@ -71,5 +71,5 @@ class TestViewFlows(FlowTest): FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception FlowStep(UnhandledExceptionView), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), ]) From 8e56803063edd7dd476d46d8177f588a5cf5bcb2 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 14 Apr 2023 14:59:12 -0500 Subject: [PATCH 19/42] added comment --- src/seedsigner/controller.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 10053f43..cfa9c665 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -34,13 +34,18 @@ class BackStack(List[Destination]): class StopFlowBasedTest(Exception): """ - This is a special exception that is raised by the test suite to stop the - Controller's main loop. It is not meant to be raised by any other code. + This is a special exception that is only raised by the test suite to stop the + Controller's main loop. It should not be raised by any other code. """ pass class FlowBasedTestUnexpectedViewError(Exception): + """ + This is a special exception that is only raised by the test suite to indicate + that the Controller's current View does not match the expected TestFlow sequence. + It should not be raised by any other code. + """ pass From 698273e3f9d596c13264b156e18c31ccb56f768e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 14 Apr 2023 15:00:33 -0500 Subject: [PATCH 20/42] Update test_flows_seed.py --- tests/test_flows_seed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 74fd5dab..a9692187 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -104,6 +104,7 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), FlowStep(seed_views.SeedExportXpubScriptTypeView), + # TODO: Test is incomplete... ] ) @@ -123,7 +124,6 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), FlowStep(seed_views.SeedExportXpubScriptTypeView), + # TODO: Test is incomplete... ] ) - - # TODO: Test is incomplete... From 696f59992deedb843b30a14e4f25c3c7d038b538 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 18 Apr 2023 07:20:22 -0500 Subject: [PATCH 21/42] Interim commit; refactoring View.run_screen patch --- src/seedsigner/views/seed_views.py | 23 ++++++++++++++--------- src/seedsigner/views/view.py | 21 ++++++++++----------- tests/base.py | 9 ++++++++- tests/test_flows.py | 12 ++---------- tests/test_flows_seed.py | 22 ++++++++++++++++++++++ 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 2b13a98c..efdcca81 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -31,6 +31,8 @@ from .view import NotYetImplementedView, View, Destination, BackStackView, MainM class SeedsMenuView(View): + LOAD = "Load a seed" + def __init__(self): super().__init__() self.seeds = [] @@ -50,11 +52,12 @@ class SeedsMenuView(View): button_data.append((seed["fingerprint"], SeedSignerCustomIconConstants.FINGERPRINT, "blue")) button_data.append("Load a seed") - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="In-Memory Seeds", is_button_text_centered=False, button_data=button_data - ).display() + ) if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): return Destination(SeedOptionsView, view_args={"seed_num": selected_menu_num}) @@ -291,6 +294,9 @@ class SeedReviewPassphraseView(View): class SeedDiscardView(View): + KEEP = "Keep Seed" + DISCARD = ("Discard", None, None, "red") + def __init__(self, seed_num: int = None): super().__init__() self.seed_num = seed_num @@ -301,27 +307,26 @@ class SeedDiscardView(View): def run(self): - KEEP = "Keep Seed" - DISCARD = ("Discard", None, None, "red") - button_data = [KEEP, DISCARD] + button_data = [self.KEEP, self.DISCARD] fingerprint = self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, title="Discard Seed?", status_headline=None, text=f"Wipe seed {fingerprint} from the device?", show_back_button=False, button_data=button_data, - ).display() + ) - if button_data[selected_menu_num] == KEEP: + if button_data[selected_menu_num] == self.KEEP: # Use skip_current_view=True to prevent BACK from landing on this warning screen if self.seed_num is not None: return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, skip_current_view=True) else: return Destination(SeedFinalizeView, skip_current_view=True) - elif button_data[selected_menu_num] == DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: if self.seed_num is not None: self.controller.discard_seed(self.seed_num) else: diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 6b699e23..f9a40f89 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -141,15 +141,14 @@ class MainMenuView(View): SEEDS = ("Seeds", FontAwesomeIconConstants.KEY) TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH) SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR) - button_data = [SCAN, SEEDS, TOOLS, SETTINGS] - def run(self): + button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] selected_menu_num = self.run_screen( LargeButtonScreen, title="Home", title_font_size=26, - button_data=self.button_data, + button_data=button_data, show_back_button=False, show_power_button=True, ) @@ -157,19 +156,19 @@ class MainMenuView(View): if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) - if self.button_data[selected_menu_num] == self.SCAN: + if button_data[selected_menu_num] == self.SCAN: from .scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] == self.SEEDS: + elif button_data[selected_menu_num] == self.SEEDS: from .seed_views import SeedsMenuView return Destination(SeedsMenuView) - elif self.button_data[selected_menu_num] == self.TOOLS: + elif button_data[selected_menu_num] == self.TOOLS: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) - elif self.button_data[selected_menu_num] == self.SETTINGS: + elif button_data[selected_menu_num] == self.SETTINGS: from .settings_views import SettingsMenuView return Destination(SettingsMenuView) @@ -178,23 +177,23 @@ class MainMenuView(View): class PowerOptionsView(View): RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) - button_data = [RESET, POWER_OFF] def run(self): + button_data = [self.RESET, self.POWER_OFF] selected_menu_num = self.run_screen( LargeButtonScreen, title="Reset / Power", show_back_button=True, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == PowerOptionsView.RESET: + elif button_data[selected_menu_num] == self.RESET: return Destination(RestartView) - elif self.button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: + elif button_data[selected_menu_num] == self.POWER_OFF: return Destination(PowerOffView) diff --git a/tests/base.py b/tests/base.py index ebf520fe..27dd2d4e 100644 --- a/tests/base.py +++ b/tests/base.py @@ -138,8 +138,15 @@ class FlowTest(BaseTest): # Return the return value specified in the test sequence and # remove the completed test step from the sequence. flow_step = sequence.pop(0) + if flow_step.button_data_selection: - return view.button_data.index(flow_step.button_data_selection) + # We're mocking out the View.run_screen() method, so we'll get all of the + # input args that are normally passed into the Screen.run() method, + # including the button_data kwarg. + if "button_data" in kwargs: + return kwargs.get("button_data").index(flow_step.button_data_selection) + else: + raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") elif type(flow_step.screen_return_value) in [StopFlowBasedTest, Exception]: raise flow_step.screen_return_value diff --git a/tests/test_flows.py b/tests/test_flows.py index 3cc29e24..f900d1bc 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -43,16 +43,8 @@ class TestFlowTest(FlowTest): """ Ensure that the FlowTest can execute a function before running a View. """ - def break_button_data(view): - # Intentionally break the FlowTest's integration with the View's expected button_data - view.button_data = [] - - self.run_sequence([ - # Offering a button_data_selection that doesn't exist in the View's button_data - # will cause the FlowTest to raise an Exception. - FlowStep(MainMenuView, before_run=break_button_data, button_data_selection=MainMenuView.TOOLS), - FlowStep(UnhandledExceptionView), - ]) + # TODO + pass def test_back_button_flow(self): diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index a9692187..a8ea4e03 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -127,3 +127,25 @@ class TestSeedFlows(FlowTest): # TODO: Test is incomplete... ] ) + + + def test_discard_seed_flow(self): + """ + Selecting "Discard Seed" from the SeedOptionsView should enter the Discard Seed flow and + remove the in-memory seed from the Controller. + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.DISCARD), + FlowStep(seed_views.SeedDiscardView, button_data_selection=seed_views.SeedDiscardView.DISCARD), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView), + ] + ) From 57ece3cc1c9f8f4a6997763e2eb84fc1c797454f Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:31:39 -0700 Subject: [PATCH 22/42] removing now-unnecessary `button_data` edits --- src/seedsigner/views/seed_views.py | 139 ++++++++++++++-------------- src/seedsigner/views/tools_views.py | 59 ++++++------ 2 files changed, 101 insertions(+), 97 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index efdcca81..c564f516 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -78,37 +78,37 @@ class LoadSeedView(View): TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) - button_data=[ - SEED_QR, - TYPE_12WORD, - TYPE_24WORD, - CREATE, - ] def run(self): + button_data = [ + self.SEED_QR, + self.TYPE_12WORD, + self.TYPE_24WORD, + self.CREATE, + ] selected_menu_num = self.run_screen( ButtonListScreen, title="Load A Seed", is_button_text_centered=False, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if self.button_data[selected_menu_num] == self.SEED_QR: + if button_data[selected_menu_num] == self.SEED_QR: from .scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] == self.TYPE_12WORD: + elif button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) return Destination(SeedMnemonicEntryView) - elif self.button_data[selected_menu_num] == self.TYPE_24WORD: + elif button_data[selected_menu_num] == self.TYPE_24WORD: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) - elif self.button_data[selected_menu_num] == self.CREATE: + elif button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -174,7 +174,6 @@ class SeedMnemonicEntryView(View): class SeedMnemonicInvalidView(View): EDIT = "Review & Edit" DISCARD = ("Discard", None, None, "red") - button_data = [EDIT, DISCARD] def __init__(self): super().__init__() @@ -182,19 +181,20 @@ class SeedMnemonicInvalidView(View): def run(self): + button_data = [self.EDIT, self.DISCARD] selected_menu_num = self.run_screen( WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, - button_data=self.button_data, + button_data=button_data, ) - if self.button_data[selected_menu_num] == self.EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) - elif self.button_data[selected_menu_num] == self.DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) @@ -203,29 +203,29 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): FINALIZE = "Done" PASSPHRASE = "BIP-39 Passphrase" - button_data = [FINALIZE] def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - self.button_data.append(self.PASSPHRASE) - def run(self): + button_data = [self.FINALIZE] + if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: + button_data.append(self.PASSPHRASE) + selected_menu_num = self.run_screen( seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, - button_data=self.button_data, + button_data=button_data, ) - if self.button_data[selected_menu_num] == self.FINALIZE: + if button_data[selected_menu_num] == self.FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - elif self.button_data[selected_menu_num] == self.PASSPHRASE: + elif button_data[selected_menu_num] == self.PASSPHRASE: return Destination(SeedAddPassphraseView) @@ -257,7 +257,6 @@ class SeedReviewPassphraseView(View): """ EDIT = "Edit passphrase" DONE = "Done" - button_data = [EDIT, DONE] def __init__(self): super().__init__() @@ -273,6 +272,8 @@ class SeedReviewPassphraseView(View): fingerprint_without = self.seed.get_fingerprint(network=network) self.seed.set_passphrase(passphrase) + button_data = [self.EDIT, self.DONE] + # Because we have an explicit "Edit" button, we disable "BACK" to keep the # routing options sane. selected_menu_num = self.run_screen( @@ -280,14 +281,14 @@ class SeedReviewPassphraseView(View): fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, - button_data=self.button_data, + button_data=button_data, show_back_button=False, ) - if self.button_data[selected_menu_num] == self.EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedAddPassphraseView) - elif self.button_data[selected_menu_num] == self.DONE: + elif button_data[selected_menu_num] == self.DONE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) @@ -352,25 +353,6 @@ class SeedOptionsView(View): super().__init__() self.seed_num = seed_num self.seed = self.controller.get_seed(self.seed_num) - self.button_data = [] - - if self.controller.unverified_address: - addr = self.controller.unverified_address["address"][:7] - self.VERIFY_ADDRESS += f" {addr}" - self.button_data.append(self.VERIFY_ADDRESS) - - self.button_data.append(self.SCAN_PSBT) - - if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: - self.button_data.append(self.EXPORT_XPUB) - - self.button_data.append(self.EXPLORER) - self.button_data.append(self.BACKUP) - - if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: - self.button_data.append(self.BIP85_CHILD_SEED) - - self.button_data.append(self.DISCARD) def run(self): @@ -396,9 +378,29 @@ class SeedOptionsView(View): self.controller.psbt_seed = self.seed return Destination(PSBTOverviewView, skip_current_view=True) + button_data = [] + + if self.controller.unverified_address: + addr = self.controller.unverified_address["address"][:7] + self.VERIFY_ADDRESS += f" {addr}" + button_data.append(self.VERIFY_ADDRESS) + + button_data.append(self.SCAN_PSBT) + + if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.EXPORT_XPUB) + + button_data.append(self.EXPLORER) + button_data.append(self.BACKUP) + + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.BIP85_CHILD_SEED) + + button_data.append(self.DISCARD) + selected_menu_num = self.run_screen( seed_screens.SeedOptionsScreen, - button_data=self.button_data, + button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, ) @@ -407,28 +409,28 @@ class SeedOptionsView(View): # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if self.button_data[selected_menu_num] == self.SCAN_PSBT: + if button_data[selected_menu_num] == self.SCAN_PSBT: from seedsigner.views.scan_views import ScanView self.controller.psbt_seed = self.controller.get_seed(self.seed_num) return Destination(ScanView) - elif self.button_data[selected_menu_num] == self.VERIFY_ADDRESS: + elif button_data[selected_menu_num] == self.VERIFY_ADDRESS: return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num)) - elif self.button_data[selected_menu_num] == self.EXPORT_XPUB: + elif button_data[selected_menu_num] == self.EXPORT_XPUB: return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num)) - elif self.button_data[selected_menu_num] == self.EXPLORER: + elif button_data[selected_menu_num] == self.EXPLORER: self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG)) - elif self.button_data[selected_menu_num] == self.BACKUP: + elif button_data[selected_menu_num] == self.BACKUP: return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) - elif self.button_data[selected_menu_num] == self.BIP85_CHILD_SEED: + elif button_data[selected_menu_num] == self.BIP85_CHILD_SEED: return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num}) - elif self.button_data[selected_menu_num] == self.DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num)) @@ -436,7 +438,6 @@ class SeedOptionsView(View): class SeedBackupView(View): VIEW_WORDS = "View Seed Words" EXPORT_SEEDQR = "Export as SeedQR" - button_data = [VIEW_WORDS, EXPORT_SEEDQR] def __init__(self, seed_num): super().__init__() @@ -445,20 +446,22 @@ class SeedBackupView(View): def run(self): + button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR] + selected_menu_num = self.run_screen( ButtonListScreen, title="Backup Seed", - button_data=self.button_data, + button_data=button_data, is_bottom_list=True, ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.VIEW_WORDS: + elif button_data[selected_menu_num] == self.VIEW_WORDS: return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) - elif self.button_data[selected_menu_num] == self.EXPORT_SEEDQR: + elif button_data[selected_menu_num] == self.EXPORT_SEEDQR: return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) @@ -469,7 +472,6 @@ class SeedBackupView(View): class SeedExportXpubSigTypeView(View): SINGLE_SIG = "Single Sig" MULTISIG = "Multisig" - button_data=[SINGLE_SIG, MULTISIG] def __init__(self, seed_num: int): super().__init__() @@ -481,19 +483,21 @@ class SeedExportXpubSigTypeView(View): # Nothing to select; skip this screen return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True) + button_data=[self.SINGLE_SIG, self.MULTISIG] + selected_menu_num = self.run_screen( ButtonListScreen, title="Export Xpub", - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if self.button_data[selected_menu_num] == self.SINGLE_SIG: + if button_data[selected_menu_num] == self.SINGLE_SIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG}) - elif self.button_data[selected_menu_num] == self.MULTISIG: + elif button_data[selected_menu_num] == self.MULTISIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) @@ -504,10 +508,6 @@ class SeedExportXpubScriptTypeView(View): self.seed_num = seed_num self.sig_type = sig_type - self.button_data = [] - for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): - self.button_data.append(script_type) - def run(self): from .tools_views import ToolsAddressExplorerAddressTypeView @@ -526,11 +526,15 @@ class SeedExportXpubScriptTypeView(View): if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: title = "Address Explorer" + button_data = [] + for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): + button_data.append(script_type) + selected_menu_num = self.run_screen( ButtonListScreen, title=title, is_button_text_centered=False, - button_data=self.button_data, + button_data=button_data, is_bottom_list=True, ) @@ -542,7 +546,7 @@ class SeedExportXpubScriptTypeView(View): else: script_types_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) - selected_display_name = self.button_data[selected_menu_num] + selected_display_name = button_data[selected_menu_num] args["script_type"] = script_types_settings_entry.get_selection_option_value_by_display_name(selected_display_name) if args["script_type"] == SettingsConstants.CUSTOM_DERIVATION: @@ -564,6 +568,7 @@ class SeedExportXpubCustomDerivationView(View): self.script_type = script_type self.custom_derivation_path = "m/" + def run(self): ret = seed_screens.SeedExportXpubCustomDerivationScreen( initial_value=self.custom_derivation_path, diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index de4d5e03..6e5404c5 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -27,29 +27,30 @@ class ToolsMenuView(View): DICE = ("New seed", FontAwesomeIconConstants.DICE) KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" - button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] def run(self): + button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] + selected_menu_num = self.run_screen( ButtonListScreen, title="Tools", is_button_text_centered=False, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.IMAGE: + elif button_data[selected_menu_num] == self.IMAGE: return Destination(ToolsImageEntropyLivePreviewView) - elif self.button_data[selected_menu_num] == self.DICE: + elif button_data[selected_menu_num] == self.DICE: return Destination(ToolsDiceEntropyMnemonicLengthView) - elif self.button_data[selected_menu_num] == self.KEYBOARD: + elif button_data[selected_menu_num] == self.KEYBOARD: return Destination(ToolsCalcFinalWordNumWordsView) - elif self.button_data[selected_menu_num] == self.EXPLORER: + elif button_data[selected_menu_num] == self.EXPLORER: return Destination(ToolsAddressExplorerSelectSourceView) @@ -233,27 +234,28 @@ class ToolsDiceEntropyEntryView(View): class ToolsCalcFinalWordNumWordsView(View): TWELVE = "12 words" TWENTY_FOUR = "24 words" - button_data = [TWELVE, TWENTY_FOUR] - def run(self): + def run(self): + button_data = [self.TWELVE, self.TWENTY_FOUR] + selected_menu_num = self.run_screen( ButtonListScreen, title="Mnemonic Length", is_bottom_list=True, is_button_text_centered=True, - button_data=self.button_data, + button_data=button_data, ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.TWELVE: + elif button_data[selected_menu_num] == self.TWELVE: self.controller.storage.init_pending_mnemonic(12) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - elif self.button_data[selected_menu_num] == self.TWENTY_FOUR: + elif button_data[selected_menu_num] == self.TWENTY_FOUR: self.controller.storage.init_pending_mnemonic(24) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) @@ -427,23 +429,19 @@ class ToolsAddressExplorerSelectSourceView(View): SCAN_DESCRIPTOR = ("Scan wallet descriptor", FontAwesomeIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [SCAN_SEED, SCAN_DESCRIPTOR, TYPE_12WORD, TYPE_24WORD] - - def __init__(self): - super().__init__() - self.seeds = self.controller.storage.seeds - seed_buttons =[] - for seed in self.seeds: - button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - seed_buttons.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - self.button_data = seed_buttons + self.button_data def run(self): + seeds = self.controller.storage.seeds + button_data = [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] + for seed in seeds: + button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + button_data.insert(0, (button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + selected_menu_num = self.run_screen( ButtonListScreen, title="Address Explorer", - button_data=self.button_data, + button_data=button_data, is_button_text_centered=False, is_bottom_list=True, ) @@ -456,7 +454,7 @@ class ToolsAddressExplorerSelectSourceView(View): # knows to re-route us once the side flow is complete. self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER - if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): + if len(seeds) > 0 and selected_menu_num < len(seeds): # User selected one of the n seeds return Destination( SeedExportXpubScriptTypeView, @@ -466,13 +464,13 @@ class ToolsAddressExplorerSelectSourceView(View): ) ) - elif self.button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: + elif button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if self.button_data[selected_menu_num] == self.TYPE_12WORD: + if button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -483,7 +481,6 @@ class ToolsAddressExplorerSelectSourceView(View): class ToolsAddressExplorerAddressTypeView(View): RECEIVE = "Receive Addresses" CHANGE = "Change Addresses" - button_data = [RECEIVE, CHANGE] def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None): @@ -540,9 +537,11 @@ class ToolsAddressExplorerAddressTypeView(View): script_type = data["script_type"] if "script_type" in data else None + button_data = [self.RECEIVE, self.CHANGE] + selected_menu_num = self.run_screen( ToolsAddressExplorerAddressTypeScreen, - button_data=self.button_data, + button_data=button_data, fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, wallet_descriptor_display_name=wallet_descriptor_display_name, script_type=script_type, @@ -552,8 +551,8 @@ class ToolsAddressExplorerAddressTypeView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: - return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.button_data[selected_menu_num] == self.CHANGE)) + elif button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == self.CHANGE)) From 81ea0787c8e82316f906a7906988f7ce9e99c0ea Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:47:25 -0700 Subject: [PATCH 23/42] More `button_data` cleanup --- src/seedsigner/views/scan_views.py | 3 +++ src/seedsigner/views/settings_views.py | 26 +++++++++++++------------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index cd297ead..dc81b508 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -15,6 +15,9 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest class ScanView(View): def __init__(self): super().__init__() + + # Set up the QR decoder here so we can inject data into it in the test suite's + # `before_run`. self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) self.decoder = DecodeQR(wordlist_language_code=self.wordlist_language_code) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 929c3c81..49753eeb 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -19,14 +19,16 @@ class SettingsMenuView(View): # Used to preserve the rendering position in the list self.initial_scroll = initial_scroll - self.settings_entries = SettingsDefinition.get_settings_entries( + + def run(self): + settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) - self.button_data=[e.display_name for e in self.settings_entries] + button_data=[e.display_name for e in settings_entries] self.selected_button = 0 if self.selected_attr: - for i, entry in enumerate(self.settings_entries): + for i, entry in enumerate(settings_entries): if entry.attr_name == self.selected_attr: self.selected_button = i break @@ -35,11 +37,11 @@ class SettingsMenuView(View): self.title = "Settings" # Set up the next nested level of menuing - self.button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - self.button_data.append(self.IO_TEST) - self.button_data.append(self.DONATE) + button_data.append(self.IO_TEST) + button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: self.title = "Advanced" @@ -53,13 +55,11 @@ class SettingsMenuView(View): self.title = "Dev Options" self.next_destination = None - - def run(self): selected_menu_num = self.run_screen( ButtonListScreen, title=self.title, is_button_text_centered=False, - button_data=self.button_data, + button_data=button_data, selected_button=self.selected_button, scroll_y_initial_offset=self.initial_scroll, ) @@ -75,17 +75,17 @@ class SettingsMenuView(View): else: return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - elif selected_menu_num == len(self.settings_entries): + elif selected_menu_num == len(settings_entries): return self.next_destination - elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.IO_TEST: + elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) - elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.DONATE: + elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) else: - return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) From 50e3a3698f62b870f4eb011c0c65665f1c8b3fc5 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:48:48 -0700 Subject: [PATCH 24/42] Update settings_views.py --- src/seedsigner/views/settings_views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 49753eeb..90a056cb 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -26,11 +26,11 @@ class SettingsMenuView(View): ) button_data=[e.display_name for e in settings_entries] - self.selected_button = 0 + selected_button = 0 if self.selected_attr: for i, entry in enumerate(settings_entries): if entry.attr_name == self.selected_attr: - self.selected_button = i + selected_button = i break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: @@ -60,7 +60,7 @@ class SettingsMenuView(View): title=self.title, is_button_text_centered=False, button_data=button_data, - selected_button=self.selected_button, + selected_button=selected_button, scroll_y_initial_offset=self.initial_scroll, ) From 63ff9c06d909d71b78058117c721ffc3cf73c628 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:52:45 -0700 Subject: [PATCH 25/42] Update settings_views.py --- src/seedsigner/views/settings_views.py | 48 +++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 90a056cb..4ccfe66a 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -34,30 +34,30 @@ class SettingsMenuView(View): break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: - self.title = "Settings" + title = "Settings" # Set up the next nested level of menuing button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) + next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) button_data.append(self.IO_TEST) button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: - self.title = "Advanced" + title = "Advanced" # So far there are no real Developer options; disabling for now - # self.button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - # self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) - self.next_destination = None + # button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + # next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) + next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: - self.title = "Dev Options" - self.next_destination = None + title = "Dev Options" + next_destination = None selected_menu_num = self.run_screen( ButtonListScreen, - title=self.title, + title=title, is_button_text_centered=False, button_data=button_data, selected_button=selected_button, @@ -76,7 +76,7 @@ class SettingsMenuView(View): return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) elif selected_menu_num == len(settings_entries): - return self.next_destination + return next_destination elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) @@ -100,19 +100,21 @@ class SettingsEntryUpdateSelectionView(View): self.selected_button = selected_button self.parent_initial_scroll = parent_initial_scroll - self.button_data = [] - self.checked_buttons = [] - self.initial_value = self.settings.get_value(self.settings_entry.attr_name) + + def run(self): + button_data = [] + checked_buttons = [] + initial_value = self.settings.get_value(self.settings_entry.attr_name) for i, value in enumerate(self.settings_entry.selection_options): if type(value) == tuple: value, display_name = value else: display_name = value - self.button_data.append(display_name) + button_data.append(display_name) - if (type(self.initial_value) == list and value in self.initial_value) or value == self.initial_value: - self.checked_buttons.append(i) + if (type(initial_value) == list and value in initial_value) or value == initial_value: + checked_buttons.append(i) if self.selected_button is None: # Highlight the selection (for multiselect highlight the first @@ -121,16 +123,14 @@ class SettingsEntryUpdateSelectionView(View): if self.selected_button is None: self.selected_button = 0 - - - def run(self): + ret_value = self.run_screen( settings_screens.SettingsEntryUpdateSelectionScreen, display_name=self.settings_entry.display_name, help_text=self.settings_entry.help_text, - button_data=self.button_data, + button_data=button_data, selected_button=self.selected_button, - checked_buttons=self.checked_buttons, + checked_buttons=checked_buttons, settings_entry_type=self.settings_entry.type, ) @@ -154,8 +154,8 @@ class SettingsEntryUpdateSelectionView(View): destination = settings_menu_view_destination elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT: - updated_value = list(self.initial_value) - if ret_value not in self.checked_buttons: + updated_value = list(initial_value) + if ret_value not in checked_buttons: # This is a new selection to add updated_value.append(value) else: @@ -164,7 +164,7 @@ class SettingsEntryUpdateSelectionView(View): else: # All other types are single selects (e.g. Enabled/Disabled, SELECT_1) - if value == self.initial_value: + if value == initial_value: # No change, return to menu return settings_menu_view_destination else: From 78904fd7ff8e35cb492489cd85f3af1bdb3afe46 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:54:47 -0700 Subject: [PATCH 26/42] Update settings_views.py --- src/seedsigner/views/settings_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 4ccfe66a..ccb19150 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -102,9 +102,9 @@ class SettingsEntryUpdateSelectionView(View): def run(self): + initial_value = self.settings.get_value(self.settings_entry.attr_name) button_data = [] checked_buttons = [] - initial_value = self.settings.get_value(self.settings_entry.attr_name) for i, value in enumerate(self.settings_entry.selection_options): if type(value) == tuple: value, display_name = value From 2e91267a43af7b6abe3498f0dcca9e820558e24e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 20:02:23 -0700 Subject: [PATCH 27/42] Update tools_views.py --- src/seedsigner/views/tools_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 6e5404c5..6cd0c0bd 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -433,10 +433,11 @@ class ToolsAddressExplorerSelectSourceView(View): def run(self): seeds = self.controller.storage.seeds - button_data = [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] + button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - button_data.insert(0, (button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] selected_menu_num = self.run_screen( ButtonListScreen, From 2aa851dd493c0f8bddaec4bdcb52844f32257b4d Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 20:07:06 -0700 Subject: [PATCH 28/42] Update view.py --- src/seedsigner/views/view.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index f9a40f89..de4bac71 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, List, Type, Union +from typing import Type from seedsigner.gui.components import FontAwesomeIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON @@ -59,7 +59,7 @@ class View: self.screen = None - def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> Union[int,str]: + def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str: """ Instantiates the provided Screen_cls and runs its interactive display. Returns the user's input upon completion. @@ -262,7 +262,7 @@ class NotYetImplementedView(View): class UnhandledExceptionView(View): - def __init__(self, error: List[str]): + def __init__(self, error: list[str]): self.error = error From 3748a4a6db8c814b6b39d93be205ce935aa7b700 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Fri, 14 Apr 2023 14:22:18 -0400 Subject: [PATCH 29/42] add psbt scan to sign flow happy path --- src/seedsigner/views/psbt_views.py | 132 ++++++++++++++++------------- tests/test_flows_psbt.py | 56 ++++++++++++ 2 files changed, 130 insertions(+), 58 deletions(-) create mode 100644 tests/test_flows_psbt.py diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 59bda243..a4eb7408 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -11,9 +11,8 @@ from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.settings import SettingsConstants -from seedsigner.gui.screens import psbt_screens -from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, - LoadingScreenThread, QRDisplayScreen, WarningScreen) +from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen +from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen, WarningScreen) from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -22,6 +21,11 @@ logger = logging.getLogger(__name__) class PSBTSelectSeedView(View): + SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + button_data = [] + def run(self): # Note: we can't just autoroute to the PSBT Overview because we might have a # multisig where we want to sign with more than one key on this device. @@ -31,32 +35,29 @@ class PSBTSelectSeedView(View): seeds = self.controller.storage.seeds - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # Doesn't look like this seed can sign the current PSBT button_str += " (?)" - button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + self.button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - button_data.append(SCAN_SEED) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) + self.button_data.append(self.SCAN_SEED) + self.button_data.append(self.TYPE_12WORD) + self.button_data.append(self.TYPE_24WORD) if self.controller.psbt_seed: if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # skip the seed prompt if a seed was previous selected and has matching input fingerprint return Destination(PSBTOverviewView) - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Select Signer", is_button_text_centered=False, - button_data=button_data - ).display() + button_data=self.button_data + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -69,13 +70,13 @@ class PSBTSelectSeedView(View): # The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded. self.controller.resume_main_flow = Controller.FLOW__PSBT - if button_data[selected_menu_num] == SCAN_SEED: + if self.button_data[selected_menu_num] == self.SCAN_SEED: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: + if button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -92,8 +93,10 @@ class PSBTOverviewView(View): if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed: # The PSBTParser takes a while to read the PSBT. Run the loading screen while # we wait. + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Parsing PSBT...") self.loading_screen.start() + try: self.controller.psbt_parser = PSBTParser( self.controller.psbt, @@ -128,22 +131,27 @@ class PSBTOverviewView(View): else: num_self_transfer_outputs += 1 - screen = psbt_screens.PSBTOverviewScreen( - spend_amount=psbt_parser.spend_amount, - change_amount=psbt_parser.change_amount, - fee_amount=psbt_parser.fee_amount, - num_inputs=psbt_parser.num_inputs, - num_self_transfer_outputs=num_self_transfer_outputs, - num_change_outputs=num_change_outputs, - destination_addresses=psbt_parser.destination_addresses, - ) + spend_amount = psbt_parser.spend_amount + change_amount = psbt_parser.change_amount + fee_amount = psbt_parser.fee_amount + num_inputs = psbt_parser.num_inputs + destination_addresses = psbt_parser.destination_addresses # Everything is set. Stop the loading screen if self.loading_screen: self.loading_screen.stop() # Run the overview screen - selected_menu_num = screen.display() + selected_menu_num = self.run_screen( + PSBTOverviewScreen, + spend_amount=spend_amount, + change_amount=change_amount, + fee_amount=fee_amount, + num_inputs=num_inputs, + num_self_transfer_outputs=num_self_transfer_outputs, + num_change_outputs=num_change_outputs, + destination_addresses=destination_addresses + ) if selected_menu_num == RET_CODE__BACK_BUTTON: self.controller.psbt_seed = None @@ -214,14 +222,15 @@ class PSBTMathView(View): # Should not be able to get here return Destination(MainMenuView) - selected_menu_num = psbt_screens.PSBTMathScreen( + selected_menu_num = self.run_screen( + PSBTMathScreen, input_amount=psbt_parser.input_amount, num_inputs=psbt_parser.num_inputs, spend_amount=psbt_parser.spend_amount, num_recipients=psbt_parser.num_destinations, fee_amount=psbt_parser.fee_amount, change_amount=psbt_parser.change_amount, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -238,7 +247,10 @@ class PSBTAddressDetailsView(View): """ Shows the recipient's address and amount they will receive """ - def __init__(self, address_num, is_change=False): + NEXT = "Next" + button_data = [] + + def __init__(self, address_num): super().__init__() self.address_num = address_num @@ -254,23 +266,27 @@ class PSBTAddressDetailsView(View): if psbt_parser.num_destinations > 1: title += f" (#{self.address_num + 1})" - button_data = [] if self.address_num < psbt_parser.num_destinations - 1: - button_data.append("Next Recipient") + self.NEXT = "Next Recipient" else: - button_data.append("Next") + self.NEXT = "Next" + self.button_data.append(self.NEXT) - selected_menu_num = psbt_screens.PSBTAddressDetailsScreen( + selected_menu_num = self.run_screen( + PSBTAddressDetailsScreen, title=title, - button_data=button_data, + button_data=self.button_data, address=psbt_parser.destination_addresses[self.address_num], amount=psbt_parser.destination_amounts[self.address_num], - ).display() + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) - if selected_menu_num == 0: + if self.button_data[selected_menu_num] == self.NEXT: if self.address_num < len(psbt_parser.destination_addresses) - 1: # Show the next receive addr - return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1, "is_change": False}) + return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1}) elif psbt_parser.change_amount > 0: # Move on to display change @@ -280,14 +296,13 @@ class PSBTAddressDetailsView(View): # There's no change output to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - class PSBTChangeDetailsView(View): - """ - """ + NEXT = "Next" + VERIFY_MULTISIG = "Verify Multisig Change" + button_data = [NEXT] + def __init__(self, change_address_num): super().__init__() self.change_address_num = change_address_num @@ -327,14 +342,12 @@ class PSBTChangeDetailsView(View): is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1 derivation_path_addr_index = int(derivation_path.split("/")[-1]) - NEXT = "Next" - if is_change_derivation_path: title = "Your Change" - VERIFY_MULTISIG = "Verify Multisig Change" + self.VERIFY_MULTISIG = "Verify Multisig Change" else: title = "Self-Transfer" - VERIFY_MULTISIG = "Verify Multisig Addr" + self.VERIFY_MULTISIG = "Verify Multisig Addr" # if psbt_parser.num_change_outputs > 1: # title += f" (#{self.change_address_num + 1})" @@ -343,11 +356,11 @@ class PSBTChangeDetailsView(View): # if the known-good multisig descriptor is already onboard: if self.controller.multisig_wallet_descriptor: is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num) - button_data = [NEXT] + self.button_data = [self.NEXT] else: # Have the Screen offer to load in the multisig descriptor. - button_data = [VERIFY_MULTISIG, NEXT] + self.button_data = [self.VERIFY_MULTISIG, self.NEXT] else: # Single sig @@ -356,6 +369,7 @@ class PSBTChangeDetailsView(View): loading_screen_text = "Verifying Change..." else: loading_screen_text = "Verifying Self-Transfer..." + from seedsigner.gui.screens.screen import LoadingScreenThread loading_screen = LoadingScreenThread(text=loading_screen_text) loading_screen.start() @@ -389,7 +403,7 @@ class PSBTChangeDetailsView(View): if change_data["address"] == calc_address: is_change_addr_verified = True - button_data = [NEXT] + self.button_data = [self.NEXT] finally: loading_screen.stop() @@ -397,9 +411,10 @@ class PSBTChangeDetailsView(View): if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None): return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True) - selected_menu_num = psbt_screens.PSBTChangeDetailsScreen( + selected_menu_num = self.run_screen( + PSBTChangeDetailsScreen, title=title, - button_data=button_data, + button_data=self.button_data, address=change_data.get("address"), amount=change_data.get("amount"), is_multisig=psbt_parser.is_multisig, @@ -408,19 +423,19 @@ class PSBTChangeDetailsView(View): is_change_derivation_path=is_change_derivation_path, derivation_path_addr_index=derivation_path_addr_index, is_change_addr_verified=is_change_addr_verified, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == NEXT: + elif self.button_data[selected_menu_num] == self.NEXT: if self.change_address_num < psbt_parser.num_change_outputs - 1: return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1}) else: # There's no more change to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - elif button_data[selected_menu_num] == VERIFY_MULTISIG: + elif self.button_data[selected_menu_num] == self.VERIFY_MULTISIG: from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView self.controller.resume_main_flow = Controller.FLOW__PSBT return Destination(LoadMultisigWalletDescriptorView) @@ -467,9 +482,10 @@ class PSBTFinalizeView(View): # Should not be able to get here return Destination(MainMenuView) - selected_menu_num = psbt_screens.PSBTFinalizeScreen( + selected_menu_num = self.run_screen( + PSBTFinalizeScreen, button_data=["Approve PSBT"] - ).display() + ) if selected_menu_num == 0: # Sign PSBT @@ -500,7 +516,7 @@ class PSBTSignedQRDisplayView(View): qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE), ) - QRDisplayScreen(qr_encoder=qr_encoder).display() + self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder) # We're done with this PSBT. Route back to MainMenuView which always # clears all ephemeral data (except in-memory seeds). diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py new file mode 100644 index 00000000..8fe6d862 --- /dev/null +++ b/tests/test_flows_psbt.py @@ -0,0 +1,56 @@ +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.views.view import MainMenuView +from seedsigner.views import scan_views, seed_views, psbt_views + +class TestPSBTFlows(FlowTest): + + def test_scan_psbt_first_then_correct_seedqr_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a PSBT should enter the PSBTSelectSeedView flow + when Scan a Seed is selected from PSBTSelectSeedView it should enter the ScanView flow + when a SeedQR is scanned it should enter the PSBTOverviewView flow + since the PSBT has change no warning is displayed and it should enter the PSBTMathView flow + since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + """ + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ + - 2 Inputs + - 56,522,834 sats + - 1,990,245,069 sats + - 4 Outputs + - 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats + - 3 Outputs change + - 3 outputs to emulate a fake mix to increase privacy + - Change addresses are index 1/7, 1/8, 1/9 + - 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats + - 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats + - 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats + - Fee 272 sats + """ + view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==") + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("080115060387063104071857067618681125136207731354") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, run_before=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(psbt_views.PSBTOverviewView), + FlowStep(psbt_views.PSBTMathView), + FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=psbt_views.PSBTAddressDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTFinalizeView, screen_return_value=StopControllerCommand()), + FlowStep(psbt_views.PSBTSignedQRDisplayView), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()) + ]) \ No newline at end of file From 91400bc4cee2c0623cf70dadf846be455b1c32b5 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Fri, 14 Apr 2023 14:35:21 -0400 Subject: [PATCH 30/42] remove extra StopControllerCommand in test and fix PSBTFinalizeView --- src/seedsigner/views/psbt_views.py | 9 ++++++--- tests/test_flows_psbt.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index a4eb7408..7c7e2bf4 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -474,6 +474,9 @@ class PSBTAddressVerificationFailedView(View): class PSBTFinalizeView(View): """ """ + APPROVE_PSBT = "Approve PSBT" + button_data = [APPROVE_PSBT] + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser psbt: PSBT = self.controller.psbt @@ -481,13 +484,13 @@ class PSBTFinalizeView(View): if not psbt_parser: # Should not be able to get here return Destination(MainMenuView) - + selected_menu_num = self.run_screen( PSBTFinalizeScreen, - button_data=["Approve PSBT"] + button_data=self.button_data ) - if selected_menu_num == 0: + if self.button_data[selected_menu_num] == self.APPROVE_PSBT: # Sign PSBT sig_cnt = PSBTParser.sig_count(psbt) psbt.sign_with(psbt_parser.root) diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 8fe6d862..075b0d04 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -50,7 +50,7 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), - FlowStep(psbt_views.PSBTFinalizeView, screen_return_value=StopControllerCommand()), + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView, screen_return_value=StopControllerCommand()) ]) \ No newline at end of file From 69ab92dbf7083d23f6e2b5bb8f516c422ff6d736 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Wed, 28 Jun 2023 22:11:02 -0400 Subject: [PATCH 31/42] update test_flows_psbt.py to work with base.py breaking changes --- tests/test_flows_psbt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 075b0d04..f8d9c7fd 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -1,6 +1,6 @@ from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, StopFlowBasedTest from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views @@ -39,9 +39,9 @@ class TestPSBTFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), - FlowStep(scan_views.ScanView, run_before=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), - FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), FlowStep(psbt_views.PSBTOverviewView), @@ -52,5 +52,5 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()) + FlowStep(MainMenuView) ]) \ No newline at end of file From 9b3959050c106f2668fe43e878bdb97cada4598c Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 7 Jul 2023 08:00:12 -0500 Subject: [PATCH 32/42] Additional flow-specific test exceptions, reorg * detects when `View.run_screen` did not execute. * detects when the supplied button_data_selection isn't a valid option in `Screen.button_data`. * Renamed internal mock functions in `FlowTest.run_sequence()` for better clarity. --- src/seedsigner/controller.py | 10 +- src/seedsigner/views/seed_views.py | 5 +- tests/base.py | 152 +++++++++++++++++------------ tests/test_flows.py | 55 ++++++++--- 4 files changed, 143 insertions(+), 79 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 05fdee6a..3f6d4b9d 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -40,10 +40,9 @@ class StopFlowBasedTest(Exception): pass -class FlowBasedTestUnexpectedViewError(Exception): +class FlowBasedTestException(Exception): """ - This is a special exception that is only raised by the test suite to indicate - that the Controller's current View does not match the expected TestFlow sequence. + This is a special exception that is only raised by the test suite. It should not be raised by any other code. """ pass @@ -276,9 +275,8 @@ class Controller(Singleton): # to stop the Controller loop and exit the test. return - except FlowBasedTestUnexpectedViewError as e: - # This is a special exception that is only raised by the test suite - # when a flow-based test fails to follow the expected sequence. + except FlowBasedTestException as e: + # This is a special exception that is only raised by the test suite. # Re-raise so the test suite can handle it. raise e diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0dda88da..02e160b5 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -814,9 +814,10 @@ class SeedWordsWarningView(View): # Forward straight to showing the words return destination - selected_menu_num = DireWarningScreen( + selected_menu_num = self.run_screen( + DireWarningScreen, text="""You must keep your seed words private & away from all online devices.""", - ).display() + ) if selected_menu_num == 0: # User clicked "I Understand" diff --git a/tests/base.py b/tests/base.py index 27dd2d4e..fb8f1bca 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,7 +12,8 @@ sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() -from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError, StopFlowBasedTest +from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models import Settings from seedsigner.views.view import Destination, MainMenuView, View @@ -81,6 +82,7 @@ class FlowStep: * before_run: function that takes a View instance as an arg and modifies it before running the View. * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. + * is_redirect: expects the Destination to specify `skip_current_view=True`. """ expected_view: type[View] = None before_run: Callable[[View], None] = None @@ -94,75 +96,107 @@ class FlowStep: +class FlowTestInvalidButtonDataSelectionException(FlowBasedTestException): + """ The FlowStep's button_data_selection value was not found in the View's button_data """ + pass + + + +class FlowTestUnexpectedViewException(FlowBasedTestException): + """ The Controller's current View did not match the expected View in the FlowStep sequence """ + pass + + + +class FlowTestRunScreenNotExecutedException(FlowBasedTestException): + """ The View's run_screen() method was not called but the FlowStep expected it to need user input """ + pass + + + class FlowTest(BaseTest): """ Base class for any tests that do flow-based testing """ + def stop_test(self): + """ Raise our special Exception to stop the test """ + raise StopFlowBasedTest() + + def run_sequence(self, sequence: list[FlowStep], initial_destination_view_args: dict = None) -> None: """ Run a pre-set sequence of Views w/manually-specified return values in order to test the Controller's flow control logic and the routing from View to View. """ - def verify_next_View_cls_and_run_view(destination: Destination, *args, **kwargs): - # Verify that the View class specified in the test sequence matches the - # View class that is being run. - if destination.View_cls != sequence[0].expected_view: - raise FlowBasedTestUnexpectedViewError(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") - print(f"Confirmed {sequence[0].expected_view} is the next View") - - if len(sequence) == 1: - # The last entry in the sequence just needs to confirm that we got the - # right View class; we can now stop the Controller and exit the test. - raise StopFlowBasedTest() - - # Run the optional pre-run function to modify the View. - if sequence[0].before_run: - sequence[0].before_run(destination.view) - - if sequence[0].is_redirect: - # The current View is going to auto-redirect without calling run_screen(), - # so we need to remove the current step from the sequence before the - # View.run() call below. - sequence.pop(0) - - # Some Views reach into their Screen's variables directly (e.g. - # Screen.buttons to preserve the scroll position), so we need to mock out the - # Screen instance that is created by the View. - destination.view.screen = MagicMock() - - # Run the View (we're mocking out View._run_view() so the Destination - # won't actually run the View unless we explicitly do so here). - return destination.view.run() - - - def next_return_value(view: View, *args, **kwargs): - # Return the return value specified in the test sequence and - # remove the completed test step from the sequence. - flow_step = sequence.pop(0) - - if flow_step.button_data_selection: - # We're mocking out the View.run_screen() method, so we'll get all of the - # input args that are normally passed into the Screen.run() method, - # including the button_data kwarg. - if "button_data" in kwargs: - return kwargs.get("button_data").index(flow_step.button_data_selection) - else: - raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") - - elif type(flow_step.screen_return_value) in [StopFlowBasedTest, Exception]: - raise flow_step.screen_return_value - - return flow_step.screen_return_value - - with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_run_view: - # Mock out the View._run_view() method so we can verify the View class - # that is specified in the test sequence and then run the View. - mock_run_view.side_effect = verify_next_View_cls_and_run_view - with patch("seedsigner.views.view.View.run_screen", autospec=True) as mock_run_screen: + def run_view(destination: Destination, *args, **kwargs): + """ Replaces Destination._run_view() """ + if len(sequence) == 0: + self.stop_test() + + cur_flow_step = sequence[0] + + # Verify that the View class specified in the test sequence matches the + # View class that is being run. + if destination.View_cls != cur_flow_step.expected_view: + raise FlowTestUnexpectedViewException(f"Expected {cur_flow_step.expected_view}, got {destination.View_cls}") + + # Run the optional pre-run function to modify the View. + if cur_flow_step.before_run: + cur_flow_step.before_run(destination.view) + + if cur_flow_step.is_redirect: + # The current View is going to auto-redirect without calling run_screen(), + # so we need to remove the current step from the sequence before the + # View.run() call below. + sequence.pop(0) + + # Some Views reach into their Screen's variables directly (e.g. + # Screen.buttons to preserve the scroll position), so we need to mock out the + # Screen instance that is created by the View. + destination.view.screen = MagicMock() + + prev_mock_run_screen_call_count = mock_run_screen.call_count + + # Run the View (with our mocked run_screen) and get the next Destination that results + destination = destination.view.run() + + if (cur_flow_step.button_data_selection or cur_flow_step.screen_return_value is not None) and mock_run_screen.call_count == prev_mock_run_screen_call_count: + # The FlowStep was expecting some kind of user interaction, but the View + # never called run_screen(). + raise FlowTestRunScreenNotExecutedException(f"View.run_screen() was not run for {destination.View_cls.__name__}") + + return destination + + def run_screen(view: View, *args, **kwargs): + """ Replaces View.run_screen() """ + # Return the return value specified in the test sequence and + # remove the completed test step from the sequence. + flow_step = sequence.pop(0) + + if flow_step.button_data_selection: + # We're mocking out the View.run_screen() method, so we'll get all of the + # input args that are normally passed into the Screen.run() method, + # including the button_data kwarg. + if "button_data" in kwargs: + if flow_step.button_data_selection not in kwargs.get("button_data") and flow_step.button_data_selection not in [RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON]: + raise FlowTestInvalidButtonDataSelectionException(f"'{flow_step.button_data_selection}' not found in button_data: {kwargs.get('button_data')}") + return kwargs.get("button_data").index(flow_step.button_data_selection) + else: + raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") + + elif type(flow_step.screen_return_value) in [StopFlowBasedTest, FlowBasedTestException]: + raise flow_step.screen_return_value + + return flow_step.screen_return_value + + # Mock out the Destination._run_view() method so we can verify the View class + # that is specified in the test sequence and then run the View. + mock_run_view.side_effect = run_view + # Mock out the View.run_screen() method so we can provide the # return value that is specified in the test sequence. - mock_run_screen.side_effect = next_return_value + mock_run_screen.side_effect = run_screen # Start the Controller with the first View_cls specified in the test sequence if sequence[0].expected_view != MainMenuView: @@ -172,5 +206,3 @@ class FlowTest(BaseTest): # Start the Controller and run the sequence Controller.get_instance().start(initial_destination=initial_destination) - - diff --git a/tests/test_flows.py b/tests/test_flows.py index f900d1bc..3b99eb9d 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,12 +1,13 @@ import pytest # Must import test base before the Controller -from base import FlowTest, FlowStep +from base import FlowTest, FlowStep, FlowTestUnexpectedViewException, FlowTestInvalidButtonDataSelectionException, FlowTestRunScreenNotExecutedException -from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError +from seedsigner.controller import Controller from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models.seed import Seed -from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView from seedsigner.views.view import MainMenuView, PowerOptionsView, UnhandledExceptionView from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView @@ -27,17 +28,49 @@ class TestFlowTest(FlowTest): ]) - def test_wrong_View_cls(self): + def test_FlowTestUnexpectedViewException(self): """ - Ensure that the FlowTest will raise an AssertionError if the next View in the - sequence is not the expected View. + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the next + View in the sequence is not the expected View. """ - with pytest.raises(FlowBasedTestUnexpectedViewError): + with pytest.raises(FlowTestUnexpectedViewException): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. ]) - + + + def test_FlowTestInvalidButtonDataSelectionException(self): + """ + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the + specified button_data_selection in invalid. + """ + with pytest.raises(FlowTestInvalidButtonDataSelectionException): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection="this is not a real button option!"), + ]) + + + def test_FlowTestRunScreenNotExecutedException(self): + """ + Ensure that the FlowTest will raise a FlowTestRunScreenNotExecutedException if the next + View in the sequence doesn't call its View.run_screen(). + """ + # Disable dire warnings so that the SeedWordsWarningView won't execute its run_screen() + self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED) + self.controller.storage.set_pending_seed(Seed(mnemonic=["bacon"] * 24)) + self.controller.storage.finalize_pending_seed() + + with pytest.raises(FlowTestRunScreenNotExecutedException): + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), + FlowStep(SeedBackupView, button_data_selection=SeedBackupView.VIEW_WORDS), + FlowStep(SeedWordsWarningView, screen_return_value=0), + ], + ) + def test_before_run_executes(self): """ @@ -69,12 +102,12 @@ class TestFlowTest(FlowTest): # And again, but this time with a View that requires input view_args self.reset_controller() + self.controller = Controller.get_instance() # Load a seed into the Controller - controller = Controller.get_instance() seed = Seed(mnemonic=["abandon "* 11 + "about"]) - controller.storage.set_pending_seed(seed) - controller.storage.finalize_pending_seed() + self.controller.storage.set_pending_seed(seed) + self.controller.storage.finalize_pending_seed() self.run_sequence( initial_destination_view_args=dict(seed_num=0), From 3c4c91a0af713b5763c8d558e9911fd7c7da4245 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sat, 17 Jun 2023 15:36:32 -0400 Subject: [PATCH 33/42] baby steps for jdlcdl (post hard reset) --- src/seedsigner/views/seed_views.py | 20 +++++++++----- tests/test_flows_seed.py | 43 ++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 02e160b5..5545ad06 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -617,11 +617,12 @@ class SeedExportXpubCoordinatorView(View): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0] return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True) - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", is_button_text_centered=False, button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), - ).display() + ) if selected_menu_num < len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[selected_menu_num] @@ -659,10 +660,11 @@ class SeedExportXpubWarningView(View): # Skip the WarningView entirely return destination - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, status_headline="Privacy Leak!", text="""Xpub can be used to view all future transactions.""", - ).display() + ) if selected_menu_num == 0: # User clicked "I Understand" @@ -727,12 +729,13 @@ class SeedExportXpubDetailsView(View): finally: self.loading_screen.stop() - selected_menu_num = seed_screens.SeedExportXpubDetailsScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedExportXpubDetailsScreen, fingerprint=fingerprint, has_passphrase=self.seed.passphrase is not None, derivation_path=derivation_path, xpub=xpub_base58, - ).display() + ) if selected_menu_num == 0: return Destination( @@ -784,7 +787,10 @@ class SeedExportXpubQRDisplayView(View): def run(self): - QRDisplayScreen(qr_encoder=self.qr_encoder).display() + self.run_screen( + QRDisplayScreen, + qr_encoder=self.qr_encoder + ) return Destination(MainMenuView) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index a8ea4e03..df93c073 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep +from seedsigner.models.settings import SettingsConstants from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -91,40 +92,66 @@ class TestSeedFlows(FlowTest): def test_export_xpub_flow(self): """ - Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the SeedOptionsView. + Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ # Load a finalized Seed into the Controller mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() + SIG_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # 0: single-sig + SCRIPT_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[0] # 0: native segwit + COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0] # 0: bluwallet + COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # 0: specter + + # TEST PASSES BUT RAISES WARNING + # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ + # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): + # TypeError: '<' not supported between instances of 'int' and 'MagicMock' + # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), - FlowStep(seed_views.SeedExportXpubScriptTypeView), - # TODO: Test is incomplete... + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=SIG_TYPE), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SCRIPT_TYPE), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=COORDINATOR), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), ] ) def test_export_xpub_skip_sig_type_flow(self): """ - + Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators """ # Load a finalized Seed into the Controller mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() + # exclusively set only one choice for each of sig_types, script_types and coordinators + self.settings.update(dict(sig_types=['ms'], script_types=['nes'], coordinators=['spa']), False) + + # TEST PASSES BUT RAISES WARNING + # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ + # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): + # TypeError: '<' not supported between instances of 'int' and 'MagicMock' + # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), - FlowStep(seed_views.SeedExportXpubScriptTypeView), - # TODO: Test is incomplete... + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubCoordinatorView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), ] ) From 7edc14fce8aa22c4781bad287c283ebd0f0b3b44 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 05:57:22 -0400 Subject: [PATCH 34/42] exhaustive flow test for exporting standard xpubs --- tests/test_flows_seed.py | 69 ++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index df93c073..b5d4b370 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.models.settings import SettingsConstants +from seedsigner.models.settings import SettingsConstants as SC from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -90,41 +90,62 @@ class TestSeedFlows(FlowTest): self.run_sequence(sequence) - def test_export_xpub_flow(self): + def test_export_xpub_standard_flow(self): """ Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - # Load a finalized Seed into the Controller - mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() - self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) - self.controller.storage.finalize_pending_seed() - - SIG_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # 0: single-sig - SCRIPT_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[0] # 0: native segwit - COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0] # 0: bluwallet - COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # 0: specter # TEST PASSES BUT RAISES WARNING # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): # TypeError: '<' not supported between instances of 'int' and 'MagicMock' # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) - self.run_sequence( - initial_destination_view_args=dict(seed_num=0), - sequence=[ - FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=SIG_TYPE), - FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SCRIPT_TYPE), - FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=COORDINATOR), - FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), - FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), - FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), - FlowStep(MainMenuView), - ] + def test_standard_xpubs(sig_tuple, script_tuple, coord_tuple): + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_tuple[1]), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_tuple[1]), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coord_tuple[1]), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] ) + + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # these are (constant_value, display_name) tuples + sig_types = SC.ALL_SIG_TYPES + script_types = SC.ALL_SCRIPT_TYPES + coordinators = SC.ALL_COORDINATORS + + # enable non-defaults so they're available in views + self.settings.set_value(SC.SETTING__SIG_TYPES, [x for x,y in sig_types]) + self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) + self.settings.set_value(SC.SETTING__COORDINATORS, [x for x,y in coordinators]) + + # exhaustively test flows thru standard sig_types, script_types, and coordinators + for sig_tuple in sig_types: + for script_tuple in script_types: + for coord_tuple in coordinators: + # skip custom derivation + if script_tuple[0] == 'cus': + continue + # skip multisig taproot + elif sig_tuple[0] == "ms" and script_tuple[0] == 'tr': + continue + else: + print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) + test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) - def test_export_xpub_skip_sig_type_flow(self): + def test_export_xpub_skip_non_option_flow(self): """ Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators """ From ac2d92c1d4e7263e9457653849395c8105f18341 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 06:37:49 -0400 Subject: [PATCH 35/42] settings constants instead of lazy hardcoding --- tests/test_flows_seed.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index b5d4b370..3896beb5 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -135,10 +135,10 @@ class TestSeedFlows(FlowTest): for script_tuple in script_types: for coord_tuple in coordinators: # skip custom derivation - if script_tuple[0] == 'cus': + if script_tuple[0] == SC.CUSTOM_DERIVATION: continue # skip multisig taproot - elif sig_tuple[0] == "ms" and script_tuple[0] == 'tr': + elif sig_tuple[0] == SC.MULTISIG and script_tuple[0] == SC.TAPROOT: continue else: print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) @@ -155,7 +155,11 @@ class TestSeedFlows(FlowTest): self.controller.storage.finalize_pending_seed() # exclusively set only one choice for each of sig_types, script_types and coordinators - self.settings.update(dict(sig_types=['ms'], script_types=['nes'], coordinators=['spa']), False) + self.settings.update({ + SC.SETTING__SIG_TYPES: SC.MULTISIG, + SC.SETTING__SCRIPT_TYPES: SC.NESTED_SEGWIT, + SC.SETTING__COORDINATORS: SC.COORDINATOR__SPECTER_DESKTOP, + }, disable_missing_entries=False) # TEST PASSES BUT RAISES WARNING # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ From 1509df72b8c02ef4121625123bb6995df7631f35 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 07:59:56 -0400 Subject: [PATCH 36/42] flow test: export xpub for custom derivation --- src/seedsigner/views/seed_views.py | 5 ++-- tests/test_flows_seed.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 5545ad06..1e14f914 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -570,9 +570,10 @@ class SeedExportXpubCustomDerivationView(View): def run(self): - ret = seed_screens.SeedExportXpubCustomDerivationScreen( + ret = self.run_screen( + seed_screens.SeedExportXpubCustomDerivationScreen, initial_value=self.custom_derivation_path, - ).display() + ) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 3896beb5..0ae78d23 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -145,6 +145,44 @@ class TestSeedFlows(FlowTest): test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) + def test_export_xpub_custom_derivation_flow(self): + """ + Export XPUB flow for custom derivation finishes at MainMenuView + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # enable custom derivation script_type setting (plus at least one more for a choice) + self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [SC.NATIVE_SEGWIT, SC.NESTED_SEGWIT, SC.CUSTOM_DERIVATION]) + + # get display names to access button choices in the views (ugh: hardcoding, is there a better way?) + sig_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SIG_TYPES)[0] # single sig + script_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SCRIPT_TYPES)[2] # custom derivation + coordinator = self.settings.get_multiselect_value_display_names(SC.SETTING__COORDINATORS)[3] # specter + + # TEST PASSES BUT RAISES WARNING + # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ + # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): + # TypeError: '<' not supported between instances of 'int' and 'MagicMock' + # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_type), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_type), + FlowStep(seed_views.SeedExportXpubCustomDerivationView, screen_return_value="m/0'/0'"), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coordinator), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + def test_export_xpub_skip_non_option_flow(self): """ Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators From b842f1fe1aba3b5b124cd55002e20dfe2972a32c Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 08:51:07 -0400 Subject: [PATCH 37/42] local import of LoadScreenThread to avoid flow-test warnings --- src/seedsigner/views/seed_views.py | 5 ++++- tests/test_flows_seed.py | 15 --------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 1e14f914..cad143de 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -13,7 +13,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustom from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -707,6 +707,9 @@ class SeedExportXpubDetailsView(View): selected_menu_num = 0 else: + # imported locally instead of at module level to avoid flow-test warnings: "comparing int to MagicMock" + from seedsigner.gui.screens.screen import LoadingScreenThread + # The derivation calc takes a few moments. Run the loading screen while we wait. self.loading_screen = LoadingScreenThread(text="Generating xpub...") self.loading_screen.start() diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 0ae78d23..187900a4 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -95,11 +95,6 @@ class TestSeedFlows(FlowTest): Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - # TEST PASSES BUT RAISES WARNING - # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ - # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # TypeError: '<' not supported between instances of 'int' and 'MagicMock' - # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) def test_standard_xpubs(sig_tuple, script_tuple, coord_tuple): self.run_sequence( initial_destination_view_args=dict(seed_num=0), @@ -162,11 +157,6 @@ class TestSeedFlows(FlowTest): script_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SCRIPT_TYPES)[2] # custom derivation coordinator = self.settings.get_multiselect_value_display_names(SC.SETTING__COORDINATORS)[3] # specter - # TEST PASSES BUT RAISES WARNING - # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ - # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # TypeError: '<' not supported between instances of 'int' and 'MagicMock' - # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ @@ -199,11 +189,6 @@ class TestSeedFlows(FlowTest): SC.SETTING__COORDINATORS: SC.COORDINATOR__SPECTER_DESKTOP, }, disable_missing_entries=False) - # TEST PASSES BUT RAISES WARNING - # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ - # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # TypeError: '<' not supported between instances of 'int' and 'MagicMock' - # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ From 39962b98d9148bffbf752d34e06d5a0958832e9a Mon Sep 17 00:00:00 2001 From: Jean Do Date: Thu, 22 Jun 2023 04:31:50 -0400 Subject: [PATCH 38/42] more consistent with existing convention --- tests/test_flows_seed.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 187900a4..d2f22876 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.models.settings import SettingsConstants as SC +from seedsigner.models.settings import SettingsConstants from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -116,24 +116,24 @@ class TestSeedFlows(FlowTest): self.controller.storage.finalize_pending_seed() # these are (constant_value, display_name) tuples - sig_types = SC.ALL_SIG_TYPES - script_types = SC.ALL_SCRIPT_TYPES - coordinators = SC.ALL_COORDINATORS + sig_types = SettingsConstants.ALL_SIG_TYPES + script_types = SettingsConstants.ALL_SCRIPT_TYPES + coordinators = SettingsConstants.ALL_COORDINATORS # enable non-defaults so they're available in views - self.settings.set_value(SC.SETTING__SIG_TYPES, [x for x,y in sig_types]) - self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) - self.settings.set_value(SC.SETTING__COORDINATORS, [x for x,y in coordinators]) + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators]) # exhaustively test flows thru standard sig_types, script_types, and coordinators for sig_tuple in sig_types: for script_tuple in script_types: for coord_tuple in coordinators: # skip custom derivation - if script_tuple[0] == SC.CUSTOM_DERIVATION: + if script_tuple[0] == SettingsConstants.CUSTOM_DERIVATION: continue # skip multisig taproot - elif sig_tuple[0] == SC.MULTISIG and script_tuple[0] == SC.TAPROOT: + elif sig_tuple[0] == SettingsConstants.MULTISIG and script_tuple[0] == SettingsConstants.TAPROOT: continue else: print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) @@ -150,12 +150,16 @@ class TestSeedFlows(FlowTest): self.controller.storage.finalize_pending_seed() # enable custom derivation script_type setting (plus at least one more for a choice) - self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [SC.NATIVE_SEGWIT, SC.NESTED_SEGWIT, SC.CUSTOM_DERIVATION]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [ + SettingsConstants.NATIVE_SEGWIT, + SettingsConstants.NESTED_SEGWIT, + SettingsConstants.CUSTOM_DERIVATION + ]) # get display names to access button choices in the views (ugh: hardcoding, is there a better way?) - sig_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SIG_TYPES)[0] # single sig - script_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SCRIPT_TYPES)[2] # custom derivation - coordinator = self.settings.get_multiselect_value_display_names(SC.SETTING__COORDINATORS)[3] # specter + sig_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # single sig + script_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2] # custom derivation + coordinator = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # specter self.run_sequence( initial_destination_view_args=dict(seed_num=0), @@ -184,9 +188,9 @@ class TestSeedFlows(FlowTest): # exclusively set only one choice for each of sig_types, script_types and coordinators self.settings.update({ - SC.SETTING__SIG_TYPES: SC.MULTISIG, - SC.SETTING__SCRIPT_TYPES: SC.NESTED_SEGWIT, - SC.SETTING__COORDINATORS: SC.COORDINATOR__SPECTER_DESKTOP, + SettingsConstants.SETTING__SIG_TYPES: SettingsConstants.MULTISIG, + SettingsConstants.SETTING__SCRIPT_TYPES: SettingsConstants.NESTED_SEGWIT, + SettingsConstants.SETTING__COORDINATORS: SettingsConstants.COORDINATOR__SPECTER_DESKTOP, }, disable_missing_entries=False) self.run_sequence( From e25de29a64cd2ce4d3a2ec403600eed2b3bed8cf Mon Sep 17 00:00:00 2001 From: Jean Do Date: Thu, 22 Jun 2023 07:24:31 -0400 Subject: [PATCH 39/42] Export xpub flow test for disabled sig/script/coord settings --- tests/test_flows_seed.py | 64 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index d2f22876..7090e2fd 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,7 @@ # Must import test base before the Controller -from base import BaseTest, FlowTest, FlowStep +from base import BaseTest, FlowTest, FlowStep, FlowBasedTestUnexpectedViewError +import pytest from seedsigner.models.settings import SettingsConstants from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView @@ -140,6 +141,67 @@ class TestSeedFlows(FlowTest): test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) + def test_export_xpub_disabled_not_available_flow(self): + """ + If sig_type/script_type/coordinator disabled, then these options are not available + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # these are (constant_value, display_name) tuples + sig_types = SettingsConstants.ALL_SIG_TYPES + script_types = SettingsConstants.ALL_SCRIPT_TYPES + coordinators = SettingsConstants.ALL_COORDINATORS + + # these are the disabled types that we will be testing + disabled_sig = SettingsConstants.MULTISIG + disabled_script = SettingsConstants.TAPROOT + disabled_coord = SettingsConstants.COORDINATOR__NUNCHUK + + # enable all but our target disabled type + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types if x!=disabled_sig]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord]) + + # test that multisig is not an option via exception raised when NOT redirected to next step w/o a choice + with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=disabled_sig), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + # test that taproot is not an option via exception raised when choice is taproot + with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=disabled_script), + FlowStep(seed_views.SeedExportXpubCoordinatorView), + ] + ) + + # test that nunchuk is not an option via exception raised when choice is nunchuk + with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord), + FlowStep(seed_views.SeedExportXpubWarningView), + ] + ) + + def test_export_xpub_custom_derivation_flow(self): """ Export XPUB flow for custom derivation finishes at MainMenuView From 35b3dc65cd3e5088d448e4fb1bc50162ac382f08 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 4 Jul 2023 16:55:48 -0400 Subject: [PATCH 40/42] code cleanup * funcs internal to test_funcs shouldn't start with "test" * comments in test modules preferred to repeating comments in code * type hinting in code preferred to explaining w/ comments --- src/seedsigner/views/seed_views.py | 4 +--- tests/base.py | 2 +- tests/test_flows_seed.py | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index cad143de..c2458ee7 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -707,10 +707,8 @@ class SeedExportXpubDetailsView(View): selected_menu_num = 0 else: - # imported locally instead of at module level to avoid flow-test warnings: "comparing int to MagicMock" - from seedsigner.gui.screens.screen import LoadingScreenThread - # The derivation calc takes a few moments. Run the loading screen while we wait. + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Generating xpub...") self.loading_screen.start() diff --git a/tests/base.py b/tests/base.py index fb8f1bca..78d3c61a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -26,7 +26,7 @@ class BaseTest: # Ensure there are no on-disk artifacts after running tests. Settings.SETTINGS_FILENAME = "settings-test.json" - # Mock out the loading screen so it can't spawn. + # Mock out the loading screen so it can't spawn. View classes must import locally! patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 7090e2fd..18628f1e 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -96,7 +96,7 @@ class TestSeedFlows(FlowTest): Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - def test_standard_xpubs(sig_tuple, script_tuple, coord_tuple): + def flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple): self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ @@ -116,10 +116,10 @@ class TestSeedFlows(FlowTest): self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() - # these are (constant_value, display_name) tuples - sig_types = SettingsConstants.ALL_SIG_TYPES - script_types = SettingsConstants.ALL_SCRIPT_TYPES - coordinators = SettingsConstants.ALL_COORDINATORS + # these are lists of (constant_value, display_name) tuples + sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES + script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES + coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS # enable non-defaults so they're available in views self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types]) @@ -138,7 +138,7 @@ class TestSeedFlows(FlowTest): continue else: print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) - test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) + flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple) def test_export_xpub_disabled_not_available_flow(self): @@ -150,10 +150,10 @@ class TestSeedFlows(FlowTest): self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() - # these are (constant_value, display_name) tuples - sig_types = SettingsConstants.ALL_SIG_TYPES - script_types = SettingsConstants.ALL_SCRIPT_TYPES - coordinators = SettingsConstants.ALL_COORDINATORS + # these are lists of (constant_value, display_name) tuples + sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES + script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES + coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS # these are the disabled types that we will be testing disabled_sig = SettingsConstants.MULTISIG From 01c4f51f3345212d02b0e6f60c7311c5a0ec31c4 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Fri, 7 Jul 2023 11:21:50 -0400 Subject: [PATCH 41/42] more precise flow-test exceptions --- tests/test_flows_seed.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 18628f1e..973518d3 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,5 +1,6 @@ # Must import test base before the Controller -from base import BaseTest, FlowTest, FlowStep, FlowBasedTestUnexpectedViewError +from base import BaseTest, FlowTest, FlowStep +from base import FlowTestUnexpectedViewException, FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException import pytest from seedsigner.models.settings import SettingsConstants @@ -165,31 +166,29 @@ class TestSeedFlows(FlowTest): self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script]) self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord]) - # test that multisig is not an option via exception raised when NOT redirected to next step w/o a choice - with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + # test that multisig is not an option via exception raised when redirected to next step instead of having a choice + with pytest.raises(FlowTestRunScreenNotExecutedException) as e: self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=disabled_sig), - FlowStep(seed_views.SeedExportXpubScriptTypeView), ] ) # test that taproot is not an option via exception raised when choice is taproot - with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=disabled_script), - FlowStep(seed_views.SeedExportXpubCoordinatorView), ] ) # test that nunchuk is not an option via exception raised when choice is nunchuk - with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ @@ -197,7 +196,6 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0), FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord), - FlowStep(seed_views.SeedExportXpubWarningView), ] ) From b843b6b9fcfc787a937403242b2d98fda1abcb10 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sat, 8 Jul 2023 13:18:46 -0400 Subject: [PATCH 42/42] pr_339 post-review comments implemented --- src/seedsigner/views/psbt_views.py | 18 ++++++------------ tests/base.py | 2 +- tests/test_flows_psbt.py | 4 ++-- tests/test_flows_settings.py | 3 +-- tests/test_flows_view.py | 11 +++++------ 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 7c7e2bf4..fc5b4763 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -12,7 +12,7 @@ from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.settings import SettingsConstants from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen -from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen, WarningScreen) +from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen) from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -131,12 +131,6 @@ class PSBTOverviewView(View): else: num_self_transfer_outputs += 1 - spend_amount = psbt_parser.spend_amount - change_amount = psbt_parser.change_amount - fee_amount = psbt_parser.fee_amount - num_inputs = psbt_parser.num_inputs - destination_addresses = psbt_parser.destination_addresses - # Everything is set. Stop the loading screen if self.loading_screen: self.loading_screen.stop() @@ -144,13 +138,13 @@ class PSBTOverviewView(View): # Run the overview screen selected_menu_num = self.run_screen( PSBTOverviewScreen, - spend_amount=spend_amount, - change_amount=change_amount, - fee_amount=fee_amount, - num_inputs=num_inputs, + spend_amount=psbt_parser.spend_amount, + change_amount=psbt_parser.change_amount, + fee_amount=psbt_parser.fee_amount, + num_inputs=psbt_parser.num_inputs, num_self_transfer_outputs=num_self_transfer_outputs, num_change_outputs=num_change_outputs, - destination_addresses=destination_addresses + destination_addresses=psbt_parser.destination_addresses ) if selected_menu_num == RET_CODE__BACK_BUTTON: diff --git a/tests/base.py b/tests/base.py index 78d3c61a..72bd55f3 100644 --- a/tests/base.py +++ b/tests/base.py @@ -50,7 +50,7 @@ class BaseTest: if os.path.exists(Settings.SETTINGS_FILENAME): os.remove(Settings.SETTINGS_FILENAME) except: - print(f"{Settings.SETTINGS_FILENAME} not found to be removed") + print(f"{Settings.SETTINGS_FILENAME} could not be removed") @classmethod diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index f8d9c7fd..f50491a1 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -1,6 +1,6 @@ from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopFlowBasedTest +from seedsigner.controller import Controller from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views @@ -53,4 +53,4 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView) - ]) \ No newline at end of file + ]) diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 4bf61212..8c603170 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -3,7 +3,6 @@ import os # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopFlowBasedTest from seedsigner.models import SettingsDefinition from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants @@ -28,7 +27,7 @@ class TestSettingsFlows(FlowTest): FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopFlowBasedTest()), + FlowStep(settings_views.SettingsMenuView), ]) # Settings file should now exist diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 43c7b9f0..3f4d537a 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -3,7 +3,6 @@ from mock import patch # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON from seedsigner.models.settings import Settings from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView @@ -21,7 +20,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), - FlowStep(RestartView, screen_return_value=StopFlowBasedTest()), + FlowStep(RestartView), ]) @@ -33,7 +32,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), - FlowStep(PowerOffView, screen_return_value=StopFlowBasedTest()), + FlowStep(PowerOffView), ]) # And again, but this time as if we were in the SeedSigner OS @@ -42,7 +41,7 @@ class TestViewFlows(FlowTest): FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), FlowStep(PowerOffView), # returns BackStackView - FlowStep(PowerOptionsView, screen_return_value=StopFlowBasedTest()), + FlowStep(PowerOptionsView), ]) @@ -58,7 +57,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(IncompleteView), FlowStep(NotYetImplementedView), - FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), + FlowStep(MainMenuView), ]) @@ -71,5 +70,5 @@ class TestViewFlows(FlowTest): FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception FlowStep(UnhandledExceptionView), - FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), + FlowStep(MainMenuView), ])