diff --git a/docs/manual_installation.md b/docs/manual_installation.md index addba92c..7b6d42c8 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -177,6 +177,7 @@ cd seedsigner ```bash # Takes 1hr 45min on a Pi Zero 1.3 pip install -r requirements.txt +pip install -r requirements-raspi.txt ``` #### `pyzbar` 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/controller.py b/src/seedsigner/controller.py index 8f68c264..3f6d4b9d 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -32,6 +32,23 @@ class BackStack(List[Destination]): +class StopFlowBasedTest(Exception): + """ + 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 FlowBasedTestException(Exception): + """ + This is a special exception that is only raised by the test suite. + It should not be raised by any other code. + """ + pass + + + class Controller(Singleton): """ The Controller is a globally available singleton that maintains SeedSigner state. @@ -185,7 +202,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 +241,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 +266,24 @@ 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 StopFlowBasedTest: + # This is a special exception that is only raised by the test suite + # to stop the Controller loop and exit the test. + return + + 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 + 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/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index b4be014e..547112a9 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -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[str | int], str] = None default_value: Any = None def __post_init__(self): diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 59bda243..fc5b4763 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) 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,23 +131,22 @@ class PSBTOverviewView(View): else: num_self_transfer_outputs += 1 - screen = psbt_screens.PSBTOverviewScreen( + # Everything is set. Stop the loading screen + if self.loading_screen: + self.loading_screen.stop() + + # Run the overview screen + selected_menu_num = self.run_screen( + 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, + 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() - if selected_menu_num == RET_CODE__BACK_BUTTON: self.controller.psbt_seed = None return Destination(BackStackView) @@ -214,14 +216,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 +241,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 +260,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 +290,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 +336,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 +350,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 +363,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 +397,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 +405,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 +417,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) @@ -459,6 +468,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 @@ -466,12 +478,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=self.button_data + ) - selected_menu_num = psbt_screens.PSBTFinalizeScreen( - button_data=["Approve PSBT"] - ).display() - - 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) @@ -500,7 +513,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/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 5af972c8..dc81b508 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -4,6 +4,7 @@ import re from embit.descriptor import Descriptor 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 @@ -12,19 +13,24 @@ 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) + + 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(scan_screens.ScanScreen, 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/seed_views.py b/src/seedsigner/views/seed_views.py index 3b47c2a1..c8f5cacb 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 @@ -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}) @@ -71,40 +74,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) - selected_menu_num = ButtonListScreen( + 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=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SEED_QR: + if 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 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 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 button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -119,22 +123,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) @@ -174,34 +172,38 @@ class SeedMnemonicEntryView(View): class SeedMnemonicInvalidView(View): + EDIT = "Review & Edit" + DISCARD = ("Discard", None, None, "red") + 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 = WarningScreen( + 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=button_data, - ).display() + ) - if button_data[selected_menu_num] == EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) - elif button_data[selected_menu_num] == DISCARD: + elif 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" + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -209,25 +211,21 @@ class SeedFinalizeView(View): def run(self): - FINALIZE = "Done" - PASSPHRASE = "BIP-39 Passphrase" - button_data = [] - - button_data.append(FINALIZE) - + button_data = [self.FINALIZE] if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - button_data.append(PASSPHRASE) + button_data.append(self.PASSPHRASE) - selected_menu_num = seed_screens.SeedFinalizeScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, button_data=button_data, - ).display() + ) - if button_data[selected_menu_num] == 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 button_data[selected_menu_num] == PASSPHRASE: + elif button_data[selected_menu_num] == self.PASSPHRASE: return Destination(SeedAddPassphraseView) @@ -239,7 +237,7 @@ class SeedAddPassphraseView(View): def run(self): - ret = seed_screens.SeedAddPassphraseScreen(passphrase=self.seed.passphrase).display() + ret = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -257,16 +255,15 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ + EDIT = "Edit passphrase" + DONE = "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 @@ -275,26 +272,32 @@ 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 + 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 = seed_screens.SeedReviewPassphraseScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedReviewPassphraseScreen, 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: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedAddPassphraseView) - elif button_data[selected_menu_num] == 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) 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 @@ -305,27 +308,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: @@ -338,6 +340,15 @@ 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 @@ -347,90 +358,87 @@ class SeedOptionsView(View): 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.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) + 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) - button_data.append(SCAN_PSBT) + 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 = [] + + 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(EXPORT_XPUB) + button_data.append(self.EXPORT_XPUB) - button_data.append(EXPLORER) - button_data.append(BACKUP) + 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(BIP85_CHILD_SEED) + button_data.append(self.BIP85_CHILD_SEED) - button_data.append(DISCARD) - - selected_menu_num = seed_screens.SeedOptionsScreen( + button_data.append(self.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, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if button_data[selected_menu_num] == 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 button_data[selected_menu_num] == VERIFY_ADDRESS: + elif 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 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 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 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 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 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" + def __init__(self, seed_num): super().__init__() self.seed_num = seed_num @@ -438,23 +446,22 @@ class SeedBackupView(View): def run(self): - VIEW_WORDS = "View Seed Words" - EXPORT_SEEDQR = "Export as SeedQR" - button_data = [VIEW_WORDS, EXPORT_SEEDQR] + button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR] - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Backup Seed", button_data=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 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 button_data[selected_menu_num] == self.EXPORT_SEEDQR: return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) @@ -463,6 +470,9 @@ class SeedBackupView(View): Export Xpub flow ****************************************************************************""" class SeedExportXpubSigTypeView(View): + SINGLE_SIG = "Single Sig" + MULTISIG = "Multisig" + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -473,22 +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) - SINGLE_SIG = "Single Sig" - MULTISIG = "Multisig" - button_data=[SINGLE_SIG, MULTISIG] + button_data=[self.SINGLE_SIG, self.MULTISIG] - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == 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 button_data[selected_menu_num] == MULTISIG: + elif button_data[selected_menu_num] == self.MULTISIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) @@ -512,21 +521,22 @@ 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: title = "Address Explorer" - selected_menu_num = ButtonListScreen( + 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=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). @@ -558,10 +568,12 @@ class SeedExportXpubCustomDerivationView(View): self.script_type = script_type self.custom_derivation_path = "m/" + 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) @@ -606,11 +618,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] @@ -648,10 +661,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" @@ -694,6 +708,7 @@ class SeedExportXpubDetailsView(View): else: # 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() @@ -716,12 +731,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( @@ -773,7 +789,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) @@ -803,9 +822,10 @@ class SeedWordsWarningView(View): # Forward straight to showing the words return destination - selected_menu_num = DireWarningScreen( - text="""Never input your seed phrase into a device that connects to the internet.""", - ).display() + selected_menu_num = self.run_screen( + DireWarningScreen, + text="""You must keep your seed words private & away from all online devices.""", + ) if selected_menu_num == 0: # User clicked "I Understand" diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 8a13b43d..ccb19150 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 @@ -9,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 @@ -19,9 +21,6 @@ class SettingsMenuView(View): def run(self): - IO_TEST = "I/O test" - DONATE = "Donate" - settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) @@ -39,34 +38,34 @@ class SettingsMenuView(View): # 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}) + next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - button_data.append(IO_TEST) - button_data.append(DONATE) + button_data.append(self.IO_TEST) + button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: 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 + # next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) + next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: title = "Dev Options" - next = None + next_destination = 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: @@ -77,12 +76,12 @@ class SettingsMenuView(View): return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) elif selected_menu_num == len(settings_entries): - return next + return next_destination - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST: + elif len(button_data) > selected_menu_num and 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(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) else: @@ -95,10 +94,10 @@ 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 @@ -111,7 +110,9 @@ class SettingsEntryUpdateSelectionView(View): 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) @@ -120,17 +121,18 @@ class SettingsEntryUpdateSelectionView(View): # selected option). self.selected_button = i - if not self.selected_button: + if self.selected_button is None: self.selected_button = 0 - - ret_value = settings_screens.SettingsEntryUpdateSelectionScreen( + + ret_value = self.run_screen( + settings_screens.SettingsEntryUpdateSelectionScreen, 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() + ) destination = None settings_menu_view_destination = Destination( @@ -178,7 +180,8 @@ 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() + + 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) @@ -187,7 +190,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 +198,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/tools_views.py b/src/seedsigner/views/tools_views.py index e7778bdf..6cd0c0bd 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) @@ -25,32 +23,34 @@ 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 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] - screen = ButtonListScreen( + 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=button_data ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == IMAGE: + elif button_data[selected_menu_num] == self.IMAGE: return Destination(ToolsImageEntropyLivePreviewView) - elif button_data[selected_menu_num] == DICE: + elif button_data[selected_menu_num] == self.DICE: return Destination(ToolsDiceEntropyMnemonicLengthView) - elif button_data[selected_menu_num] == KEYBOARD: + elif button_data[selected_menu_num] == self.KEYBOARD: return Destination(ToolsCalcFinalWordNumWordsView) - elif button_data[selected_menu_num] == EXPLORER: + elif button_data[selected_menu_num] == self.EXPLORER: return Destination(ToolsAddressExplorerSelectSourceView) @@ -232,28 +232,30 @@ class ToolsDiceEntropyEntryView(View): Calc final word Views ****************************************************************************""" class ToolsCalcFinalWordNumWordsView(View): + TWELVE = "12 words" + TWENTY_FOUR = "24 words" + def run(self): - TWELVE = "12 words" - TWENTY_FOUR = "24 words" - - button_data = [TWELVE, TWENTY_FOUR] - selected_menu_num = ButtonListScreen( + 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=button_data, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == 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 button_data[selected_menu_num] == 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)) @@ -423,29 +425,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) + + def run(self): seeds = self.controller.storage.seeds + button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - - button_data.append(SCAN_SEED) - button_data.append(SCAN_DESCRIPTOR) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) - - selected_menu_num = ButtonListScreen( + button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] + + 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) @@ -465,13 +465,13 @@ class ToolsAddressExplorerSelectSourceView(View): ) ) - elif button_data[selected_menu_num] in [SCAN_SEED, 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 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) @@ -480,6 +480,10 @@ class ToolsAddressExplorerSelectSourceView(View): class ToolsAddressExplorerAddressTypeView(View): + RECEIVE = "Receive Addresses" + CHANGE = "Change Addresses" + + 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,29 +532,28 @@ 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)", "") script_type = data["script_type"] if "script_type" in data else None - selected_menu_num = ToolsAddressExplorerAddressTypeScreen( + button_data = [self.RECEIVE, self.CHANGE] + + 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) - elif button_data[selected_menu_num] in [RECEIVE, CHANGE]: - return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == 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)) @@ -565,21 +568,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 of 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,35 +613,36 @@ class ToolsAddressExplorerAddressListView(View): else: raise Exception("Single sig descriptors not yet supported") - - for i, address in enumerate(addresses): - cur_index = i + self.start_index - 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)) - - 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: + finally: + # Everything is set. Stop the loading screen self.loading_screen.stop() - selected_menu_num = screen.display() + 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:]}") + + button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + + 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 +652,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 +670,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/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 02543c4d..de4bac71 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 Type -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 @@ -56,14 +56,20 @@ class View: self.canvas_width = self.renderer.canvas_width self.canvas_height = self.renderer.canvas_height - self.buttons = self.controller.buttons + self.screen = None + + + 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. + """ + self.screen = Screen_cls(**kwargs) + return self.screen.display() - def run(self, **kwargs): - if hasattr(self, "screen"): - self.screen.display() - else: - raise Exception("Must implement in the child class") + def run(self, **kwargs) -> 'Destination': + raise Exception("Must implement in the child class") @@ -73,10 +79,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): @@ -93,12 +99,22 @@ class Destination: return out - def run(self): + 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` 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 + self.view = self.View_cls(**self.view_args) + + + def _run_view(self): + return self.view.run() + + + def run(self): + self._instantiate_view() + return self._run_view() def __eq__(self, obj): @@ -121,53 +137,63 @@ 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 - from seedsigner.gui.screens import LargeButtonScreen - 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) - screen = LargeButtonScreen( + 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=[entry[0] for entry in menu_items], + button_data=button_data, show_back_button=False, show_power_button=True, ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) - return Destination(menu_items[selected_menu_num][1]) + if button_data[selected_menu_num] == self.SCAN: + from .scan_views import ScanView + return Destination(ScanView) + + elif button_data[selected_menu_num] == self.SEEDS: + from .seed_views import SeedsMenuView + return Destination(SeedsMenuView) + + elif button_data[selected_menu_num] == self.TOOLS: + from .tools_views import ToolsMenuView + return Destination(ToolsMenuView) + + elif button_data[selected_menu_num] == self.SETTINGS: + from .settings_views import SettingsMenuView + return Destination(SettingsMenuView) 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 = [self.RESET, self.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] == self.RESET: return Destination(RestartView) - elif button_data[selected_menu_num] == POWER_OFF: + elif button_data[selected_menu_num] == self.POWER_OFF: return Destination(PowerOffView) @@ -176,7 +202,7 @@ class RestartView(View): def run(self): thread = RestartView.DoResetThread() thread.start() - ResetScreen().display() + self.run_screen(ResetScreen) class DoResetThread(BaseThread): @@ -200,12 +226,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): @@ -223,30 +249,32 @@ 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) class UnhandledExceptionView(View): - def __init__(self, error: List[str]): + def __init__(self, error: list[str]): self.error = error 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/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 ``` diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 00000000..72bd55f3 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,208 @@ +import sys +from dataclasses import dataclass +from mock import MagicMock, patch +from typing import Callable + +# 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.views.screensaver'] = MagicMock() +sys.modules['seedsigner.hardware.buttons'] = MagicMock() +sys.modules['seedsigner.hardware.camera'] = MagicMock() +sys.modules['seedsigner.hardware.microsd'] = MagicMock() + +from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON +from seedsigner.models import Settings +from seedsigner.views.view import Destination, MainMenuView, 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. View classes must import locally! + patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() + + + @classmethod + def teardown_class(cls): + pass + + + @classmethod + def reset_settings(cls): + """ Wipe and re-initialize the Settings singleton """ + Settings._instance = None + BaseTest.remove_settings() + + + @classmethod + def remove_settings(cls): + """ If settings were written to disk, delete """ + import os + try: + if os.path.exists(Settings.SETTINGS_FILENAME): + os.remove(Settings.SETTINGS_FILENAME) + except: + print(f"{Settings.SETTINGS_FILENAME} could not be removed") + + + @classmethod + def reset_controller(cls): + """ Wipe and re-initialize the Controller singleton """ + Controller._instance = None + Controller.configure_instance() + + + def setup_method(self): + """ 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: verify that the current step in the sequence instantiates the right 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. + * is_redirect: expects the Destination to specify `skip_current_view=True`. + """ + expected_view: type[View] = None + before_run: Callable[[View], None] = None + screen_return_value: int | str = None + button_data_selection: 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("Can't specify both `screen_return_value` and `button_data_selection`") + + + +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. + """ + with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_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 = run_screen + + # 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) diff --git a/tests/test_controller.py b/tests/test_controller.py index 2fce0387..9ea467c7 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,117 +1,115 @@ -import configparser import pytest -from mock import MagicMock -from seedsigner.hardware.microsd import MicroSD + +# Must import this before the Controller +from base import BaseTest + 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""" +class TestController(BaseTest): - # setup - Controller.configure_instance(disable_hardware=True) + def test_reset_controller(self): + """ The reset_controller util should completely reset the Controller singleton """ + controller = Controller.get_instance() + controller.address_explorer_data = "foo" - # 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() """ - with pytest.raises(Exception): - c = Controller() + BaseTest.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_handle_exception(reset_controller): - """ Handle exceptions that get caught by the controller """ + def test_handle_exception(reset_controller): + """ Handle exceptions that get caught by the controller """ - def process_exception_asserting_valid_error(exception_type, exception_msg=None): - """ - Exceptions caught by the controller are forwarded to the - UnhandledExceptionView with view_args["error"] being a list - of three strings, ie: [exception_type, line_info, exception_msg] - """ - try: + def process_exception_asserting_valid_error(exception_type, exception_msg=None): + """ + Exceptions caught by the controller are forwarded to the + UnhandledExceptionView with view_args["error"] being a list + of three strings, ie: [exception_type, line_info, exception_msg] + """ + try: + if exception_msg: + raise exception_type(exception_msg) + else: + raise exception_type() + except Exception as e: + error = controller.handle_exception(e).view_args["error"] + + # assert that error structure is valid + assert len(error) == 3 + assert error[0] in str(exception_type) + assert type(error[1]) == str if exception_msg: - raise exception_type(exception_msg) + assert exception_msg in error[2] else: - raise exception_type() - except Exception as e: - error = controller.handle_exception(e).view_args["error"] + assert error[2] == "" - # assert that error structure is valid - assert len(error) == 3 - assert error[0] in str(exception_type) - assert type(error[1]) == str - if exception_msg: - assert exception_msg in error[2] - else: - assert error[2] == "" + # Initialize the controller + controller = Controller.get_instance() - # Initialize the controller - controller = Controller.get_instance() - - exception_tests = [ - # exceptions with an exception_msg - (Exception, "foo"), - (KeyError, "key not found"), - # exceptions without an exception_msg - (Exception, ""), - (Exception, None), - ] - - for exception_type, exception_msg in exception_tests: - process_exception_asserting_valid_error(exception_type, exception_msg) + exception_tests = [ + # exceptions with an exception_msg + (Exception, "foo"), + (KeyError, "key not found"), + # exceptions without an exception_msg + (Exception, ""), + (Exception, None), + ] + + for exception_type, exception_msg in exception_tests: + process_exception_asserting_valid_error(exception_type, exception_msg) -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 new file mode 100644 index 00000000..3b99eb9d --- /dev/null +++ b/tests/test_flows.py @@ -0,0 +1,131 @@ +import pytest + +# Must import test base before the Controller +from base import FlowTest, FlowStep, FlowTestUnexpectedViewException, FlowTestInvalidButtonDataSelectionException, FlowTestRunScreenNotExecutedException + +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.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 + + + +class TestFlowTest(FlowTest): + + def test_simple_flow(self): + """ + 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), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE), + FlowStep(SeedMnemonicEntryView), + ]) + + + def test_FlowTestUnexpectedViewException(self): + """ + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the next + View in the sequence is not the expected View. + """ + 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): + """ + Ensure that the FlowTest can execute a function before running a View. + """ + # TODO + pass + + + 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), + ]) + + + 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), + ]) + + # 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 + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + self.controller.storage.set_pending_seed(seed) + self.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), + ] + ) + + + 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_psbt.py b/tests/test_flows_psbt.py new file mode 100644 index 00000000..f50491a1 --- /dev/null +++ b/tests/test_flows_psbt.py @@ -0,0 +1,56 @@ +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller +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, 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, 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), + 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, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), + FlowStep(psbt_views.PSBTSignedQRDisplayView), + FlowStep(MainMenuView) + ]) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py new file mode 100644 index 00000000..973518d3 --- /dev/null +++ b/tests/test_flows_seed.py @@ -0,0 +1,290 @@ +# Must import test base before the Controller +from base import BaseTest, FlowTest, FlowStep +from base import FlowTestUnexpectedViewException, FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException + +import pytest +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 + + + +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") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + 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), + ]) + + + 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(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(seed_views.SeedMnemonicEntryView, screen_return_value=word) + ) + + # 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), + ] + + 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()) + + 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()) + + + 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(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(seed_views.SeedMnemonicEntryView, screen_return_value=word)) + + sequence += [ + 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(seed_views.SeedMnemonicEntryView, screen_return_value=word)) + + 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), + ] + + self.run_sequence(sequence) + + + def test_export_xpub_standard_flow(self): + """ + Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView + """ + + def flowtest_standard_xpub(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 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]) + 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] == SettingsConstants.CUSTOM_DERIVATION: + continue + # skip multisig 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)) + flowtest_standard_xpub(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 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 + 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 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), + ] + ) + + # test that taproot is not an option via exception raised when choice is taproot + 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), + ] + ) + + # test that nunchuk is not an option via exception raised when choice is nunchuk + 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, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord), + ] + ) + + + 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(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(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), + 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 + """ + # 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({ + 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( + 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, 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), + ] + ) + + + 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), + ] + ) diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py new file mode 100644 index 00000000..8c603170 --- /dev/null +++ b/tests/test_flows_settings.py @@ -0,0 +1,70 @@ +import os + +# Must import test base before the Controller +from base import FlowTest, FlowStep + +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 MainMenuView +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_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + + # 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), + ]) + + # Settings file should now exist + assert os.path.exists(Settings.SETTINGS_FILENAME) == True + + + 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) + + 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), + ]) + + + def test_io_test(self): + """ Basic flow from MainMenuView to I/O Test View """ + 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), + FlowStep(settings_views.SettingsMenuView), + ]) + + + def test_donate(self): + """ Basic flow from MainMenuView to Donate View """ + 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), + FlowStep(settings_views.SettingsMenuView), + ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py new file mode 100644 index 00000000..ba5abe8c --- /dev/null +++ b/tests/test_flows_tools.py @@ -0,0 +1,72 @@ +# Must import test base before the Controller +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 MainMenuView +from seedsigner.views import scan_views, seed_views, tools_views + + + +class TestToolsFlows(FlowTest): + + 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() + + 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), # runs until dismissed; no ret value + FlowStep(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. + """ + controller = Controller.get_instance() + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + # Finalize the new seed w/out passphrase + 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, 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), + ]) + + assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) + + # Finalize the new seed w/passphrase + self.run_sequence( + sequence=[ + 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), + ] + ) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py new file mode 100644 index 00000000..3f4d537a --- /dev/null +++ b/tests/test_flows_view.py @@ -0,0 +1,74 @@ +from mock import patch + +# Must import test base before the Controller +from base import FlowTest, FlowStep + +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), + ]) + + + 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), + ]) + + # 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), + ]) + + + def test_not_yet_implemented_flow(self): + """ + Run an incomplete View that returns None and ensure that we get the NotYetImplementedView + """ + class IncompleteView(View): + def run(self): + self.run_screen(None) + return None + + self.run_sequence([ + FlowStep(IncompleteView), + FlowStep(NotYetImplementedView), + FlowStep(MainMenuView), + ]) + + + 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), + ]) 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