From af7b095aa4ec4176fa785cfbae081e07b809b4e8 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Fri, 27 Jan 2023 17:28:14 -0400 Subject: [PATCH 01/67] seed backup warnings: text changes --- src/seedsigner/views/seed_views.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 8c24d851..2158650b 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -791,7 +791,7 @@ class SeedWordsWarningView(View): return destination selected_menu_num = DireWarningScreen( - text="""You must keep your seed words private & away from all online devices.""", + text="""Never input your seed phrase into a device that connects to the internet.""", ).display() if selected_menu_num == 0: @@ -1193,7 +1193,7 @@ class SeedTranscribeSeedQRWarningView(View): selected_menu_num = DireWarningScreen( status_headline="SeedQR is your private key!", - text="""Never photograph it or scan it into an online device.""", + text="""Never photograph or scan it into a device that connects to the internet.""", ).display() if selected_menu_num == RET_CODE__BACK_BUTTON: From 563bbfff385c18d300694938bc99b4d4298ee155 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 14:06:20 -0600 Subject: [PATCH 02/67] initial testable flows --- src/seedsigner/views/seed_views.py | 39 +++++++++---- src/seedsigner/views/view.py | 32 +++++++--- tests/test_controller.py | 20 ++----- tests/test_flows.py | 93 ++++++++++++++++++++++++++++++ tests/utils.py | 15 +++++ 5 files changed, 163 insertions(+), 36 deletions(-) create mode 100644 tests/test_flows.py create mode 100644 tests/utils.py diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index aca803ba..8154bcac 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -6,14 +6,14 @@ from binascii import hexlify from embit import bip39 from embit.descriptor import Descriptor from embit.networks import NETWORKS -from typing import List +from typing import List, Type from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import BaseScreen, LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -71,6 +71,9 @@ class SeedsMenuView(View): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): + Screen_cls: Type[BaseScreen] = ButtonListScreen + + def run(self): SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) @@ -83,11 +86,11 @@ class LoadSeedView(View): CREATE, ] - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( title="Load A Seed", is_button_text_centered=False, button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -202,6 +205,9 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): + Screen_cls: Type[BaseScreen] = seed_screens.SeedFinalizeScreen + + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -218,10 +224,12 @@ class SeedFinalizeView(View): if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: button_data.append(PASSPHRASE) - selected_menu_num = seed_screens.SeedFinalizeScreen( + selected_menu_num = self.run_screen( fingerprint=self.fingerprint, button_data=button_data, - ).display() + ) + + print(f"selected_menu_num: {selected_menu_num}") if button_data[selected_menu_num] == FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() @@ -233,13 +241,16 @@ class SeedFinalizeView(View): class SeedAddPassphraseView(View): + Screen_cls: Type[BaseScreen] = seed_screens.SeedAddPassphraseScreen + + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - ret = seed_screens.SeedAddPassphraseScreen(passphrase=self.seed.passphrase).display() + ret = self.run_screen(passphrase=self.seed.passphrase) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -257,6 +268,9 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ + Screen_cls: Type[BaseScreen] = seed_screens.SeedReviewPassphraseScreen + + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -277,13 +291,13 @@ class SeedReviewPassphraseView(View): # Because we have ane explicit "Edit" button, we disable "BACK" to keep the # routing options sane. - selected_menu_num = seed_screens.SeedReviewPassphraseScreen( + selected_menu_num = self.run_screen( fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, button_data=button_data, show_back_button=False, - ).display() + ) if button_data[selected_menu_num] == EDIT: return Destination(SeedAddPassphraseView) @@ -338,6 +352,9 @@ class SeedDiscardView(View): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): + Screen_cls: Type[BaseScreen] = seed_screens.SeedOptionsScreen + + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -394,11 +411,11 @@ class SeedOptionsView(View): button_data.append(DISCARD) - selected_menu_num = seed_screens.SeedOptionsScreen( + selected_menu_num = self.run_screen( button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: # Force BACK to always return to the Main Menu diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 02543c4d..b49fa140 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,9 +1,9 @@ from dataclasses import dataclass -from typing import List +from typing import List, Type, Union -from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants -from seedsigner.gui.screens import RET_CODE__POWER_BUTTON -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen +from seedsigner.gui.components import FontAwesomeIconConstants +from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import BaseScreen, DireWarningScreen, LargeButtonScreen, PowerOffScreen, PowerOffNotRequiredScreen, ResetScreen, WarningScreen from seedsigner.models.threads import BaseThread from seedsigner.models import Settings @@ -42,6 +42,10 @@ class BackStackView: "Cancel" - End task and return to entry point (destructive) """ class View: + # Explicitly specifying the Screen class allows us to mock out its behavior in the test suite + Screen_cls: Type[BaseScreen] = None + + def __init__(self) -> None: # Import here to avoid circular imports from seedsigner.controller import Controller @@ -57,11 +61,21 @@ class View: self.canvas_height = self.renderer.canvas_height self.buttons = self.controller.buttons + + + def run_screen(self, **kwargs) -> Union[int,str]: + """ + Instantiates the View class' Screen_cls and runs its interactive display. + Returns the user's input upon completion. + """ + if not self.Screen_cls: + raise Exception("Screen_cls not defined") + return self.Screen_cls(**kwargs).display() def run(self, **kwargs): if hasattr(self, "screen"): - self.screen.display() + self.run_screen() else: raise Exception("Must implement in the child class") @@ -73,10 +87,10 @@ class Destination: Basic struct to pass back to the Controller to tell it which View the user should be presented with next. """ - View_cls: View # The target View to route to - view_args: dict = None # The input args required to instantiate the target View - skip_current_view: bool = False # The current View is just forwarding; omit current View from history - clear_history: bool = False # Optionally clears the back_stack to prevent "back" + View_cls: Type[View] # The target View to route to + view_args: dict = None # The input args required to instantiate the target View + skip_current_view: bool = False # The current View is just forwarding; omit current View from history + clear_history: bool = False # Optionally clears the back_stack to prevent "back" def __repr__(self): diff --git a/tests/test_controller.py b/tests/test_controller.py index bd8b2c18..b51b822b 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,26 +1,14 @@ -import configparser import pytest -from mock import MagicMock + +# Must import this before the Controller +from utils import reset_controller + from seedsigner.hardware.microsd import MicroSD from seedsigner.controller import Controller from seedsigner.models.settings_definition import SettingsConstants -@pytest.fixture() -def reset_controller(): - """fixture to setup, then yield to run test, then tear down""" - - # setup - Controller.configure_instance(disable_hardware=True) - - # yield to run a single test - yield - - # tear down - MicroSD._instance = None - Controller._instance = None - def test_singleton_init_fails(reset_controller): """ The Controller should not allow any code to instantiate it via Controller() """ diff --git a/tests/test_flows.py b/tests/test_flows.py new file mode 100644 index 00000000..39750f44 --- /dev/null +++ b/tests/test_flows.py @@ -0,0 +1,93 @@ +import sys +from typing import Any, List, Tuple, Type, Union +from mock import patch, MagicMock + +# sys.modules['seedsigner.gui.screens.seed_screens'] = MagicMock() + +# Must import this before the Controller +from utils import reset_controller + +from seedsigner.controller import Controller +from seedsigner.models.seed import Seed +from seedsigner.views.view import Destination + + + +def flow_sequence(initial_destination: Destination, return_values: List[Union[int, str]]) -> Destination: + """ + Runs the given sequence from the initial_destination, mocking out the associated + Screen and the scenario's return_value, receiving the resulting new Destination, + and repeating until the sequence is complete. + + Returns the final Destination. + """ + next_destination = initial_destination + for return_value in return_values: + if next_destination.View_cls.Screen_cls is None: + raise Exception(f"Screen_cls not specified in {next_destination.View_cls}") + qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) + with patch(qualname + ".run_screen") as mock_run_screen: + mock_run_screen.return_value = return_value + next_destination = next_destination.run() + + return next_destination + + + +class TestFlows: + + + def setup_method(self): + reset_controller() + + + def test_addressexplorer_seedfinalizescreen_flow(self): + """ + Finalizing a seed during the Address Explorer flow should return to the next + Address Explorer step upon completion. + """ + from seedsigner.views import seed_views + + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + + # Default behavior should land at the SeedOptionsView + sequence = [ + 0, # SeedFinalizeView: ret DONE + ] + destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + assert destination.View_cls == seed_views.SeedOptionsView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Now set the flow and try again + controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + + # Finalize the new seed w/out passphrase + sequence = [ + 0, # SeedFinalizeView: ret DONE + None, # SeedOptionsView should auto-route away + ] + destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Finalize the new seed w/passphrase + sequence = [ + 1, # SeedFinalizeView: ret PASSPHRASE + "mypassphrase", # SeedPassphraseView: ret passphrase str + 1, # SeedReviewPassphraseView: ret DONE + None, # SeedOptionsView should auto-route away + ] + destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/utils.py b/tests/utils.py new file mode 100644 index 00000000..6a428429 --- /dev/null +++ b/tests/utils.py @@ -0,0 +1,15 @@ +import sys +from mock import MagicMock + +# Prevent importing modules w/Raspi hardware dependencies +sys.modules['seedsigner.gui.renderer'] = MagicMock() +sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.hardware.buttons'] = MagicMock() +sys.modules['seedsigner.hardware.microsd'] = MagicMock() + +from seedsigner.controller import Controller + + +def reset_controller(): + Controller._instance = None + Controller.configure_instance() From 3824fd3e61dfa6fe1591405eb600170047d05054 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 19:46:15 -0600 Subject: [PATCH 03/67] run_before View injection; full basic MainMenuView test flow --- src/seedsigner/helpers/qr.py | 2 +- src/seedsigner/views/scan_views.py | 22 ++++--- src/seedsigner/views/view.py | 22 +++++-- tests/test_controller.py | 100 ++++++++++++++++------------- tests/test_flows.py | 57 ++++++++++++---- tests/utils.py | 2 + 6 files changed, 132 insertions(+), 73 deletions(-) diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index b986c6d7..333910b0 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -48,6 +48,6 @@ class QR: # if qrencode fails, fall back to only encoder if rv != 0: return self.qrimage(data,width,height,border) - img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") + img = Image.open("/tmp/qrcode.png").resize((width,height), Image.Resampling.NEAREST).convert("RGBA") return img diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 5af972c8..aba74a9b 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -1,9 +1,11 @@ import json import re +from typing import Type from embit.descriptor import Descriptor -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen +from seedsigner.gui.screens import scan_screens from seedsigner.models import DecodeQR, Seed from seedsigner.models.settings import SettingsConstants @@ -12,19 +14,23 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest class ScanView(View): + Screen_cls: Type[BaseScreen] = scan_screens.ScanScreen + + def __init__(self): + super().__init__() + self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) + self.decoder = DecodeQR(wordlist_language_code=self.wordlist_language_code) + + def run(self): - from seedsigner.gui.screens.scan_screens import ScanScreen - - wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) - self.decoder = DecodeQR(wordlist_language_code=wordlist_language_code) - # Start the live preview and background QR reading - ScanScreen(decoder=self.decoder).display() + self.run_screen(decoder=self.decoder) # Handle the results if self.decoder.is_complete: if self.decoder.is_seed: seed_mnemonic = self.decoder.get_seed_phrase() + if not seed_mnemonic: # seed is not valid, Exit if not valid with message raise Exception("Not yet implemented!") @@ -33,7 +39,7 @@ class ScanView(View): # pending (might set a passphrase, SeedXOR, etc) until finalized. from .seed_views import SeedFinalizeView self.controller.storage.set_pending_seed( - Seed(mnemonic=seed_mnemonic, wordlist_language_code=wordlist_language_code) + Seed(mnemonic=seed_mnemonic, wordlist_language_code=self.wordlist_language_code) ) if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__REQUIRED: from seedsigner.views.seed_views import SeedAddPassphraseView diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index b49fa140..e16ea373 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import List, Type, Union +from typing import Callable, List, Type, Union from seedsigner.gui.components import FontAwesomeIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON @@ -107,12 +107,20 @@ class Destination: return out - def run(self): + def run(self, run_before: Callable = None): if not self.view_args: # Can't unpack (**) None so we replace with an empty dict self.view_args = {} - # Instantiate the `View_cls` and run() it with the `view_args` dict - return self.View_cls(**self.view_args).run() + + # Instantiate the `View_cls` with the `view_args` dict + view = self.View_cls(**self.view_args) + + # Enable test suite modifications to the View before running it + if run_before: + run_before(view) + + # and run() it + return view.run() def __eq__(self, obj): @@ -135,12 +143,13 @@ class Destination: # ######################################################################################### class MainMenuView(View): + Screen_cls: Type[BaseScreen] = LargeButtonScreen + def run(self): from .seed_views import SeedsMenuView from .settings_views import SettingsMenuView from .scan_views import ScanView from .tools_views import ToolsMenuView - from seedsigner.gui.screens import LargeButtonScreen menu_items = [ (("Scan", FontAwesomeIconConstants.QRCODE), ScanView), (("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView), @@ -148,14 +157,13 @@ class MainMenuView(View): (("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView), ] - screen = LargeButtonScreen( + selected_menu_num = self.run_screen( title="Home", title_font_size=26, button_data=[entry[0] for entry in menu_items], show_back_button=False, show_power_button=True, ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) diff --git a/tests/test_controller.py b/tests/test_controller.py index b51b822b..51511807 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -3,61 +3,75 @@ import pytest # Must import this before the Controller from utils import reset_controller -from seedsigner.hardware.microsd import MicroSD from seedsigner.controller import Controller from seedsigner.models.settings_definition import SettingsConstants +class TestController: + + def setup_method(self): + reset_controller() -def test_singleton_init_fails(reset_controller): - """ The Controller should not allow any code to instantiate it via Controller() """ - with pytest.raises(Exception): - c = Controller() + + def test_reset_controller(self): + """ The reset_controller util should completely reset the Controller singleton """ + controller = Controller.get_instance() + controller.address_explorer_data = "foo" + + reset_controller() + controller = Controller.get_instance() + assert controller.address_explorer_data is None -def test_singleton_get_instance_preserves_state(reset_controller): - """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - - # Initialize the instance and verify that it read the config settings - controller = Controller.get_instance() - assert controller.unverified_address is None - - # Change a value in the instance... - controller.unverified_address = "123abc" - - # ...get a new copy of the instance and confirm change - controller = Controller.get_instance() - assert controller.unverified_address == "123abc" + def test_singleton_init_fails(self): + """ The Controller should not allow any code to instantiate it via Controller() """ + with pytest.raises(Exception): + c = Controller() -def test_missing_settings_get_defaults(reset_controller): - """ Should gracefully handle all missing fields from `settings.json` """ + def test_singleton_get_instance_preserves_state(self): + """ Changes to the Controller singleton should be preserved across calls to get_instance() """ - controller = Controller.get_instance() + # Initialize the instance and verify that it read the config settings + controller = Controller.get_instance() + assert controller.unverified_address is None - # Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH - assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH - assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED - assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"] - assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD + # Change a value in the instance... + controller.unverified_address = "123abc" - # Advanced Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET - assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM - assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] - assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT] - assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__0 - assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED - assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED - assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + # ...get a new copy of the instance and confirm change + controller = Controller.get_instance() + assert controller.unverified_address == "123abc" - # Hidden Settings defaults - assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189 + + def test_missing_settings_get_defaults(self): + """ Should gracefully handle all missing fields from `settings.json` """ + + controller = Controller.get_instance() + + # Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__LANGUAGE) == SettingsConstants.LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) == SettingsConstants.WORDLIST_LANGUAGE__ENGLISH + assert controller.settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__COORDINATORS) == [i for i,j in SettingsConstants.ALL_COORDINATORS if i!="kpr"] + assert controller.settings.get_value(SettingsConstants.SETTING__BTC_DENOMINATION) == SettingsConstants.BTC_DENOMINATION__THRESHOLD + + # Advanced Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__NETWORK) == SettingsConstants.MAINNET + assert controller.settings.get_value(SettingsConstants.SETTING__QR_DENSITY) == SettingsConstants.DENSITY__MEDIUM + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__SIG_TYPES) == [i for i,j in SettingsConstants.ALL_SIG_TYPES] + assert controller.settings.get_value(SettingsConstants.SETTING__SCRIPT_TYPES) == [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.NESTED_SEGWIT] + assert controller.settings.get_value(SettingsConstants.SETTING__XPUB_DETAILS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__CAMERA_ROTATION) == SettingsConstants.CAMERA_ROTATION__0 + assert controller.settings.get_value(SettingsConstants.SETTING__COMPACT_SEEDQR) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__DISABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PRIVACY_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__DIRE_WARNINGS) == SettingsConstants.OPTION__ENABLED + assert controller.settings.get_value(SettingsConstants.SETTING__PARTNER_LOGOS) == SettingsConstants.OPTION__ENABLED + + # Hidden Settings defaults + assert controller.settings.get_value(SettingsConstants.SETTING__QR_BRIGHTNESS) == 189 diff --git a/tests/test_flows.py b/tests/test_flows.py index 39750f44..c5325816 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,19 +1,22 @@ -import sys -from typing import Any, List, Tuple, Type, Union -from mock import patch, MagicMock - -# sys.modules['seedsigner.gui.screens.seed_screens'] = MagicMock() +from typing import List, Type, Union +from mock import patch # Must import this before the Controller from utils import reset_controller from seedsigner.controller import Controller from seedsigner.models.seed import Seed -from seedsigner.views.view import Destination +from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views import seed_views, scan_views -def flow_sequence(initial_destination: Destination, return_values: List[Union[int, str]]) -> Destination: +RUN_BEFORE = "run_before" +RETURN_VALUE = "return_value" + + + +def run_flow_sequence(initial_destination: Destination, return_values: List[Union[int, str, dict]]) -> Destination: """ Runs the given sequence from the initial_destination, mocking out the associated Screen and the scenario's return_value, receiving the resulting new Destination, @@ -25,10 +28,20 @@ def flow_sequence(initial_destination: Destination, return_values: List[Union[in for return_value in return_values: if next_destination.View_cls.Screen_cls is None: raise Exception(f"Screen_cls not specified in {next_destination.View_cls}") + + # Optionally modify the View_cls instance before run_screen is called + run_before = None + if type(return_value) == dict: + if RUN_BEFORE in return_value: + run_before = return_value[RUN_BEFORE] + + # Patch the run_screen so we don't actually instantiated and execute the Screen_cls qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: mock_run_screen.return_value = return_value - next_destination = next_destination.run() + + # Now we can run the View and grab its resulting next Destination + next_destination = next_destination.run(run_before=run_before) return next_destination @@ -36,18 +49,34 @@ def flow_sequence(initial_destination: Destination, return_values: List[Union[in class TestFlows: - def setup_method(self): reset_controller() + def test_home_scan_seedoptions_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the + Finalize Seed flow and end at the SeedOptionsView. + """ + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + sequence = [ + 0, # MainMenuView: ret SCAN + {RUN_BEFORE: load_seed_into_decoder, RETURN_VALUE: None}, # ScanView: read SeedQR + 0, # SeedFinalizeView: ret DONE + ] + destination = run_flow_sequence(Destination(MainMenuView), return_values=sequence) + + # Should land on SeedOptionsView + assert destination.View_cls == seed_views.SeedOptionsView + + def test_addressexplorer_seedfinalizescreen_flow(self): """ Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ - from seedsigner.views import seed_views - controller = Controller.get_instance() seed = Seed(mnemonic=["abandon "* 11 + "about"]) controller.storage.set_pending_seed(seed) @@ -56,7 +85,7 @@ class TestFlows: sequence = [ 0, # SeedFinalizeView: ret DONE ] - destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) assert destination.View_cls == seed_views.SeedOptionsView # Reset @@ -71,7 +100,7 @@ class TestFlows: 0, # SeedFinalizeView: ret DONE None, # SeedOptionsView should auto-route away ] - destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) # Flow should resume at Script Type selection assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView @@ -87,7 +116,7 @@ class TestFlows: 1, # SeedReviewPassphraseView: ret DONE None, # SeedOptionsView should auto-route away ] - destination = flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) + destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) # Flow should resume at Script Type selection assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/utils.py b/tests/utils.py index 6a428429..f0813665 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -5,11 +5,13 @@ from mock import MagicMock sys.modules['seedsigner.gui.renderer'] = MagicMock() sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() sys.modules['seedsigner.hardware.buttons'] = MagicMock() +sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() from seedsigner.controller import Controller def reset_controller(): + """ Wipe and re-initialize the Controller singleton for each test run """ Controller._instance = None Controller.configure_instance() From 5a1c4b73de1d419e8942eff4a45bcd126da8b998 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 20:15:40 -0600 Subject: [PATCH 04/67] Simplification - require Screen class as first arg in View.run_screen() --- src/seedsigner/views/scan_views.py | 4 +--- src/seedsigner/views/seed_views.py | 19 ++++--------------- src/seedsigner/views/view.py | 13 +++---------- tests/test_flows.py | 5 +---- 4 files changed, 9 insertions(+), 32 deletions(-) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index aba74a9b..95903040 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -14,8 +14,6 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest class ScanView(View): - Screen_cls: Type[BaseScreen] = scan_screens.ScanScreen - def __init__(self): super().__init__() self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) @@ -24,7 +22,7 @@ class ScanView(View): def run(self): # Start the live preview and background QR reading - self.run_screen(decoder=self.decoder) + self.run_screen(scan_screens.ScanScreen, decoder=self.decoder) # Handle the results if self.decoder.is_complete: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 8154bcac..dd3a72b0 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -205,9 +205,6 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): - Screen_cls: Type[BaseScreen] = seed_screens.SeedFinalizeScreen - - def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -225,12 +222,11 @@ class SeedFinalizeView(View): button_data.append(PASSPHRASE) selected_menu_num = self.run_screen( + seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, button_data=button_data, ) - print(f"selected_menu_num: {selected_menu_num}") - if button_data[selected_menu_num] == FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) @@ -241,16 +237,13 @@ class SeedFinalizeView(View): class SeedAddPassphraseView(View): - Screen_cls: Type[BaseScreen] = seed_screens.SeedAddPassphraseScreen - - def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - ret = self.run_screen(passphrase=self.seed.passphrase) + ret = self.run_screen(seed_screens.SeedAddPassphraseScreen, passphrase=self.seed.passphrase) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -268,9 +261,6 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ - Screen_cls: Type[BaseScreen] = seed_screens.SeedReviewPassphraseScreen - - def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() @@ -292,6 +282,7 @@ class SeedReviewPassphraseView(View): # Because we have ane explicit "Edit" button, we disable "BACK" to keep the # routing options sane. selected_menu_num = self.run_screen( + seed_screens.SeedReviewPassphraseScreen, fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, @@ -352,9 +343,6 @@ class SeedDiscardView(View): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): - Screen_cls: Type[BaseScreen] = seed_screens.SeedOptionsScreen - - def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -412,6 +400,7 @@ class SeedOptionsView(View): button_data.append(DISCARD) selected_menu_num = self.run_screen( + seed_screens.SeedOptionsScreen, button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index e16ea373..7245aa9b 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -42,10 +42,6 @@ class BackStackView: "Cancel" - End task and return to entry point (destructive) """ class View: - # Explicitly specifying the Screen class allows us to mock out its behavior in the test suite - Screen_cls: Type[BaseScreen] = None - - def __init__(self) -> None: # Import here to avoid circular imports from seedsigner.controller import Controller @@ -63,14 +59,12 @@ class View: self.buttons = self.controller.buttons - def run_screen(self, **kwargs) -> Union[int,str]: + def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> Union[int,str]: """ Instantiates the View class' Screen_cls and runs its interactive display. Returns the user's input upon completion. """ - if not self.Screen_cls: - raise Exception("Screen_cls not defined") - return self.Screen_cls(**kwargs).display() + return Screen_cls(**kwargs).display() def run(self, **kwargs): @@ -143,8 +137,6 @@ class Destination: # ######################################################################################### class MainMenuView(View): - Screen_cls: Type[BaseScreen] = LargeButtonScreen - def run(self): from .seed_views import SeedsMenuView from .settings_views import SettingsMenuView @@ -158,6 +150,7 @@ class MainMenuView(View): ] selected_menu_num = self.run_screen( + LargeButtonScreen, title="Home", title_font_size=26, button_data=[entry[0] for entry in menu_items], diff --git a/tests/test_flows.py b/tests/test_flows.py index c5325816..80dc2fdc 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -26,16 +26,13 @@ def run_flow_sequence(initial_destination: Destination, return_values: List[Unio """ next_destination = initial_destination for return_value in return_values: - if next_destination.View_cls.Screen_cls is None: - raise Exception(f"Screen_cls not specified in {next_destination.View_cls}") - # Optionally modify the View_cls instance before run_screen is called run_before = None if type(return_value) == dict: if RUN_BEFORE in return_value: run_before = return_value[RUN_BEFORE] - # Patch the run_screen so we don't actually instantiated and execute the Screen_cls + # Patch the run_screen so we don't actually instantiate and execute the Screen_cls qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: mock_run_screen.return_value = return_value From 1aeef3f7eeaf170ef42c5fba424757fa46ddca0c Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 21:08:38 -0600 Subject: [PATCH 05/67] Updating to latest approach --- src/seedsigner/views/seed_views.py | 4 +--- tests/test_flows.py | 2 +- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index dd3a72b0..36dba57f 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -71,9 +71,6 @@ class SeedsMenuView(View): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): - Screen_cls: Type[BaseScreen] = ButtonListScreen - - def run(self): SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) @@ -87,6 +84,7 @@ class LoadSeedView(View): ] selected_menu_num = self.run_screen( + ButtonListScreen, title="Load A Seed", is_button_text_centered=False, button_data=button_data diff --git a/tests/test_flows.py b/tests/test_flows.py index 80dc2fdc..d5685722 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -32,7 +32,7 @@ def run_flow_sequence(initial_destination: Destination, return_values: List[Unio if RUN_BEFORE in return_value: run_before = return_value[RUN_BEFORE] - # Patch the run_screen so we don't actually instantiate and execute the Screen_cls + # Patch the run_screen so we don't actually instantiate and execute the Screen qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: mock_run_screen.return_value = return_value From cd0f98b4d47cbf5903efc2d3746b4bdfdb121cbc Mon Sep 17 00:00:00 2001 From: kdmukai Date: Mon, 27 Feb 2023 21:16:39 -0600 Subject: [PATCH 06/67] Update view.py --- src/seedsigner/views/view.py | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 7245aa9b..bc21aa95 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -68,10 +68,7 @@ class View: def run(self, **kwargs): - if hasattr(self, "screen"): - self.run_screen() - else: - raise Exception("Must implement in the child class") + raise Exception("Must implement in the child class") From c61de8522fac04f5886fb016f25bb9e3288f1585 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 4 Mar 2023 14:21:15 -0600 Subject: [PATCH 07/67] New BaseTest structure; new FlowStep sequence testing --- src/seedsigner/models/settings_definition.py | 4 +- src/seedsigner/views/seed_views.py | 21 +-- src/seedsigner/views/settings_views.py | 36 +++-- src/seedsigner/views/view.py | 8 +- tests/base.py | 87 ++++++++++ tests/test_controller.py | 10 +- tests/test_flows.py | 119 -------------- tests/test_flows_seed.py | 143 +++++++++++++++++ tests/test_flows_settings.py | 157 +++++++++++++++++++ tests/test_flows_tools.py | 83 ++++++++++ tests/utils.py | 17 -- 11 files changed, 512 insertions(+), 173 deletions(-) create mode 100644 tests/base.py delete mode 100644 tests/test_flows.py create mode 100644 tests/test_flows_seed.py create mode 100644 tests/test_flows_settings.py create mode 100644 tests/test_flows_tools.py delete mode 100644 tests/utils.py diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 6c2af745..df02858b 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, List +from typing import Any, List, Tuple, Union @@ -217,7 +217,7 @@ class SettingsEntry: visibility: str = SettingsConstants.VISIBILITY__GENERAL type: str = SettingsConstants.TYPE__ENABLED_DISABLED help_text: str = None - selection_options: List[str] = None + selection_options: List[Tuple[Union[str,int],str]] = None default_value: Any = None def __post_init__(self): diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 36dba57f..0112267b 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -6,14 +6,14 @@ from binascii import hexlify from embit import bip39 from embit.descriptor import Descriptor from embit.networks import NETWORKS -from typing import List, Type +from typing import List from seedsigner.controller import Controller from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import BaseScreen, LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -120,22 +120,16 @@ class SeedMnemonicEntryView(View): def run(self): - ret = seed_screens.SeedMnemonicEntryScreen( + ret = self.run_screen( + seed_screens.SeedMnemonicEntryScreen, title=f"Seed Word #{self.cur_word_index + 1}", # Human-readable 1-indexing! initial_letters=list(self.cur_word) if self.cur_word else ["a"], wordlist=Seed.get_wordlist(wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE)), - ).display() + ) if ret == RET_CODE__BACK_BUTTON: if self.cur_word_index > 0: return Destination(BackStackView) - # return Destination( - # SeedMnemonicEntryView, - # view_args={ - # "cur_word_index": self.cur_word_index - 1, - # "is_calc_final_word": self.is_calc_final_word - # } - # ) else: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) @@ -185,13 +179,14 @@ class SeedMnemonicInvalidView(View): DISCARD = ("Discard", None, None, "red") button_data = [EDIT, DISCARD] - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, button_data=button_data, - ).display() + ) if button_data[selected_menu_num] == EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 8a13b43d..b458ea0c 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -1,7 +1,6 @@ -from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustomIconConstants -from seedsigner.models.decode_qr import DecodeQR +from seedsigner.gui.components import SeedSignerCustomIconConstants -from .view import View, Destination, BackStackView, MainMenuView +from .view import View, Destination, MainMenuView from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, settings_screens) from seedsigner.models.settings import SettingsConstants, SettingsDefinition @@ -56,17 +55,17 @@ class SettingsMenuView(View): title = "Dev Options" next = None - screen = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title=title, is_button_text_centered=False, button_data=button_data, selected_button=selected_button, scroll_y_initial_offset=self.initial_scroll, ) - selected_menu_num = screen.display() # Preserve our scroll position in this Screen so we can return - initial_scroll = screen.buttons[0].scroll_y + initial_scroll = self.screen.buttons[0].scroll_y if selected_menu_num == RET_CODE__BACK_BUTTON: if self.visibility == SettingsConstants.VISIBILITY__GENERAL: @@ -100,6 +99,9 @@ class SettingsEntryUpdateSelectionView(View): self.settings_entry = SettingsDefinition.get_settings_entry(attr_name) self.selected_button = None self.parent_initial_scroll = parent_initial_scroll + + # hook for test suite to override recursion in run() + self.is_test = False def run(self): @@ -123,14 +125,18 @@ class SettingsEntryUpdateSelectionView(View): if not self.selected_button: self.selected_button = 0 - ret_value = settings_screens.SettingsEntryUpdateSelectionScreen( + screen_args = dict( display_name=self.settings_entry.display_name, help_text=self.settings_entry.help_text, button_data=button_data, selected_button=self.selected_button, checked_buttons=checked_buttons, - settings_entry_type=self.settings_entry.type, - ).display() + settings_entry_type=self.settings_entry.type, + ) + ret_value = self.run_screen( + settings_screens.SettingsEntryUpdateSelectionScreen, + **screen_args + ) destination = None settings_menu_view_destination = Destination( @@ -178,7 +184,13 @@ class SettingsEntryUpdateSelectionView(View): # All selects stay in place; re-initialize where in the list we left off self.selected_button = ret_value - return self.run() + + if self.is_test: + # Test suite can't handle the recursive call. Fake it by returning us here + # via a Destination back to this View. + return Destination(self.__class__, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll)) + else: + return self.run() @@ -187,7 +199,7 @@ class SettingsEntryUpdateSelectionView(View): ****************************************************************************""" class IOTestView(View): def run(self): - settings_screens.IOTestScreen().display() + self.run_screen(settings_screens.IOTestScreen) return Destination(SettingsMenuView) @@ -195,6 +207,6 @@ class IOTestView(View): class DonateView(View): def run(self): - settings_screens.DonateScreen().display() + self.run_screen(settings_screens.DonateScreen) return Destination(SettingsMenuView) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index bc21aa95..4e7e5db9 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -57,17 +57,19 @@ class View: self.canvas_height = self.renderer.canvas_height self.buttons = self.controller.buttons + self.screen = None def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> Union[int,str]: """ - Instantiates the View class' Screen_cls and runs its interactive display. + Instantiates the provided Screen_cls and runs its interactive display. Returns the user's input upon completion. """ - return Screen_cls(**kwargs).display() + self.screen = Screen_cls(**kwargs) + return self.screen.display() - def run(self, **kwargs): + def run(self, **kwargs) -> 'Destination': raise Exception("Must implement in the child class") diff --git a/tests/base.py b/tests/base.py new file mode 100644 index 00000000..dfcac8f3 --- /dev/null +++ b/tests/base.py @@ -0,0 +1,87 @@ +import sys +from dataclasses import dataclass +from mock import MagicMock, patch +from typing import Callable, List, Type, Union + +# Prevent importing modules w/Raspi hardware dependencies. +# These must precede any SeedSigner imports. +sys.modules['seedsigner.gui.renderer'] = MagicMock() +sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.hardware.buttons'] = MagicMock() +sys.modules['seedsigner.hardware.camera'] = MagicMock() +sys.modules['seedsigner.hardware.microsd'] = MagicMock() + +from seedsigner.controller import Controller +from seedsigner.models import Settings +from seedsigner.views.view import Destination, View + + + +class BaseTest: + + def reset_controller(self): + """ Wipe and re-initialize the Controller singleton for each test run """ + Controller._instance = None + Controller.configure_instance() + + + def setup_class(self): + Settings.SETTINGS_FILENAME = "settings-test.json" + + + def teardown_class(self): + """ If settings were written to disk, delete """ + import os + try: + print(f"attempting to remove {Settings.SETTINGS_FILENAME}") + os.remove(Settings.SETTINGS_FILENAME) + print(f"Removed {Settings.SETTINGS_FILENAME}") + except: + print(f"{Settings.SETTINGS_FILENAME} not found to be removed") + + + def setup_method(self): + self.reset_controller() + self.controller = Controller.get_instance() + self.settings = Settings.get_instance() + + + +@dataclass +class FlowStep: + expected_view: Type[View] = None + run_before: Callable = None + screen_return_value: Union[int, str] = None + + + +class FlowTest(BaseTest): + def run_sequence(self, initial_destination: Destination, sequence: List[FlowStep]) -> Destination: + """ + Runs the given sequence from the initial_destination, mocking out the associated + Screen and the scenario's return_value, receiving the resulting new Destination, + and repeating until the sequence is complete. + + Returns the final Destination. + """ + next_destination = initial_destination + for flow_step in sequence: + try: + # Validate that the next Destination's View is what we expected + if flow_step.expected_view: + assert next_destination.View_cls == flow_step.expected_view + + # Patch the `View.run_screen()` so we don't actually instantiate the Screen + qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) + with patch(qualname + ".run_screen") as mock_run_screen: + mock_run_screen.return_value = flow_step.screen_return_value + + # Now we can run the View and grab its resulting next Destination + next_destination = next_destination.run(run_before=flow_step.run_before) + except Exception as e: + import traceback + traceback.print_exc() + print(f"{next_destination} | {flow_step}") + raise e + + return next_destination diff --git a/tests/test_controller.py b/tests/test_controller.py index 51511807..fb76b533 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -1,25 +1,21 @@ import pytest # Must import this before the Controller -from utils import reset_controller +from base import BaseTest from seedsigner.controller import Controller from seedsigner.models.settings_definition import SettingsConstants -class TestController: - - def setup_method(self): - reset_controller() - +class TestController(BaseTest): def test_reset_controller(self): """ The reset_controller util should completely reset the Controller singleton """ controller = Controller.get_instance() controller.address_explorer_data = "foo" - reset_controller() + self.reset_controller() controller = Controller.get_instance() assert controller.address_explorer_data is None diff --git a/tests/test_flows.py b/tests/test_flows.py deleted file mode 100644 index d5685722..00000000 --- a/tests/test_flows.py +++ /dev/null @@ -1,119 +0,0 @@ -from typing import List, Type, Union -from mock import patch - -# Must import this before the Controller -from utils import reset_controller - -from seedsigner.controller import Controller -from seedsigner.models.seed import Seed -from seedsigner.views.view import Destination, MainMenuView, View -from seedsigner.views import seed_views, scan_views - - - -RUN_BEFORE = "run_before" -RETURN_VALUE = "return_value" - - - -def run_flow_sequence(initial_destination: Destination, return_values: List[Union[int, str, dict]]) -> Destination: - """ - Runs the given sequence from the initial_destination, mocking out the associated - Screen and the scenario's return_value, receiving the resulting new Destination, - and repeating until the sequence is complete. - - Returns the final Destination. - """ - next_destination = initial_destination - for return_value in return_values: - # Optionally modify the View_cls instance before run_screen is called - run_before = None - if type(return_value) == dict: - if RUN_BEFORE in return_value: - run_before = return_value[RUN_BEFORE] - - # Patch the run_screen so we don't actually instantiate and execute the Screen - qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) - with patch(qualname + ".run_screen") as mock_run_screen: - mock_run_screen.return_value = return_value - - # Now we can run the View and grab its resulting next Destination - next_destination = next_destination.run(run_before=run_before) - - return next_destination - - - -class TestFlows: - - def setup_method(self): - reset_controller() - - - def test_home_scan_seedoptions_flow(self): - """ - Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the - Finalize Seed flow and end at the SeedOptionsView. - """ - def load_seed_into_decoder(view: scan_views.ScanView): - view.decoder.add_data("0000" * 11 + "0003") - - sequence = [ - 0, # MainMenuView: ret SCAN - {RUN_BEFORE: load_seed_into_decoder, RETURN_VALUE: None}, # ScanView: read SeedQR - 0, # SeedFinalizeView: ret DONE - ] - destination = run_flow_sequence(Destination(MainMenuView), return_values=sequence) - - # Should land on SeedOptionsView - assert destination.View_cls == seed_views.SeedOptionsView - - - def test_addressexplorer_seedfinalizescreen_flow(self): - """ - Finalizing a seed during the Address Explorer flow should return to the next - Address Explorer step upon completion. - """ - controller = Controller.get_instance() - seed = Seed(mnemonic=["abandon "* 11 + "about"]) - controller.storage.set_pending_seed(seed) - - # Default behavior should land at the SeedOptionsView - sequence = [ - 0, # SeedFinalizeView: ret DONE - ] - destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) - assert destination.View_cls == seed_views.SeedOptionsView - - # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) - - # Now set the flow and try again - controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER - - # Finalize the new seed w/out passphrase - sequence = [ - 0, # SeedFinalizeView: ret DONE - None, # SeedOptionsView should auto-route away - ] - destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) - - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView - - # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) - - # Finalize the new seed w/passphrase - sequence = [ - 1, # SeedFinalizeView: ret PASSPHRASE - "mypassphrase", # SeedPassphraseView: ret passphrase str - 1, # SeedReviewPassphraseView: ret DONE - None, # SeedOptionsView should auto-route away - ] - destination = run_flow_sequence(Destination(seed_views.SeedFinalizeView), return_values=sequence) - - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py new file mode 100644 index 00000000..220e384b --- /dev/null +++ b/tests/test_flows_seed.py @@ -0,0 +1,143 @@ + +# Must import this before the Controller +from base import FlowTest, FlowStep + +from seedsigner.views.view import Destination, MainMenuView +from seedsigner.views import seed_views, scan_views + + + +class TestSeedFlows(FlowTest): + + def test_scan_seedqr_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a SeedQR should enter the + Finalize Seed flow and end at the SeedOptionsView. + """ + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=0, # ret SCAN + ), + FlowStep( + expected_view=scan_views.ScanView, + run_before=load_seed_into_decoder, # simulate read SeedQR + screen_return_value=None, + ), + FlowStep( + expected_view=seed_views.SeedFinalizeView, + screen_return_value=0, # ret DONE + ), + ] + ) + assert destination.View_cls == seed_views.SeedOptionsView + + + def test_mnemonic_entry_flow(self): + """ + Manually entering a mnemonic should land at the Finalize Seed flow and end at + the SeedOptionsView. + """ + def test_with_mnemonic(mnemonic): + sequence = [ + FlowStep( + screen_return_value=1, # ret SEEDS + ), + FlowStep( + expected_view=seed_views.SeedsMenuView, + # Nothing to return; auto-forwards to LoadSeedView + ), + FlowStep( + expected_view=seed_views.LoadSeedView, + screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + ), + ] + + # Now add each manual word entry step + for word in mnemonic: + sequence.append( + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value=word + ) + ) + + # With the mnemonic completely entered, we land on the SeedFinalizeView + sequence.append( + FlowStep( + expected_view=seed_views.SeedFinalizeView, + screen_return_value=0, # ret DONE + ) + ) + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=sequence + ) + assert destination.View_cls == seed_views.SeedOptionsView + + # Test data from iancoleman.io; 12- and 24-word mnemonic + test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) + + self.reset_controller() + + test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split()) + + + def test_invalid_mnemonic(self): + """ Should be able to go back and edit or discard an invalid mnemonic """ + # Test data from iancoleman.io + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + sequence = [ + FlowStep( + screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + ), + ] + for word in mnemonic[:-1]: + sequence.append( + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value=word + ) + ) + + sequence += [ + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value="zoo" # But finish with an INVALID checksum word + ), + FlowStep( + expected_view=seed_views.SeedMnemonicInvalidView, + screen_return_value=0 # ret EDIT + ), + ] + + # Restarts from first word + for word in mnemonic[:-1]: + sequence.append( + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value=word + ) + ) + + sequence += [ + FlowStep( + expected_view=seed_views.SeedMnemonicEntryView, + screen_return_value="zebra" # provide yet another invalid checksum word + ), + FlowStep( + expected_view=seed_views.SeedMnemonicInvalidView, + screen_return_value=1 # ret DISCARD; this time we give up + ), + ] + + destination = self.run_sequence( + Destination(seed_views.LoadSeedView), + sequence + ) + assert destination.View_cls == MainMenuView \ No newline at end of file diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py new file mode 100644 index 00000000..33c8e7a9 --- /dev/null +++ b/tests/test_flows_settings.py @@ -0,0 +1,157 @@ +from mock import MagicMock +from typing import Type + +# Must import this before any seedsigner imports +from base import FlowTest, FlowStep + +from seedsigner.models import SettingsDefinition +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON +from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views import settings_views + + + +class TestSettingsFlows(FlowTest): + + def test_persistent_settings(self): + """ Basic flow from MainMenuView to enable/disable persistent settings """ + # Which option are we testing? + settings_entries = SettingsDefinition.get_settings_entries() + nth_entry = [s.attr_name for s in settings_entries].index(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret persistent settings + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + screen_return_value=[s[0] for s in settings_entry.selection_options].index(SettingsConstants.OPTION__ENABLED), + ), + ] + ) + assert destination.View_cls == settings_views.SettingsMenuView + + + def test_multiselect(self): + """ Multiselect Settings options should stay in-place; requires BACK to exit. """ + # Which option are we testing? + settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) + nth_entry = SettingsDefinition.get_settings_entries().index(settings_entry) + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + def disable_recursion(view: Type[View]): + # The recursion in SettingsEntryUpdateSelectionView for multiselect settings + # won't work with our FlowTest sequence structure. + view.is_test = True + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret Coordinator software + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + run_before=disable_recursion, + screen_return_value=0, # select/deselect first option + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + run_before=disable_recursion, + screen_return_value=1, # select/deselect second option + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + run_before=disable_recursion, + screen_return_value=1, # select/deselect second option + ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + screen_return_value=RET_CODE__BACK_BUTTON, # BACK to exit + ), + ] + ) + assert destination.View_cls == settings_views.SettingsMenuView + + + def test_io_test(self): + """ Basic flow from MainMenuView to I/O Test View """ + # I/O Test will be the nth_entry after we include the submenu to "Advanced" + nth_entry = len(SettingsDefinition.get_settings_entries()) + 1 + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret I/O Test + ), + FlowStep( + expected_view=settings_views.IOTestView, + screen_return_value=None, # ret value is ignored + ), + ] + ) + # Exiting IOTestView should return us to the main SettingsMenuView + assert destination.View_cls == settings_views.SettingsMenuView + + + def test_donate(self): + """ Basic flow from MainMenuView to Donate View """ + # DonateView will be the nth_entry after we include the submenu to "Advanced" + # and the I/O Test + nth_entry = len(SettingsDefinition.get_settings_entries()) + 2 + + def mock_settingsmenuview_screen(view: Type[View]): + # SettingsMenuView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=3, # ret SETTINGS + ), + FlowStep( + expected_view=settings_views.SettingsMenuView, + run_before=mock_settingsmenuview_screen, + screen_return_value=nth_entry, # ret Donate + ), + FlowStep( + expected_view=settings_views.DonateView, + screen_return_value=None, # ret value is ignored + ), + ] + ) + # Exiting IOTestView should return us to the main SettingsMenuView + assert destination.View_cls == settings_views.SettingsMenuView diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py new file mode 100644 index 00000000..efa8529f --- /dev/null +++ b/tests/test_flows_tools.py @@ -0,0 +1,83 @@ + +# Must import this before the Controller +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller +from seedsigner.models.seed import Seed +from seedsigner.views.view import Destination, MainMenuView +from seedsigner.views import seed_views, scan_views + + + +class TestToolsFlows(FlowTest): + + def test_addressexplorer_seedfinalizescreen_flow(self): + """ + Finalizing a seed during the Address Explorer flow should return to the next + Address Explorer step upon completion. + """ + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + + # Default behavior should land at the SeedOptionsView + destination = self.run_sequence( + Destination(seed_views.SeedFinalizeView), + sequence=[ + FlowStep( + screen_return_value=0, # ret DONE + ), + ] + ) + assert destination.View_cls == seed_views.SeedOptionsView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Now set the flow and try again + controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + + # Finalize the new seed w/out passphrase + destination = self.run_sequence( + Destination(seed_views.SeedFinalizeView), + sequence=[ + FlowStep( + screen_return_value=0, # ret DONE + ), + FlowStep( + expected_view=seed_views.SeedOptionsView, + screen_return_value=None, # should auto-route away w/out a selection + ), + ] + ) + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + + # Reset + controller.storage.seeds.clear() + controller.storage.set_pending_seed(seed) + + # Finalize the new seed w/passphrase + destination = self.run_sequence( + Destination(seed_views.SeedFinalizeView), + sequence=[ + FlowStep( + screen_return_value=1, # ret PASSPHRASE + ), + FlowStep( + expected_view=seed_views.SeedAddPassphraseView, + screen_return_value="mypassphrase", + ), + FlowStep( + expected_view=seed_views.SeedReviewPassphraseView, + screen_return_value=1, # ret DONE + ), + FlowStep( + expected_view=seed_views.SeedOptionsView, + screen_return_value=None, # SeedOptionsView should auto-route away + ), + ] + ) + # Flow should resume at Script Type selection + assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView diff --git a/tests/utils.py b/tests/utils.py deleted file mode 100644 index f0813665..00000000 --- a/tests/utils.py +++ /dev/null @@ -1,17 +0,0 @@ -import sys -from mock import MagicMock - -# Prevent importing modules w/Raspi hardware dependencies -sys.modules['seedsigner.gui.renderer'] = MagicMock() -sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() -sys.modules['seedsigner.hardware.buttons'] = MagicMock() -sys.modules['seedsigner.hardware.camera'] = MagicMock() -sys.modules['seedsigner.hardware.microsd'] = MagicMock() - -from seedsigner.controller import Controller - - -def reset_controller(): - """ Wipe and re-initialize the Controller singleton for each test run """ - Controller._instance = None - Controller.configure_instance() From b03222cdc603c73f9a28d0dc0314ff347286a71f Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 08:32:40 -0600 Subject: [PATCH 08/67] Update test_flows_tools.py --- tests/test_flows_tools.py | 22 ++++------------------ 1 file changed, 4 insertions(+), 18 deletions(-) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index efa8529f..602c53af 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -16,26 +16,12 @@ class TestToolsFlows(FlowTest): Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ + # Set up pending seed leading up to SeedFinalizeView controller = Controller.get_instance() seed = Seed(mnemonic=["abandon "* 11 + "about"]) controller.storage.set_pending_seed(seed) - - # Default behavior should land at the SeedOptionsView - destination = self.run_sequence( - Destination(seed_views.SeedFinalizeView), - sequence=[ - FlowStep( - screen_return_value=0, # ret DONE - ), - ] - ) - assert destination.View_cls == seed_views.SeedOptionsView - # Reset - controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) - - # Now set the flow and try again + # Set the Address Explorer flow to change routing behavior controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER # Finalize the new seed w/out passphrase @@ -47,7 +33,7 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedOptionsView, - screen_return_value=None, # should auto-route away w/out a selection + # should auto-route away w/out a selection ), ] ) @@ -75,7 +61,7 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedOptionsView, - screen_return_value=None, # SeedOptionsView should auto-route away + # SeedOptionsView should auto-route away ), ] ) From 2d9bf13b306c1d26a6b515c756cafa1f80375783 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 12:45:31 -0600 Subject: [PATCH 09/67] More extensive tools_view testing; BaseTest fixes --- src/seedsigner/views/seed_views.py | 5 +- src/seedsigner/views/tools_views.py | 84 ++++++++++++++------------ tests/base.py | 34 ++++++----- tests/test_flows_tools.py | 92 +++++++++++++++++++++++++---- 4 files changed, 149 insertions(+), 66 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0112267b..8f16bfac 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -520,12 +520,13 @@ class SeedExportXpubScriptTypeView(View): if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: title = "Address Explorer" - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title=title, is_button_text_centered=False, button_data=button_data, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: # If previous view is SeedOptionsView then that should be where resume_main_flow started (otherwise it would have been skipped). diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index e7778bdf..20b7309b 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -7,8 +7,6 @@ from embit.descriptor import Descriptor from PIL import Image from PIL.ImageOps import autocontrast from seedsigner.controller import Controller -from seedsigner.gui.screens.screen import LoadingScreenThread, QRDisplayScreen - from seedsigner.hardware.camera import Camera from seedsigner.gui.components import FontAwesomeIconConstants, GUIConstants, SeedSignerCustomIconConstants from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen) @@ -31,12 +29,13 @@ class ToolsMenuView(View): KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] - screen = ButtonListScreen( + + selected_menu_num = self.run_screen( + ButtonListScreen, title="Tools", is_button_text_centered=False, button_data=button_data ) - selected_menu_num = screen.display() if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -440,12 +439,13 @@ class ToolsAddressExplorerSelectSourceView(View): button_data.append(TYPE_12WORD) button_data.append(TYPE_24WORD) - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Address Explorer", button_data=button_data, is_button_text_centered=False, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -538,13 +538,14 @@ class ToolsAddressExplorerAddressTypeView(View): script_type = data["script_type"] if "script_type" in data else None - selected_menu_num = ToolsAddressExplorerAddressTypeScreen( + selected_menu_num = self.run_screen( + ToolsAddressExplorerAddressTypeScreen, button_data=button_data, fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, wallet_descriptor_display_name=wallet_descriptor_display_name, script_type=script_type, custom_derivation_path=self.custom_derivation, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -565,21 +566,23 @@ class ToolsAddressExplorerAddressListView(View): def run(self): self.loading_screen = None - try: - addresses = [] - button_data = [] - data = self.controller.address_explorer_data - addrs_per_screen = 10 - addr_storage_key = "receive_addrs" - if self.is_change: - addr_storage_key = "change_addrs" + addresses = [] + button_data = [] + data = self.controller.address_explorer_data + addrs_per_screen = 10 - if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: - # We already calculated this range addresses; just retrieve them - addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] + addr_storage_key = "receive_addrs" + if self.is_change: + addr_storage_key = "change_addrs" - else: + if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: + # We already calculated this range addresses; just retrieve them + addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] + + else: + try: + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Calculating addrs...") self.loading_screen.start() @@ -608,9 +611,15 @@ class ToolsAddressExplorerAddressListView(View): else: raise Exception("Single sig descriptors not yet supported") + finally: + # Everything is set. Stop the loading screen + self.loading_screen.stop() for i, address in enumerate(addresses): cur_index = i + self.start_index + + # Adjust the trailing addr display length based on available room + # (the index number will push it out on each order of magnitude) if cur_index < 10: end_digits = -6 elif cur_index < 100: @@ -621,22 +630,17 @@ class ToolsAddressExplorerAddressListView(View): button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - screen = ButtonListScreen( - title="{} Addrs".format("Receive" if not self.is_change else "Change"), - button_data=button_data, - button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, - button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, - is_button_text_centered=False, - is_bottom_list=True, - selected_button=self.selected_button_index, - scroll_y_initial_offset=self.initial_scroll, - ) - finally: - # Everything is set. Stop the loading screen - if self.loading_screen: - self.loading_screen.stop() - - selected_menu_num = screen.display() + selected_menu_num = self.run_screen( + ButtonListScreen, + title="{} Addrs".format("Receive" if not self.is_change else "Change"), + button_data=button_data, + button_font_name=GUIConstants.FIXED_WIDTH_EMPHASIS_FONT_NAME, + button_font_size=GUIConstants.BUTTON_FONT_SIZE + 4, + is_button_text_centered=False, + is_bottom_list=True, + selected_button=self.selected_button_index, + scroll_y_initial_offset=self.initial_scroll, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -646,7 +650,7 @@ class ToolsAddressExplorerAddressListView(View): return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index + addrs_per_screen)) # Preserve the list's current scroll so we can return to the same spot - initial_scroll = screen.buttons[0].scroll_y + initial_scroll = self.screen.buttons[0].scroll_y index = selected_menu_num + self.start_index return Destination(ToolsAddressExplorerAddressView, view_args=dict(index=index, address=addresses[selected_menu_num], is_change=self.is_change, start_index=self.start_index, parent_initial_scroll=initial_scroll), skip_current_view=True) @@ -664,10 +668,12 @@ class ToolsAddressExplorerAddressView(View): def run(self): + from seedsigner.gui.screens.screen import QRDisplayScreen qr_encoder = EncodeQR(qr_type=QRType.BITCOIN_ADDRESS, bitcoin_address=self.address) - QRDisplayScreen( + self.run_screen( + QRDisplayScreen, qr_encoder=qr_encoder, - ).display() + ) # Exiting/Cancelling the QR display screen always returns to the list return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.is_change, start_index=self.start_index, selected_button_index=self.index - self.start_index, initial_scroll=self.parent_initial_scroll), skip_current_view=True) diff --git a/tests/base.py b/tests/base.py index dfcac8f3..0a08823a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -19,27 +19,31 @@ from seedsigner.views.view import Destination, View class BaseTest: + @classmethod + def setup_class(cls): + # Ensure there are no on-disk artifacts after running tests. + Settings.SETTINGS_FILENAME = "settings-test.json" + + # Mock out the loading screen so it can't spawn. + patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() + + + @classmethod + def teardown_class(cls): + """ If settings were written to disk, delete """ + import os + try: + os.remove(Settings.SETTINGS_FILENAME) + except: + print(f"{Settings.SETTINGS_FILENAME} not found to be removed") + + def reset_controller(self): """ Wipe and re-initialize the Controller singleton for each test run """ Controller._instance = None Controller.configure_instance() - def setup_class(self): - Settings.SETTINGS_FILENAME = "settings-test.json" - - - def teardown_class(self): - """ If settings were written to disk, delete """ - import os - try: - print(f"attempting to remove {Settings.SETTINGS_FILENAME}") - os.remove(Settings.SETTINGS_FILENAME) - print(f"Removed {Settings.SETTINGS_FILENAME}") - except: - print(f"{Settings.SETTINGS_FILENAME} not found to be removed") - - def setup_method(self): self.reset_controller() self.controller = Controller.get_instance() diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 602c53af..4a834f2c 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -1,34 +1,105 @@ # Must import this before the Controller +from typing import Type + +from mock import MagicMock from base import FlowTest, FlowStep from seedsigner.controller import Controller from seedsigner.models.seed import Seed -from seedsigner.views.view import Destination, MainMenuView -from seedsigner.views import seed_views, scan_views +from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views import scan_views, seed_views, tools_views class TestToolsFlows(FlowTest): - def test_addressexplorer_seedfinalizescreen_flow(self): + def test_addressexplorer_flow(self): + """ + Test the simplest AddressExplorer flow when a seed is already loaded. + """ + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + controller.storage.finalize_pending_seed() + + def mock_addrlistview_screen(view: Type[View]): + # ToolsAddressExplorerAddressListView reaches into its `self.screen` so we need to mock it out + view.screen = MagicMock() + + destination = self.run_sequence( + Destination(MainMenuView), + sequence=[ + FlowStep( + screen_return_value=2 # ret TOOLS + ), + FlowStep( + expected_view=tools_views.ToolsMenuView, + screen_return_value=3 # ret EXPLORER + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerSelectSourceView, + screen_return_value=0 # ret 1st onboard seed + ), + FlowStep( + expected_view=seed_views.SeedExportXpubScriptTypeView, + screen_return_value=0 # ret NATIVE_SEGWIT (1st script type option) + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressTypeView, + screen_return_value=0 # ret RECEIVE addrs + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressListView, + screen_return_value=10 # ret NEXT page of addrs + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressListView, + run_before=mock_addrlistview_screen, + screen_return_value=4 # ret a specific addr from the list + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerAddressView, + # QRDisplayScreen runs until dismissed; no ret value + ), + ] + ) + # After dismissing QRDisplayScreen, should return to the addr list view + assert destination.View_cls == tools_views.ToolsAddressExplorerAddressListView + + + def test_addressexplorer_loadseed_sideflow(self): """ Finalizing a seed during the Address Explorer flow should return to the next Address Explorer step upon completion. """ - # Set up pending seed leading up to SeedFinalizeView controller = Controller.get_instance() - seed = Seed(mnemonic=["abandon "* 11 + "about"]) - controller.storage.set_pending_seed(seed) - # Set the Address Explorer flow to change routing behavior - controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("0000" * 11 + "0003") # Finalize the new seed w/out passphrase destination = self.run_sequence( - Destination(seed_views.SeedFinalizeView), + Destination(MainMenuView), sequence=[ FlowStep( + screen_return_value=2 # ret TOOLS + ), + FlowStep( + expected_view=tools_views.ToolsMenuView, + screen_return_value=3 # ret EXPLORER + ), + FlowStep( + expected_view=tools_views.ToolsAddressExplorerSelectSourceView, + screen_return_value=0 # ret SCAN SeedQR + ), + FlowStep( + expected_view=scan_views.ScanView, + run_before=load_seed_into_decoder, # simulate read SeedQR + screen_return_value=None, + ), + FlowStep( + expected_view=seed_views.SeedFinalizeView, screen_return_value=0, # ret DONE ), FlowStep( @@ -39,10 +110,11 @@ class TestToolsFlows(FlowTest): ) # Flow should resume at Script Type selection assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER # Reset controller.storage.seeds.clear() - controller.storage.set_pending_seed(seed) + controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) # Finalize the new seed w/passphrase destination = self.run_sequence( From 4f1df7ac428bbe2de7917872fd6441410102a011 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 13:06:41 -0600 Subject: [PATCH 10/67] Slight BaseTest reorg; more comments --- tests/base.py | 37 +++++++++++++++++++++++++++++++------ tests/test_controller.py | 2 +- tests/test_flows_seed.py | 4 ++-- 3 files changed, 34 insertions(+), 9 deletions(-) diff --git a/tests/base.py b/tests/base.py index 0a08823a..0010cef7 100644 --- a/tests/base.py +++ b/tests/base.py @@ -30,6 +30,17 @@ class BaseTest: @classmethod def teardown_class(cls): + pass + + + @classmethod + def reset_settings(cls): + """ Wipe and re-initialize the Settings singleton """ + Settings._instance = None + + + @classmethod + def remove_settings(cls): """ If settings were written to disk, delete """ import os try: @@ -38,21 +49,29 @@ class BaseTest: print(f"{Settings.SETTINGS_FILENAME} not found to be removed") - def reset_controller(self): - """ Wipe and re-initialize the Controller singleton for each test run """ + @classmethod + def reset_controller(cls): + """ Wipe and re-initialize the Controller singleton """ Controller._instance = None Controller.configure_instance() def setup_method(self): - self.reset_controller() + """ Guarantee a clean/default Controller and Settings state for each test case """ + BaseTest.reset_controller() + BaseTest.reset_settings() self.controller = Controller.get_instance() self.settings = Settings.get_instance() + + + def teardown_method(self): + BaseTest.remove_settings() @dataclass class FlowStep: + """ Trivial helper class to express FlowTest sequences below """ expected_view: Type[View] = None run_before: Callable = None screen_return_value: Union[int, str] = None @@ -60,11 +79,17 @@ class FlowStep: class FlowTest(BaseTest): + """ Base class for any tests that do flow-based testing """ + def run_sequence(self, initial_destination: Destination, sequence: List[FlowStep]) -> Destination: """ - Runs the given sequence from the initial_destination, mocking out the associated - Screen and the scenario's return_value, receiving the resulting new Destination, - and repeating until the sequence is complete. + Runs the given sequence of FlowSteps starting from the initial_destination: + * verifies that we landed on the expected_view (if provided). + * mocks out the `View.run_screen()` to prevent the associated Screen class from instantiating. + * patches in the FlowStep's screen_return_value (as if it came from user interaction). + * optional `run_before` method modifies the View when necessary to be compatible w/test suite limitations. + * Runs the View and receives the resulting next Destination. + * then repeats the process on the next Destination until the sequence is complete. Returns the final Destination. """ diff --git a/tests/test_controller.py b/tests/test_controller.py index fb76b533..df6c37ec 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -15,7 +15,7 @@ class TestController(BaseTest): controller = Controller.get_instance() controller.address_explorer_data = "foo" - self.reset_controller() + BaseTest.reset_controller() controller = Controller.get_instance() assert controller.address_explorer_data is None diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 220e384b..942c9710 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,6 @@ # Must import this before the Controller -from base import FlowTest, FlowStep +from base import BaseTest, FlowTest, FlowStep from seedsigner.views.view import Destination, MainMenuView from seedsigner.views import seed_views, scan_views @@ -83,7 +83,7 @@ class TestSeedFlows(FlowTest): # Test data from iancoleman.io; 12- and 24-word mnemonic test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) - self.reset_controller() + BaseTest.reset_controller() test_with_mnemonic("cotton artefact spy mind wing there echo steak child oak awful host despair online bicycle divorce middle firm diamond rare execute chimney almost hollow".split()) From 25cb92a31fd8f42d22fcc3b4931c1ab7975a996e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 5 Mar 2023 19:48:50 -0600 Subject: [PATCH 11/67] Improved integration w/View.button_data options * Modifies Views to make button_data and its options available to external caller (FlowTests). * Adds button_data_selection for tighter integration with Views and better human-readability of FlowStep sequences. * Removes the unique recursive run() call in SettingsEntryUpdateSelectionView. --- src/seedsigner/views/seed_views.py | 94 ++++++++++---------- src/seedsigner/views/settings_views.py | 113 ++++++++++++------------- src/seedsigner/views/tools_views.py | 76 +++++++++-------- src/seedsigner/views/view.py | 56 +++++++----- tests/base.py | 32 +++++-- tests/test_flows_seed.py | 18 ++-- tests/test_flows_settings.py | 43 ++++------ tests/test_flows_tools.py | 24 +++--- tests/test_settings.py | 16 ++++ 9 files changed, 254 insertions(+), 218 deletions(-) create mode 100644 tests/test_settings.py diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 8f16bfac..9e45a1f7 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -71,41 +71,41 @@ class SeedsMenuView(View): Loading seeds, passphrases, etc ****************************************************************************""" class LoadSeedView(View): - def run(self): - SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) - button_data=[ - SEED_QR, - TYPE_12WORD, - TYPE_24WORD, - CREATE, - ] + SEED_QR = (" Scan a SeedQR", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) + button_data=[ + SEED_QR, + TYPE_12WORD, + TYPE_24WORD, + CREATE, + ] + def run(self): selected_menu_num = self.run_screen( ButtonListScreen, title="Load A Seed", is_button_text_centered=False, - button_data=button_data + button_data=self.button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SEED_QR: + if self.button_data[selected_menu_num] == self.SEED_QR: from .scan_views import ScanView return Destination(ScanView) - elif button_data[selected_menu_num] == TYPE_12WORD: + elif self.button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == TYPE_24WORD: + elif self.button_data[selected_menu_num] == self.TYPE_24WORD: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) - elif button_data[selected_menu_num] == CREATE: + elif self.button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -169,62 +169,60 @@ class SeedMnemonicEntryView(View): class SeedMnemonicInvalidView(View): + EDIT = "Review & Edit" + DISCARD = ("Discard", None, None, "red") + button_data = [EDIT, DISCARD] + def __init__(self): super().__init__() self.mnemonic: List[str] = self.controller.storage.pending_mnemonic def run(self): - EDIT = "Review & Edit" - DISCARD = ("Discard", None, None, "red") - button_data = [EDIT, DISCARD] - selected_menu_num = self.run_screen( WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, - button_data=button_data, + button_data=self.button_data, ) - if button_data[selected_menu_num] == EDIT: + if self.button_data[selected_menu_num] == self.EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) - elif button_data[selected_menu_num] == DISCARD: + elif self.button_data[selected_menu_num] == self.DISCARD: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) class SeedFinalizeView(View): + FINALIZE = "Done" + PASSPHRASE = "BIP-39 Passphrase" + button_data = [FINALIZE] + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: + self.button_data.append(self.PASSPHRASE) + def run(self): - FINALIZE = "Done" - PASSPHRASE = "BIP-39 Passphrase" - button_data = [] - - button_data.append(FINALIZE) - - if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - button_data.append(PASSPHRASE) - selected_menu_num = self.run_screen( seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, - button_data=button_data, + button_data=self.button_data, ) - if button_data[selected_menu_num] == FINALIZE: + if self.button_data[selected_menu_num] == self.FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - elif button_data[selected_menu_num] == PASSPHRASE: + elif self.button_data[selected_menu_num] == self.PASSPHRASE: return Destination(SeedAddPassphraseView) @@ -254,16 +252,16 @@ class SeedReviewPassphraseView(View): """ Display the completed passphrase back to the user. """ + EDIT = "Edit passphrase" + DONE = "Done" + button_data = [EDIT, DONE] + def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() def run(self): - EDIT = "Edit passphrase" - DONE = "Done" - button_data = [EDIT, DONE] - # Get the before/after fingerprints network = self.settings.get_value(SettingsConstants.SETTING__NETWORK) passphrase = self.seed.passphrase @@ -279,14 +277,14 @@ class SeedReviewPassphraseView(View): fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, - button_data=button_data, + button_data=self.button_data, show_back_button=False, ) - if button_data[selected_menu_num] == EDIT: + if self.button_data[selected_menu_num] == self.EDIT: return Destination(SeedAddPassphraseView) - elif button_data[selected_menu_num] == DONE: + elif self.button_data[selected_menu_num] == self.DONE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) @@ -498,6 +496,10 @@ class SeedExportXpubScriptTypeView(View): self.seed_num = seed_num self.sig_type = sig_type + self.button_data = [] + for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): + self.button_data.append(script_type) + def run(self): from .tools_views import ToolsAddressExplorerAddressTypeView @@ -511,10 +513,6 @@ class SeedExportXpubScriptTypeView(View): return Destination(ToolsAddressExplorerAddressTypeView, view_args=args, skip_current_view=True) else: return Destination(SeedExportXpubCoordinatorView, view_args=args, skip_current_view=True) - - button_data = [] - for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): - button_data.append(script_type) title = "Export Xpub" if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: @@ -524,7 +522,7 @@ class SeedExportXpubScriptTypeView(View): ButtonListScreen, title=title, is_button_text_centered=False, - button_data=button_data, + button_data=self.button_data, is_bottom_list=True, ) @@ -536,7 +534,7 @@ class SeedExportXpubScriptTypeView(View): else: script_types_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) - selected_display_name = button_data[selected_menu_num] + selected_display_name = self.button_data[selected_menu_num] args["script_type"] = script_types_settings_entry.get_selection_option_value_by_display_name(selected_display_name) if args["script_type"] == SettingsConstants.CUSTOM_DERIVATION: diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index b458ea0c..929c3c81 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -8,6 +8,9 @@ from seedsigner.models.settings import SettingsConstants, SettingsDefinition class SettingsMenuView(View): + IO_TEST = "I/O test" + DONATE = "Donate" + def __init__(self, visibility: str = SettingsConstants.VISIBILITY__GENERAL, selected_attr: str = None, initial_scroll: int = 0): super().__init__() self.visibility = visibility @@ -16,51 +19,48 @@ class SettingsMenuView(View): # Used to preserve the rendering position in the list self.initial_scroll = initial_scroll - - def run(self): - IO_TEST = "I/O test" - DONATE = "Donate" - - settings_entries = SettingsDefinition.get_settings_entries( + self.settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) - button_data=[e.display_name for e in settings_entries] + self.button_data=[e.display_name for e in self.settings_entries] - selected_button = 0 + self.selected_button = 0 if self.selected_attr: - for i, entry in enumerate(settings_entries): + for i, entry in enumerate(self.settings_entries): if entry.attr_name == self.selected_attr: - selected_button = i + self.selected_button = i break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: - title = "Settings" + self.title = "Settings" # Set up the next nested level of menuing - button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) + self.button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - button_data.append(IO_TEST) - button_data.append(DONATE) + self.button_data.append(self.IO_TEST) + self.button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: - title = "Advanced" + self.title = "Advanced" # So far there are no real Developer options; disabling for now - # button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - # next = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) - next = None + # self.button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + # self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) + self.next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: - title = "Dev Options" - next = None + self.title = "Dev Options" + self.next_destination = None + + def run(self): selected_menu_num = self.run_screen( ButtonListScreen, - title=title, + title=self.title, is_button_text_centered=False, - button_data=button_data, - selected_button=selected_button, + button_data=self.button_data, + selected_button=self.selected_button, scroll_y_initial_offset=self.initial_scroll, ) @@ -75,17 +75,17 @@ class SettingsMenuView(View): else: return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - elif selected_menu_num == len(settings_entries): - return next + elif selected_menu_num == len(self.settings_entries): + return self.next_destination - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == IO_TEST: + elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) - elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == DONATE: + elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) else: - return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) @@ -94,48 +94,44 @@ class SettingsEntryUpdateSelectionView(View): Handles changes to all selection-type settings (Multiselect, SELECT_1, Enabled/Disabled, etc). """ - def __init__(self, attr_name: str, parent_initial_scroll: int = 0): + def __init__(self, attr_name: str, parent_initial_scroll: int = 0, selected_button: int = None): super().__init__() self.settings_entry = SettingsDefinition.get_settings_entry(attr_name) - self.selected_button = None + self.selected_button = selected_button self.parent_initial_scroll = parent_initial_scroll - - # hook for test suite to override recursion in run() - self.is_test = False - - def run(self): - initial_value = self.settings.get_value(self.settings_entry.attr_name) - button_data = [] - checked_buttons = [] + self.button_data = [] + self.checked_buttons = [] + self.initial_value = self.settings.get_value(self.settings_entry.attr_name) for i, value in enumerate(self.settings_entry.selection_options): if type(value) == tuple: value, display_name = value else: display_name = value - button_data.append(display_name) - if (type(initial_value) == list and value in initial_value) or value == initial_value: - checked_buttons.append(i) + + self.button_data.append(display_name) + + if (type(self.initial_value) == list and value in self.initial_value) or value == self.initial_value: + self.checked_buttons.append(i) if self.selected_button is None: # Highlight the selection (for multiselect highlight the first # selected option). self.selected_button = i - if not self.selected_button: + if self.selected_button is None: self.selected_button = 0 - screen_args = dict( - display_name=self.settings_entry.display_name, - help_text=self.settings_entry.help_text, - button_data=button_data, - selected_button=self.selected_button, - checked_buttons=checked_buttons, - settings_entry_type=self.settings_entry.type, - ) + + def run(self): ret_value = self.run_screen( settings_screens.SettingsEntryUpdateSelectionScreen, - **screen_args + display_name=self.settings_entry.display_name, + help_text=self.settings_entry.help_text, + button_data=self.button_data, + selected_button=self.selected_button, + checked_buttons=self.checked_buttons, + settings_entry_type=self.settings_entry.type, ) destination = None @@ -158,8 +154,8 @@ class SettingsEntryUpdateSelectionView(View): destination = settings_menu_view_destination elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT: - updated_value = list(initial_value) - if ret_value not in checked_buttons: + updated_value = list(self.initial_value) + if ret_value not in self.checked_buttons: # This is a new selection to add updated_value.append(value) else: @@ -168,7 +164,7 @@ class SettingsEntryUpdateSelectionView(View): else: # All other types are single selects (e.g. Enabled/Disabled, SELECT_1) - if value == initial_value: + if value == self.initial_value: # No change, return to menu return settings_menu_view_destination else: @@ -185,12 +181,7 @@ class SettingsEntryUpdateSelectionView(View): # All selects stay in place; re-initialize where in the list we left off self.selected_button = ret_value - if self.is_test: - # Test suite can't handle the recursive call. Fake it by returning us here - # via a Destination back to this View. - return Destination(self.__class__, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll)) - else: - return self.run() + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entry.attr_name, parent_initial_scroll=self.parent_initial_scroll, selected_button=self.selected_button), skip_current_view=True) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 20b7309b..572b996a 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -23,33 +23,38 @@ from .view import View, Destination, BackStackView class ToolsMenuView(View): + IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) + DICE = ("New seed", FontAwesomeIconConstants.DICE) + KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) + EXPLORER = "Address Explorer" + + def __init__(self): + super().__init__() + self.button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] + + def run(self): - IMAGE = (" New seed", FontAwesomeIconConstants.CAMERA) - DICE = ("New seed", FontAwesomeIconConstants.DICE) - KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) - EXPLORER = "Address Explorer" - button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] selected_menu_num = self.run_screen( ButtonListScreen, title="Tools", is_button_text_centered=False, - button_data=button_data + button_data=self.button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == IMAGE: + elif self.button_data[selected_menu_num] == self.IMAGE: return Destination(ToolsImageEntropyLivePreviewView) - elif button_data[selected_menu_num] == DICE: + elif self.button_data[selected_menu_num] == self.DICE: return Destination(ToolsDiceEntropyMnemonicLengthView) - elif button_data[selected_menu_num] == KEYBOARD: + elif self.button_data[selected_menu_num] == self.KEYBOARD: return Destination(ToolsCalcFinalWordNumWordsView) - elif button_data[selected_menu_num] == EXPLORER: + elif self.button_data[selected_menu_num] == self.EXPLORER: return Destination(ToolsAddressExplorerSelectSourceView) @@ -422,27 +427,27 @@ class ToolsCalcFinalWordDoneView(View): Address Explorer Views ****************************************************************************""" class ToolsAddressExplorerSelectSourceView(View): - def run(self): - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - SCAN_DESCRIPTOR = ("Scan wallet descriptor", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] + SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) + SCAN_DESCRIPTOR = ("Scan wallet descriptor", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + button_data = [SCAN_SEED, SCAN_DESCRIPTOR, TYPE_12WORD, TYPE_24WORD] - seeds = self.controller.storage.seeds - for seed in seeds: + def __init__(self): + super().__init__() + self.seeds = self.controller.storage.seeds + seed_buttons =[] + for seed in self.seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + seed_buttons.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + self.button_data = seed_buttons + self.button_data - button_data.append(SCAN_SEED) - button_data.append(SCAN_DESCRIPTOR) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) + def run(self): selected_menu_num = self.run_screen( ButtonListScreen, title="Address Explorer", - button_data=button_data, + button_data=self.button_data, is_button_text_centered=False, is_bottom_list=True, ) @@ -455,7 +460,7 @@ class ToolsAddressExplorerSelectSourceView(View): # knows to re-route us once the side flow is complete. self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER - if len(seeds) > 0 and selected_menu_num < len(seeds): + if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): # User selected one of the n seeds return Destination( SeedExportXpubScriptTypeView, @@ -465,13 +470,13 @@ class ToolsAddressExplorerSelectSourceView(View): ) ) - elif button_data[selected_menu_num] in [SCAN_SEED, SCAN_DESCRIPTOR]: + elif self.button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: + elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: + if self.button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -480,6 +485,11 @@ class ToolsAddressExplorerSelectSourceView(View): class ToolsAddressExplorerAddressTypeView(View): + RECEIVE = "Receive Addresses" + CHANGE = "Change Addresses" + button_data = [RECEIVE, CHANGE] + + def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None): """ If the explorer source is a seed, `seed_num` and `script_type` must be @@ -528,10 +538,6 @@ class ToolsAddressExplorerAddressTypeView(View): def run(self): data = self.controller.address_explorer_data - RECEIVE = "Receive Addresses" - CHANGE = "Change Addresses" - button_data = [RECEIVE, CHANGE] - wallet_descriptor_display_name = None if "wallet_descriptor" in data: wallet_descriptor_display_name = data["wallet_descriptor"].brief_policy.replace(" (sorted)", "") @@ -540,7 +546,7 @@ class ToolsAddressExplorerAddressTypeView(View): selected_menu_num = self.run_screen( ToolsAddressExplorerAddressTypeScreen, - button_data=button_data, + button_data=self.button_data, fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, wallet_descriptor_display_name=wallet_descriptor_display_name, script_type=script_type, @@ -550,8 +556,8 @@ class ToolsAddressExplorerAddressTypeView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] in [RECEIVE, CHANGE]: - return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == CHANGE)) + elif self.button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.button_data[selected_menu_num] == self.CHANGE)) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 4e7e5db9..495d5c05 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -56,7 +56,6 @@ class View: self.canvas_width = self.renderer.canvas_width self.canvas_height = self.renderer.canvas_height - self.buttons = self.controller.buttons self.screen = None @@ -100,20 +99,22 @@ class Destination: return out - def run(self, run_before: Callable = None): + def _instantiate_view(self): if not self.view_args: # Can't unpack (**) None so we replace with an empty dict self.view_args = {} # Instantiate the `View_cls` with the `view_args` dict - view = self.View_cls(**self.view_args) + self.view = self.View_cls(**self.view_args) + - # Enable test suite modifications to the View before running it - if run_before: - run_before(view) + def _run_view(self): + return self.view.run() - # and run() it - return view.run() + + def run(self): + self._instantiate_view() + return self._run_view() def __eq__(self, obj): @@ -136,23 +137,22 @@ class Destination: # ######################################################################################### class MainMenuView(View): - def run(self): - from .seed_views import SeedsMenuView - from .settings_views import SettingsMenuView - from .scan_views import ScanView - from .tools_views import ToolsMenuView - menu_items = [ - (("Scan", FontAwesomeIconConstants.QRCODE), ScanView), - (("Seeds", FontAwesomeIconConstants.KEY), SeedsMenuView), - (("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH), ToolsMenuView), - (("Settings", FontAwesomeIconConstants.GEAR), SettingsMenuView), - ] + SCAN = ("Scan", FontAwesomeIconConstants.QRCODE) + SEEDS = ("Seeds", FontAwesomeIconConstants.KEY) + TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH) + SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR) + def __init__(self): + super().__init__() + self.button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] + + + def run(self): selected_menu_num = self.run_screen( LargeButtonScreen, title="Home", title_font_size=26, - button_data=[entry[0] for entry in menu_items], + button_data=self.button_data, show_back_button=False, show_power_button=True, ) @@ -160,7 +160,21 @@ class MainMenuView(View): if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) - return Destination(menu_items[selected_menu_num][1]) + if self.button_data[selected_menu_num] == self.SCAN: + from .scan_views import ScanView + return Destination(ScanView) + + elif self.button_data[selected_menu_num] == self.SEEDS: + from .seed_views import SeedsMenuView + return Destination(SeedsMenuView) + + elif self.button_data[selected_menu_num] == self.TOOLS: + from .tools_views import ToolsMenuView + return Destination(ToolsMenuView) + + elif self.button_data[selected_menu_num] == self.SETTINGS: + from .settings_views import SettingsMenuView + return Destination(SettingsMenuView) diff --git a/tests/base.py b/tests/base.py index 0010cef7..48e97738 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,7 +1,7 @@ import sys from dataclasses import dataclass from mock import MagicMock, patch -from typing import Callable, List, Type, Union +from typing import Callable, List, Tuple, Type, Union # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. @@ -37,6 +37,7 @@ class BaseTest: def reset_settings(cls): """ Wipe and re-initialize the Settings singleton """ Settings._instance = None + BaseTest.remove_settings() @classmethod @@ -71,10 +72,22 @@ class BaseTest: @dataclass class FlowStep: - """ Trivial helper class to express FlowTest sequences below """ + """ + Trivial helper class to express FlowTest sequences below. + + * expected_view: verify that the next step in the sequence ended up at the right View. + * run_before: function that takes a View instance as an arg and modifies it before running the View. + * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. + * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. + """ expected_view: Type[View] = None run_before: Callable = None - screen_return_value: Union[int, str] = None + screen_return_value: Union[int,str] = None + button_data_selection: Union[str,Tuple] = None + + def __post_init__(self): + if self.screen_return_value is not None and self.button_data_selection is not None: + raise Exception("Should only specify screen_return_value or button_data_selection") @@ -87,6 +100,7 @@ class FlowTest(BaseTest): * verifies that we landed on the expected_view (if provided). * mocks out the `View.run_screen()` to prevent the associated Screen class from instantiating. * patches in the FlowStep's screen_return_value (as if it came from user interaction). + * OR retrieves the index number of the specified button_data_selection and provides that as the Screen return value. * optional `run_before` method modifies the View when necessary to be compatible w/test suite limitations. * Runs the View and receives the resulting next Destination. * then repeats the process on the next Destination until the sequence is complete. @@ -103,10 +117,18 @@ class FlowTest(BaseTest): # Patch the `View.run_screen()` so we don't actually instantiate the Screen qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) with patch(qualname + ".run_screen") as mock_run_screen: - mock_run_screen.return_value = flow_step.screen_return_value + next_destination._instantiate_view() + + if flow_step.run_before: + flow_step.run_before(next_destination.view) + + if flow_step.button_data_selection: + mock_run_screen.return_value = next_destination.view.button_data.index(flow_step.button_data_selection) + else: + mock_run_screen.return_value = flow_step.screen_return_value # Now we can run the View and grab its resulting next Destination - next_destination = next_destination.run(run_before=flow_step.run_before) + next_destination = next_destination._run_view() except Exception as e: import traceback traceback.print_exc() diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 942c9710..17303cf9 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -21,16 +21,16 @@ class TestSeedFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=0, # ret SCAN + button_data_selection=MainMenuView.SCAN ), FlowStep( expected_view=scan_views.ScanView, run_before=load_seed_into_decoder, # simulate read SeedQR - screen_return_value=None, + # ret value is ignored ), FlowStep( expected_view=seed_views.SeedFinalizeView, - screen_return_value=0, # ret DONE + button_data_selection=seed_views.SeedFinalizeView.FINALIZE ), ] ) @@ -45,7 +45,7 @@ class TestSeedFlows(FlowTest): def test_with_mnemonic(mnemonic): sequence = [ FlowStep( - screen_return_value=1, # ret SEEDS + button_data_selection=MainMenuView.SEEDS ), FlowStep( expected_view=seed_views.SeedsMenuView, @@ -53,7 +53,7 @@ class TestSeedFlows(FlowTest): ), FlowStep( expected_view=seed_views.LoadSeedView, - screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, ), ] @@ -70,7 +70,7 @@ class TestSeedFlows(FlowTest): sequence.append( FlowStep( expected_view=seed_views.SeedFinalizeView, - screen_return_value=0, # ret DONE + button_data_selection=seed_views.SeedFinalizeView.FINALIZE ) ) @@ -94,7 +94,7 @@ class TestSeedFlows(FlowTest): mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() sequence = [ FlowStep( - screen_return_value=1 if len(mnemonic) == 12 else 2, # ret TYPE_12WORD or TYPE_24WORD + button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, ), ] for word in mnemonic[:-1]: @@ -112,7 +112,7 @@ class TestSeedFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedMnemonicInvalidView, - screen_return_value=0 # ret EDIT + button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT ), ] @@ -132,7 +132,7 @@ class TestSeedFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedMnemonicInvalidView, - screen_return_value=1 # ret DISCARD; this time we give up + button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD ), ] diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 33c8e7a9..fd373ff1 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -17,30 +17,31 @@ class TestSettingsFlows(FlowTest): def test_persistent_settings(self): """ Basic flow from MainMenuView to enable/disable persistent settings """ # Which option are we testing? - settings_entries = SettingsDefinition.get_settings_entries() - nth_entry = [s.attr_name for s in settings_entries].index(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) def mock_settingsmenuview_screen(view: Type[View]): # SettingsMenuView reaches into its `self.screen` so we need to mock it out view.screen = MagicMock() - + destination = self.run_sequence( Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret persistent settings + button_data_selection=settings_entry.display_name ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=[s[0] for s in settings_entry.selection_options].index(SettingsConstants.OPTION__ENABLED), + button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED), ), + FlowStep( + expected_view=settings_views.SettingsEntryUpdateSelectionView, + screen_return_value=RET_CODE__BACK_BUTTON + ) ] ) assert destination.View_cls == settings_views.SettingsMenuView @@ -50,41 +51,32 @@ class TestSettingsFlows(FlowTest): """ Multiselect Settings options should stay in-place; requires BACK to exit. """ # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - nth_entry = SettingsDefinition.get_settings_entries().index(settings_entry) def mock_settingsmenuview_screen(view: Type[View]): # SettingsMenuView reaches into its `self.screen` so we need to mock it out view.screen = MagicMock() - def disable_recursion(view: Type[View]): - # The recursion in SettingsEntryUpdateSelectionView for multiselect settings - # won't work with our FlowTest sequence structure. - view.is_test = True - destination = self.run_sequence( Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret Coordinator software + button_data_selection=settings_entry.display_name ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - run_before=disable_recursion, screen_return_value=0, # select/deselect first option ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - run_before=disable_recursion, screen_return_value=1, # select/deselect second option ), FlowStep( expected_view=settings_views.SettingsEntryUpdateSelectionView, - run_before=disable_recursion, screen_return_value=1, # select/deselect second option ), FlowStep( @@ -98,9 +90,6 @@ class TestSettingsFlows(FlowTest): def test_io_test(self): """ Basic flow from MainMenuView to I/O Test View """ - # I/O Test will be the nth_entry after we include the submenu to "Advanced" - nth_entry = len(SettingsDefinition.get_settings_entries()) + 1 - def mock_settingsmenuview_screen(view: Type[View]): # SettingsMenuView reaches into its `self.screen` so we need to mock it out view.screen = MagicMock() @@ -109,16 +98,16 @@ class TestSettingsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS, ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret I/O Test + button_data_selection=settings_views.SettingsMenuView.IO_TEST ), FlowStep( expected_view=settings_views.IOTestView, - screen_return_value=None, # ret value is ignored + # ret value is ignored ), ] ) @@ -140,16 +129,16 @@ class TestSettingsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=3, # ret SETTINGS + button_data_selection=MainMenuView.SETTINGS, ), FlowStep( expected_view=settings_views.SettingsMenuView, run_before=mock_settingsmenuview_screen, - screen_return_value=nth_entry, # ret Donate + button_data_selection=settings_views.SettingsMenuView.DONATE ), FlowStep( expected_view=settings_views.DonateView, - screen_return_value=None, # ret value is ignored + # ret value is ignored ), ] ) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 4a834f2c..39f07961 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -2,11 +2,12 @@ # Must import this before the Controller from typing import Type -from mock import MagicMock +from mock import MagicMock, Mock from base import FlowTest, FlowStep from seedsigner.controller import Controller from seedsigner.models.seed import Seed +from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views.view import Destination, MainMenuView, View from seedsigner.views import scan_views, seed_views, tools_views @@ -31,11 +32,11 @@ class TestToolsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=2 # ret TOOLS + button_data_selection=MainMenuView.TOOLS ), FlowStep( expected_view=tools_views.ToolsMenuView, - screen_return_value=3 # ret EXPLORER + button_data_selection=tools_views.ToolsMenuView.EXPLORER ), FlowStep( expected_view=tools_views.ToolsAddressExplorerSelectSourceView, @@ -43,11 +44,11 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedExportXpubScriptTypeView, - screen_return_value=0 # ret NATIVE_SEGWIT (1st script type option) + button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT) ), FlowStep( expected_view=tools_views.ToolsAddressExplorerAddressTypeView, - screen_return_value=0 # ret RECEIVE addrs + button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE ), FlowStep( expected_view=tools_views.ToolsAddressExplorerAddressListView, @@ -83,24 +84,23 @@ class TestToolsFlows(FlowTest): Destination(MainMenuView), sequence=[ FlowStep( - screen_return_value=2 # ret TOOLS + button_data_selection=MainMenuView.TOOLS ), FlowStep( expected_view=tools_views.ToolsMenuView, - screen_return_value=3 # ret EXPLORER + button_data_selection=tools_views.ToolsMenuView.EXPLORER ), FlowStep( expected_view=tools_views.ToolsAddressExplorerSelectSourceView, - screen_return_value=0 # ret SCAN SeedQR + button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED ), FlowStep( expected_view=scan_views.ScanView, run_before=load_seed_into_decoder, # simulate read SeedQR - screen_return_value=None, ), FlowStep( expected_view=seed_views.SeedFinalizeView, - screen_return_value=0, # ret DONE + button_data_selection=seed_views.SeedFinalizeView.FINALIZE ), FlowStep( expected_view=seed_views.SeedOptionsView, @@ -121,7 +121,7 @@ class TestToolsFlows(FlowTest): Destination(seed_views.SeedFinalizeView), sequence=[ FlowStep( - screen_return_value=1, # ret PASSPHRASE + button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE ), FlowStep( expected_view=seed_views.SeedAddPassphraseView, @@ -129,7 +129,7 @@ class TestToolsFlows(FlowTest): ), FlowStep( expected_view=seed_views.SeedReviewPassphraseView, - screen_return_value=1, # ret DONE + button_data_selection=seed_views.SeedReviewPassphraseView.DONE ), FlowStep( expected_view=seed_views.SeedOptionsView, diff --git a/tests/test_settings.py b/tests/test_settings.py new file mode 100644 index 00000000..891cce1d --- /dev/null +++ b/tests/test_settings.py @@ -0,0 +1,16 @@ +from base import BaseTest +from seedsigner.models.settings import Settings +from seedsigner.models.settings_definition import SettingsConstants + + +class TestSettings(BaseTest): + + def test_reset_settings(self): + """ BaseTest.reset_settings() should wipe out any previous Settings changes """ + settings = Settings.get_instance() + settings.set_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS, SettingsConstants.OPTION__ENABLED) + assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__ENABLED + + BaseTest.reset_settings() + settings = Settings.get_instance() + assert settings.get_value(SettingsConstants.SETTING__PERSISTENT_SETTINGS) == SettingsConstants.OPTION__DISABLED From f474a3e8dc5c2ed899d456ad6fc6da31bdc74b22 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 12:17:19 -0500 Subject: [PATCH 12/67] Update manual_installation.md --- docs/manual_installation.md | 46 +++++++++++++++++++++++++++++-------- 1 file changed, 37 insertions(+), 9 deletions(-) diff --git a/docs/manual_installation.md b/docs/manual_installation.md index 236d77c4..bec76f2c 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -58,12 +58,40 @@ passwd You will be prompted to enter the current password ("raspberry") and then to enter a new password twice. In our prepared release image, the password used is `AirG@pped!`. +### Install python3.10 +```bash +# install compiler dependencies +sudo apt update && sudo apt install -y build-essential zlib1g-dev \ + libncurses5-dev libgdbm-dev libnss3-dev openssl libssl-dev \ + libreadline-dev libffi-dev wget + +# Grab the python3.10 source +wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz +tar -xzvf Python-3.10.10.tgz +cd Python-3.10.10 + +# configure takes ~6 minutes to check what is available +./configure --enable-optimizations + +# compiling takes ~80 minutes(!!) on the Pi Zero 1.3 +sudo make altinstall + +# cleanup +cd .. +rm -rf Python-3.10.10* + +# Make python3.10 the default version +sudo update-alternatives --install /usr/bin/python python /usr/local/bin/python3.10 1 +sudo update-alternatives --install /usr/bin/python3 python3 /usr/local/bin/python3.10 1 +``` + + ### Install dependencies Copy this entire box and run it as one command (will take 15-20min to complete): ``` -sudo apt-get update && sudo apt-get install -y wiringpi python3-pip \ - python3-numpy python-pil libopenjp2-7 git python3-opencv \ - python3-picamera libatlas-base-dev qrencode +sudo apt update && sudo apt install -y wiringpi python3-pip \ + python3-numpy python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \ + git python3-opencv python3-picamera libatlas-base-dev qrencode ``` ### Install `zbar` @@ -103,7 +131,7 @@ sudo rm -rf bcm2835-1.60 ### Set up `virtualenv` ``` -pip3 install virtualenvwrapper +python -m pip install virtualenvwrapper ``` Edit your bash profile with the command `nano ~/.profile` and add the following to the end: @@ -117,7 +145,7 @@ Then `CTRL-X` and `y` to exit and save changes. Now create the python virtualenv for SeedSigner with these two commands: ``` source ~/.profile -mkvirtualenv --python=python3 seedsigner-env +mkvirtualenv seedsigner-env ``` For convenience you can configure your `.profile` to auto-activate the SeedSigner virtualenv when you ssh in. Once again `nano ~/.profile` and add at the end: @@ -166,16 +194,16 @@ where `pr_123` is any name you want to give to the new branch in your local repo ### Install Python `pip` dependencies: -``` -pip3 install -r requirements.txt +```bash +pip install -r requirements.txt ``` #### `pyzbar` -Note: The `requirements.txt` installs a fork of the python `pyzbar` repo (for now pointing to the fork in Keith's `kdmukai` github account [https://github.com/kdmukai/pyzbar](https://github.com/kdmukai/pyzbar)). +Note: The `requirements.txt` installs a fork of the python `pyzbar` repo. The fork is required because the main `pyzbar` repo has been abandoned. This [github issue](https://github.com/NaturalHistoryMuseum/pyzbar/issues/124#issuecomment-971967091) discusses the changes needed in order to support reading binary data from `zbar`, which is required for our `CompactSeedQR` format which writes byte data instead of strings. The changes specifically reference the following PRs which have already been merged into Keith's fork: * [PR 76](https://github.com/NaturalHistoryMuseum/pyzbar/pull/76/files): enables scanning to continue even when a null byte (`x\00`) is found. -* [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in Keith's fork. +* [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in our fork. ### Configure `systemd` to run SeedSigner at boot: ``` From 1ff844062df1b315f984b27781eba8fb49371501 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 12:59:12 -0500 Subject: [PATCH 13/67] Cleanup, beginning to split requirements.txt --- requirements-raspi.txt | 3 +++ requirements.txt | 3 --- src/seedsigner/views/seed_views.py | 2 +- tests/base.py | 10 +++++++--- tests/test_flows_settings.py | 26 +------------------------- 5 files changed, 12 insertions(+), 32 deletions(-) create mode 100644 requirements-raspi.txt diff --git a/requirements-raspi.txt b/requirements-raspi.txt new file mode 100644 index 00000000..8053eeab --- /dev/null +++ b/requirements-raspi.txt @@ -0,0 +1,3 @@ +picamera==1.13 +RPi.GPIO==0.7.0 +spidev==3.5 \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index 34cf5663..e24650d4 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,10 +1,7 @@ embit==0.7.0 numpy==1.21.1 -picamera==1.13 Pillow==8.2.0 -e git+https://github.com/seedsigner/pyzbar.git@c3c237821c6a20b17953efe59b90df0b514a1c03#egg=pyzbar qrcode==7.3.1 -RPi.GPIO==0.7.0 six==1.16.0 -spidev==3.5 urtypes @ git+https://github.com/selfcustody/urtypes.git@7fb280eab3b3563dfc57d2733b0bf5cbc0a96a6a diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 9e45a1f7..f796a93f 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -270,7 +270,7 @@ class SeedReviewPassphraseView(View): fingerprint_without = self.seed.get_fingerprint(network=network) self.seed.set_passphrase(passphrase) - # Because we have ane explicit "Edit" button, we disable "BACK" to keep the + # Because we have an explicit "Edit" button, we disable "BACK" to keep the # routing options sane. selected_menu_num = self.run_screen( seed_screens.SeedReviewPassphraseScreen, diff --git a/tests/base.py b/tests/base.py index 48e97738..568f8d30 100644 --- a/tests/base.py +++ b/tests/base.py @@ -119,14 +119,18 @@ class FlowTest(BaseTest): with patch(qualname + ".run_screen") as mock_run_screen: next_destination._instantiate_view() - if flow_step.run_before: - flow_step.run_before(next_destination.view) - + # A few Views need to reference their Screen; need to mock it out + # since we're not actually instantiating it. + next_destination.view.screen = MagicMock() + if flow_step.button_data_selection: mock_run_screen.return_value = next_destination.view.button_data.index(flow_step.button_data_selection) else: mock_run_screen.return_value = flow_step.screen_return_value + if flow_step.run_before: + flow_step.run_before(next_destination.view) + # Now we can run the View and grab its resulting next Destination next_destination = next_destination._run_view() except Exception as e: diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index fd373ff1..8c68d36a 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -19,10 +19,6 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -31,7 +27,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_entry.display_name ), FlowStep( @@ -52,10 +47,6 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -64,7 +55,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_entry.display_name ), FlowStep( @@ -90,10 +80,6 @@ class TestSettingsFlows(FlowTest): def test_io_test(self): """ Basic flow from MainMenuView to I/O Test View """ - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -102,7 +88,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_views.SettingsMenuView.IO_TEST ), FlowStep( @@ -116,15 +101,7 @@ class TestSettingsFlows(FlowTest): def test_donate(self): - """ Basic flow from MainMenuView to Donate View """ - # DonateView will be the nth_entry after we include the submenu to "Advanced" - # and the I/O Test - nth_entry = len(SettingsDefinition.get_settings_entries()) + 2 - - def mock_settingsmenuview_screen(view: Type[View]): - # SettingsMenuView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - + """ Basic flow from MainMenuView to Donate View """ destination = self.run_sequence( Destination(MainMenuView), sequence=[ @@ -133,7 +110,6 @@ class TestSettingsFlows(FlowTest): ), FlowStep( expected_view=settings_views.SettingsMenuView, - run_before=mock_settingsmenuview_screen, button_data_selection=settings_views.SettingsMenuView.DONATE ), FlowStep( From 320a7990cdb06579050a23fe201cfe2790985994 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 13:07:46 -0500 Subject: [PATCH 14/67] Documentation update for splitting dependencies --- docs/manual_installation.md | 1 + tests/README.md | 10 ++++++++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/docs/manual_installation.md b/docs/manual_installation.md index bec76f2c..9f253cf0 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -196,6 +196,7 @@ where `pr_123` is any name you want to give to the new branch in your local repo ### Install Python `pip` dependencies: ```bash pip install -r requirements.txt +pip install -r requirements-raspi.txt ``` #### `pyzbar` diff --git a/tests/README.md b/tests/README.md index ae8679c6..fe2181fe 100644 --- a/tests/README.md +++ b/tests/README.md @@ -1,7 +1,13 @@ # Running Tests -You must install the test-runner dependencies: -``` +The tests are designed to be run on non-Raspi hardware. + +On your testing machine you'll have to install: +```bash +# general dependencies +pip3 install -r requirements.txt + +# test suite dependencies pip3 install -r tests/requirements.txt ``` From 6f2f68368b426aca692e37599d2d2b84e253fa27 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 13:46:29 -0500 Subject: [PATCH 15/67] Removing Pillow 9.x change --- src/seedsigner/helpers/qr.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/helpers/qr.py b/src/seedsigner/helpers/qr.py index 333910b0..b986c6d7 100644 --- a/src/seedsigner/helpers/qr.py +++ b/src/seedsigner/helpers/qr.py @@ -48,6 +48,6 @@ class QR: # if qrencode fails, fall back to only encoder if rv != 0: return self.qrimage(data,width,height,border) - img = Image.open("/tmp/qrcode.png").resize((width,height), Image.Resampling.NEAREST).convert("RGBA") + img = Image.open("/tmp/qrcode.png").resize((width,height), Image.NEAREST).convert("RGBA") return img From a4ba719062ba0997d78e989981844407679d1215 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 12 Mar 2023 14:19:19 -0500 Subject: [PATCH 16/67] Add sqlite3 for `coverage` compatibility --- docs/manual_installation.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/manual_installation.md b/docs/manual_installation.md index bec76f2c..3fcdc6ba 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -61,9 +61,11 @@ You will be prompted to enter the current password ("raspberry") and then to ent ### Install python3.10 ```bash # install compiler dependencies +# openssl, libssl-dev: ssl support when pip fetches packages +# libsqlite3-dev: required by `coverage` sudo apt update && sudo apt install -y build-essential zlib1g-dev \ libncurses5-dev libgdbm-dev libnss3-dev openssl libssl-dev \ - libreadline-dev libffi-dev wget + libreadline-dev libffi-dev wget libsqlite3-dev # Grab the python3.10 source wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz From afdd3d1ffc544fb613383100d8c46d3c828fbecc Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Sat, 18 Mar 2023 22:33:12 -0400 Subject: [PATCH 17/67] taproot_bip32_derivations won't be populated for non-change p2tr addresses and therefor my_pubkey will be None. The additional indendation of script.p2tr(my_pubkey) will make it so it's only called when my_pubkey is not None. --- src/seedsigner/models/psbt_parser.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/models/psbt_parser.py b/src/seedsigner/models/psbt_parser.py index a76785f8..3a1374be 100644 --- a/src/seedsigner/models/psbt_parser.py +++ b/src/seedsigner/models/psbt_parser.py @@ -148,7 +148,7 @@ class PSBTParser(): leaf_hashes, derivation = list(out.taproot_bip32_derivations.values())[0] der = derivation.derivation my_pubkey = self.root.derive(der) - sc = script.p2tr(my_pubkey) + sc = script.p2tr(my_pubkey) if sc.data == self.psbt.tx.vout[i].script_pubkey.data: is_change = True From 27e64123814740fe0c3b67d5558901d2093cff76 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Sat, 18 Mar 2023 22:59:57 -0400 Subject: [PATCH 18/67] add unit test for p2tr spend with change for psbt_parser --- tests/test_psbt_parser.py | 33 +++++++++++++++++++++++++++++---- 1 file changed, 29 insertions(+), 4 deletions(-) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index 7fb84c60..6afe3d00 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -1,8 +1,33 @@ import pytest from mock import MagicMock -from seedsigner.models import PSBTParser +from seedsigner.models import PSBTParser, Seed from embit import psbt +from embit import bip32 +from embit import bip39 +from binascii import a2b_base64, b2a_base64 +from seedsigner.models.settings_definition import SettingsConstants -def test_sig_count(): - print("test") - \ No newline at end of file +def test_p2tr_change_detection(): + psbt_base64 = "cHNidP8BAIkCAAAAAf8upuiIWF1VTgC/Q8ZWRrameRigaXpRcQcBe8ye+TK3AQAAAAAXCgAAAs7BJqsAAAAAIlEgGKqNQ7yF4+yFrrscHnjrbEHiJFExhR903ze43FtOH3BwTgQTAAAAACJRINBe93RcrOYO4UVLLE0y8pzvblOKQWcoQ0obCey8nA5GAAAAAE8BBDWHzwNMUx9OgAAAAJdr+WtwWfVa6IPbpKZ4KgRC0clbm11Gl155IPA27n2FAvQCrFGH6Ac2U0Gcy1IH5f5ltgUBDz2+fe8iqL6JzZdgEDlK7RRWAACAAQAAgAAAAIAAAQB9AgAAAAGAKOOUFIzw9pbRDaZ7F0DYhLImrdMn//OSm++ff5VNdAAAAAAAAQAAAAKsjLwAAAAAABYAFKEcuxvXmB3rWHSqSviP5mrKMZoL2RArvgAAAAAiUSBGU0Lg5fx/ECsB1Z4ZUqXQFSLFnlmpm0rm5R2l599h2AAAAAABASvZECu+AAAAACJRIEZTQuDl/H8QKwHVnhlSpdAVIsWeWambSublHaXn32HYAQMEAAAAACEWF7hZVn7pIDR429kAn/WDeQiWjZey1iGHztsL1H83QLMZADlK7RRWAACAAQAAgAAAAIABAAAAAAAAAAEXIBe4WVZ+6SA0eNvZAJ/1g3kIlo2XstYhh87bC9R/N0CzACEHbJdqWyMxF2eOPr6YRXUJmry04HUbgKyeM2IZeG+NI9AZADlK7RRWAACAAQAAgAAAAIABAAAAAQAAAAEFIGyXalsjMRdnjj6+mEV1CZq8tOB1G4CsnjNiGXhvjSPQAAA=" + + raw = a2b_base64(psbt_base64) + tx = psbt.PSBT.parse(raw) + + mnemonic = "goddess rough corn exclude cream trial fee trumpet million prevent gaze power".split() + pw = "" + seed = Seed(mnemonic, passphrase=pw) + + pp = PSBTParser(p=tx, seed=seed, network=SettingsConstants.REGTEST) + + assert pp.change_data == [ + { + 'output_index': 0, + 'address': 'bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30', + 'amount': 2871443918, + 'fingerprint': ['394aed14'], + 'derivation_path': ['m/86h/1h/0h/1/1']} + ] + assert pp.spend_amount == 319049328 + assert pp.change_amount == 2871443918 + assert pp.destination_addresses == ['bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek'] + assert pp.destination_amounts == [319049328] \ No newline at end of file From b46a0be92b72f924a41f37d7866e59e30da0681e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 30 Mar 2023 20:54:24 -0500 Subject: [PATCH 19/67] Flow-based tests now incorporate the Controller's main loop --- src/seedsigner/controller.py | 39 ++++++++- src/seedsigner/views/seed_views.py | 114 +++++++++++++------------ src/seedsigner/views/tools_views.py | 28 +++--- src/seedsigner/views/view.py | 21 +++-- tests/base.py | 120 ++++++++++++++++---------- tests/test_flows.py | 79 +++++++++++++++++ tests/test_flows_seed.py | 109 ++++++------------------ tests/test_flows_settings.py | 127 +++++++++------------------- tests/test_flows_tools.py | 124 +++++++-------------------- 9 files changed, 366 insertions(+), 395 deletions(-) create mode 100644 tests/test_flows.py diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 4976d6f7..44509227 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -32,6 +32,15 @@ class BackStack(List[Destination]): +class StopControllerCommand(Exception): + """ + This is a special exception that is raised by the test suite to stop the + Controller's main loop. It is not meant to be raised by any other code. + """ + pass + + + class Controller(Singleton): """ The Controller is a globally available singleton that maintains SeedSigner state. @@ -185,7 +194,13 @@ class Controller(Singleton): self.back_stack = BackStack() - def start(self) -> None: + def start(self, initial_destination: Destination = None) -> None: + """ + The main loop of the application. + + * initial_destination: The first View to run. If None, the MainMenuView is + used. Only used by the test suite. + """ from .views import MainMenuView, BackStackView from .views.screensaver import OpeningSplashScreen @@ -218,7 +233,10 @@ class Controller(Singleton): View_cls(**init_args).run() """ try: - next_destination = Destination(MainMenuView) + if initial_destination: + next_destination = initial_destination + else: + next_destination = Destination(MainMenuView) while True: # Destination(None) is a special case; render the Home screen if next_destination.View_cls is None: @@ -240,10 +258,27 @@ class Controller(Singleton): print(f"back_stack: {self.back_stack}") try: + # Instantiate the View class and run it print(f"Executing {next_destination}") next_destination = next_destination.run() + + except StopControllerCommand: + # This is a special exception that is only raised by the test suite + # to stop the Controller loop and exit the test. + print("StopControllerCommand received; ending Controller loop.") + return + + except AssertionError as e: + # Only raised by the test suite via FlowDidNotExpectView + print(f"Test suite caught {e.__class__.__name__}: {e}") + + # Re-raise so that the test suite can catch it + raise e + except Exception as e: # Display user-friendly error screen w/debugging info + import traceback + traceback.print_exc() next_destination = self.handle_exception(e) if not next_destination: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index f796a93f..0da13470 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -334,65 +334,66 @@ class SeedDiscardView(View): Views for actions on individual seeds: ****************************************************************************""" class SeedOptionsView(View): + SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE) + VERIFY_ADDRESS = "Verify Addr" + EXPORT_XPUB = "Export Xpub" + EXPLORER = "Address Explorer" + BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT) + BIP85_CHILD_SEED = "BIP-85 Child Seed" + DISCARD = ("Discard Seed", None, None, "red") + + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num self.seed = self.controller.get_seed(self.seed_num) + self.button_data = [] + + if self.controller.unverified_address: + addr = self.controller.unverified_address["address"][:7] + self.VERIFY_ADDRESS += f" {addr}" + self.button_data.append(self.VERIFY_ADDRESS) + + self.button_data.append(self.SCAN_PSBT) + + if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: + self.button_data.append(self.EXPORT_XPUB) + + self.button_data.append(self.EXPLORER) + self.button_data.append(self.BACKUP) + + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: + self.button_data.append(self.BIP85_CHILD_SEED) + + self.button_data.append(self.DISCARD) def run(self): from seedsigner.views.psbt_views import PSBTOverviewView - SCAN_PSBT = ("Scan PSBT", FontAwesomeIconConstants.QRCODE) - VERIFY_ADDRESS = "Verify Addr" - EXPORT_XPUB = "Export Xpub" - EXPLORER = "Address Explorer" - BACKUP = ("Backup Seed", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT) - BIP85_CHILD_SEED = "BIP-85 Child Seed" - DISCARD = ("Discard Seed", None, None, "red") - - button_data = [] - - if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: - # Jump straight back into the address explorer script type selection flow - # But do ont cancel the `resume_main_flow` as we'll still need that after - # derivation path is specified. - return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True) - if self.controller.unverified_address: if self.controller.resume_main_flow == Controller.FLOW__VERIFY_SINGLESIG_ADDR: # Jump straight back into the single sig addr verification flow self.controller.resume_main_flow = None return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num), skip_current_view=True) - addr = self.controller.unverified_address["address"][:7] - VERIFY_ADDRESS += f" {addr}" - button_data.append(VERIFY_ADDRESS) - + if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: + # Jump straight back into the address explorer script type selection flow + # But don't cancel the `resume_main_flow` as we'll still need that after + # derivation path is specified. + return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG), skip_current_view=True) + if self.controller.psbt: - if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): - if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: - # Re-route us directly back to the start of the PSBT flow - self.controller.resume_main_flow = None - self.controller.psbt_seed = self.seed - return Destination(PSBTOverviewView, skip_current_view=True) - - button_data.append(SCAN_PSBT) - - if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: - button_data.append(EXPORT_XPUB) - - button_data.append(EXPLORER) - button_data.append(BACKUP) - - if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: - button_data.append(BIP85_CHILD_SEED) - - button_data.append(DISCARD) + if PSBTParser.has_matching_input_fingerprint(self.controller.psbt, self.seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): + if self.controller.resume_main_flow and self.controller.resume_main_flow == Controller.FLOW__PSBT: + # Re-route us directly back to the start of the PSBT flow + self.controller.resume_main_flow = None + self.controller.psbt_seed = self.seed + return Destination(PSBTOverviewView, skip_current_view=True) selected_menu_num = self.run_screen( seed_screens.SeedOptionsScreen, - button_data=button_data, + button_data=self.button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, ) @@ -401,33 +402,37 @@ class SeedOptionsView(View): # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if button_data[selected_menu_num] == SCAN_PSBT: + if self.button_data[selected_menu_num] == self.SCAN_PSBT: from seedsigner.views.scan_views import ScanView self.controller.psbt_seed = self.controller.get_seed(self.seed_num) return Destination(ScanView) - elif button_data[selected_menu_num] == VERIFY_ADDRESS: + elif self.button_data[selected_menu_num] == self.VERIFY_ADDRESS: return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == EXPORT_XPUB: + elif self.button_data[selected_menu_num] == self.EXPORT_XPUB: return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == EXPLORER: + elif self.button_data[selected_menu_num] == self.EXPLORER: self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG)) - elif button_data[selected_menu_num] == BACKUP: + elif self.button_data[selected_menu_num] == self.BACKUP: return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) - elif button_data[selected_menu_num] == BIP85_CHILD_SEED: + elif self.button_data[selected_menu_num] == self.BIP85_CHILD_SEED: return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_num] == DISCARD: + elif self.button_data[selected_menu_num] == self.DISCARD: return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num)) class SeedBackupView(View): + VIEW_WORDS = "View Seed Words" + EXPORT_SEEDQR = "Export as SeedQR" + button_data = [VIEW_WORDS, EXPORT_SEEDQR] + def __init__(self, seed_num): super().__init__() self.seed_num = seed_num @@ -435,23 +440,20 @@ class SeedBackupView(View): def run(self): - VIEW_WORDS = "View Seed Words" - EXPORT_SEEDQR = "Export as SeedQR" - button_data = [VIEW_WORDS, EXPORT_SEEDQR] - - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Backup Seed", - button_data=button_data, + button_data=self.button_data, is_bottom_list=True, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == VIEW_WORDS: + elif self.button_data[selected_menu_num] == self.VIEW_WORDS: return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) - elif button_data[selected_menu_num] == EXPORT_SEEDQR: + elif self.button_data[selected_menu_num] == self.EXPORT_SEEDQR: return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 572b996a..b453bebc 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -27,14 +27,9 @@ class ToolsMenuView(View): DICE = ("New seed", FontAwesomeIconConstants.DICE) KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" - - def __init__(self): - super().__init__() - self.button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] - + button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] def run(self): - selected_menu_num = self.run_screen( ButtonListScreen, title="Tools", @@ -236,28 +231,29 @@ class ToolsDiceEntropyEntryView(View): Calc final word Views ****************************************************************************""" class ToolsCalcFinalWordNumWordsView(View): - def run(self): - TWELVE = "12 words" - TWENTY_FOUR = "24 words" - - button_data = [TWELVE, TWENTY_FOUR] - selected_menu_num = ButtonListScreen( + TWELVE = "12 words" + TWENTY_FOUR = "24 words" + button_data = [TWELVE, TWENTY_FOUR] + + def run(self): + selected_menu_num = self.run_screen( + ButtonListScreen, title="Mnemonic Length", is_bottom_list=True, is_button_text_centered=True, - button_data=button_data, - ).display() + button_data=self.button_data, + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == TWELVE: + elif self.button_data[selected_menu_num] == self.TWELVE: self.controller.storage.init_pending_mnemonic(12) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - elif button_data[selected_menu_num] == TWENTY_FOUR: + elif self.button_data[selected_menu_num] == self.TWENTY_FOUR: self.controller.storage.init_pending_mnemonic(24) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 495d5c05..eb638158 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -141,10 +141,7 @@ class MainMenuView(View): SEEDS = ("Seeds", FontAwesomeIconConstants.KEY) TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH) SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR) - - def __init__(self): - super().__init__() - self.button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] + button_data = [SCAN, SEEDS, TOOLS, SETTINGS] def run(self): @@ -179,23 +176,25 @@ class MainMenuView(View): class PowerOptionsView(View): + RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) + POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) + def run(self): - RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) - POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) - button_data = [RESET, POWER_OFF] - selected_menu_num = LargeButtonScreen( + button_data = [PowerOptionsView.RESET, PowerOptionsView.POWER_OFF] + selected_menu_num = self.run_screen( + LargeButtonScreen, title="Reset / Power", show_back_button=True, button_data=button_data - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == RESET: + elif button_data[selected_menu_num] == PowerOptionsView.RESET: return Destination(RestartView) - elif button_data[selected_menu_num] == POWER_OFF: + elif button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: return Destination(PowerOffView) diff --git a/tests/base.py b/tests/base.py index 568f8d30..be45ac97 100644 --- a/tests/base.py +++ b/tests/base.py @@ -7,13 +7,14 @@ from typing import Callable, List, Tuple, Type, Union # These must precede any SeedSigner imports. sys.modules['seedsigner.gui.renderer'] = MagicMock() sys.modules['seedsigner.gui.screens.screensaver'] = MagicMock() +sys.modules['seedsigner.views.screensaver'] = MagicMock() sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() -from seedsigner.controller import Controller +from seedsigner.controller import Controller, StopControllerCommand from seedsigner.models import Settings -from seedsigner.views.view import Destination, View +from seedsigner.views.view import Destination, MainMenuView, View @@ -45,7 +46,8 @@ class BaseTest: """ If settings were written to disk, delete """ import os try: - os.remove(Settings.SETTINGS_FILENAME) + if os.path.exists(Settings.SETTINGS_FILENAME): + os.remove(Settings.SETTINGS_FILENAME) except: print(f"{Settings.SETTINGS_FILENAME} not found to be removed") @@ -75,68 +77,96 @@ class FlowStep: """ Trivial helper class to express FlowTest sequences below. - * expected_view: verify that the next step in the sequence ended up at the right View. + * expected_view: verify that the current step in the sequence instantiates the right View. * run_before: function that takes a View instance as an arg and modifies it before running the View. * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. """ - expected_view: Type[View] = None + expected_view: type[View] = None run_before: Callable = None screen_return_value: Union[int,str] = None button_data_selection: Union[str,Tuple] = None + is_redirect: bool = False def __post_init__(self): if self.screen_return_value is not None and self.button_data_selection is not None: - raise Exception("Should only specify screen_return_value or button_data_selection") + raise Exception("Can't specify both `screen_return_value` and `button_data_selection`") + + + +class FlowDidNotExpectView(AssertionError): + """ Raised when the FlowTest sequence does not match the View that was run. """ + pass class FlowTest(BaseTest): """ Base class for any tests that do flow-based testing """ - def run_sequence(self, initial_destination: Destination, sequence: List[FlowStep]) -> Destination: + def run_sequence(self, sequence: list[FlowStep], initial_destination_view_args: dict = None) -> None: """ - Runs the given sequence of FlowSteps starting from the initial_destination: - * verifies that we landed on the expected_view (if provided). - * mocks out the `View.run_screen()` to prevent the associated Screen class from instantiating. - * patches in the FlowStep's screen_return_value (as if it came from user interaction). - * OR retrieves the index number of the specified button_data_selection and provides that as the Screen return value. - * optional `run_before` method modifies the View when necessary to be compatible w/test suite limitations. - * Runs the View and receives the resulting next Destination. - * then repeats the process on the next Destination until the sequence is complete. - - Returns the final Destination. + Run a pre-set sequence of Views w/manually-specified return values in order to test + the Controller's flow control logic and the routing from View to View. """ - next_destination = initial_destination - for flow_step in sequence: - try: - # Validate that the next Destination's View is what we expected - if flow_step.expected_view: - assert next_destination.View_cls == flow_step.expected_view + def verify_next_View_cls_and_run_view(destination: Destination, *args, **kwargs): + if len(sequence) == 0: + # We've reached the end of the sequence, so raise StopControllerCommand + # to stop the Controller and exit the test. + raise StopControllerCommand() - # Patch the `View.run_screen()` so we don't actually instantiate the Screen - qualname = ".".join([next_destination.View_cls.__module__, next_destination.View_cls.__name__]) - with patch(qualname + ".run_screen") as mock_run_screen: - next_destination._instantiate_view() + # Verify that the View class specified in the test sequence matches the + # View class that is being run. + if destination.View_cls != sequence[0].expected_view: + raise FlowDidNotExpectView(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") - # A few Views need to reference their Screen; need to mock it out - # since we're not actually instantiating it. - next_destination.view.screen = MagicMock() + # Run the optional pre-run function to modify the View. + if sequence[0].run_before: + sequence[0].run_before(destination.view) - if flow_step.button_data_selection: - mock_run_screen.return_value = next_destination.view.button_data.index(flow_step.button_data_selection) - else: - mock_run_screen.return_value = flow_step.screen_return_value + if sequence[0].is_redirect: + # The current View is going to auto-redirect without calling run_screen(), + # so we need to remove the current step from the sequence before the + # View.run() call below. + sequence.pop(0) + + # Some Views reach into their Screen's variables directly (e.g. + # Screen.buttons to preserve the scroll position), so we need to mock out the + # Screen instance that is created by the View. + destination.view.screen = MagicMock() + + # Run the View (we're mocking out View._run_view() so the Destination + # won't actually run the View unless we explicitly do so here). + return destination.view.run() + + + def next_return_value(view: View, *args, **kwargs): + # Return the return value specified in the test sequence and + # remove the completed test step from the sequence. + flow_step = sequence.pop(0) + if flow_step.button_data_selection: + return view.button_data.index(flow_step.button_data_selection) + elif type(flow_step.screen_return_value) == StopControllerCommand: + raise flow_step.screen_return_value + return flow_step.screen_return_value + + + with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_run_view: + # Mock out the View._run_view() method so we can verify the View class + # that is specified in the test sequence and then run the View. + mock_run_view.side_effect = verify_next_View_cls_and_run_view + + with patch("seedsigner.views.view.View.run_screen", autospec=True) as mock_run_screen: + # Mock out the View.run_screen() method so we can provide the + # return value that is specified in the test sequence. + mock_run_screen.side_effect = next_return_value + + # Start the Controller with the first View_cls specified in the test sequence + if sequence[0].expected_view != MainMenuView: + initial_destination = Destination(sequence[0].expected_view, view_args=initial_destination_view_args) + else: + initial_destination = None + + # Start the Controller and run the sequence + Controller.get_instance().start(initial_destination=initial_destination) - if flow_step.run_before: - flow_step.run_before(next_destination.view) - # Now we can run the View and grab its resulting next Destination - next_destination = next_destination._run_view() - except Exception as e: - import traceback - traceback.print_exc() - print(f"{next_destination} | {flow_step}") - raise e - - return next_destination diff --git a/tests/test_flows.py b/tests/test_flows.py new file mode 100644 index 00000000..974b6368 --- /dev/null +++ b/tests/test_flows.py @@ -0,0 +1,79 @@ +import pytest +from mock import patch + +# Must import this before any seedsigner imports +from base import FlowTest, FlowStep, FlowDidNotExpectView +from seedsigner.controller import Controller, StopControllerCommand + +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON +from seedsigner.models.seed import Seed +from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView +from seedsigner.views.view import MainMenuView, PowerOptionsView +from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView + + + +class TestFlowTest(FlowTest): + + def test_simple_flow(self): + """ + Basic test to ensure the FlowTest can flow through a sequence of Views. + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE), + FlowStep(SeedMnemonicEntryView, screen_return_value=StopControllerCommand()), + ]) + + + def test_wrong_View_cls(self): + """ + Ensure that the FlowTest will raise an AssertionError if the next View in the + sequence is not the expected View. + """ + with pytest.raises(FlowDidNotExpectView): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), + FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. + ]) + + + def test_back_button_flow(self): + """ + Ensure that the FlowTest works correctly with the Controller and its BackStack. + """ + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + ]) + + + def test_initial_destination(self): + """ + Ensure that the FlowTest can start from a View other than MainMenuView. + """ + # Don't have to start at the MainMenuView; can jump straight in + self.run_sequence([ + FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=StopControllerCommand()), + ]) + + # And again, but this time with a View that requires input view_args + self.reset_controller() + + # Load a seed into the Controller + controller = Controller.get_instance() + seed = Seed(mnemonic=["abandon "* 11 + "about"]) + controller.storage.set_pending_seed(seed) + controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), + FlowStep(SeedBackupView, screen_return_value=StopControllerCommand()), + ] + ) + + diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 17303cf9..6d69a4ac 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,7 @@ # Must import this before the Controller from base import BaseTest, FlowTest, FlowStep +from seedsigner.controller import StopControllerCommand from seedsigner.views.view import Destination, MainMenuView from seedsigner.views import seed_views, scan_views @@ -17,24 +18,12 @@ class TestSeedFlows(FlowTest): def load_seed_into_decoder(view: scan_views.ScanView): view.decoder.add_data("0000" * 11 + "0003") - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SCAN - ), - FlowStep( - expected_view=scan_views.ScanView, - run_before=load_seed_into_decoder, # simulate read SeedQR - # ret value is ignored - ), - FlowStep( - expected_view=seed_views.SeedFinalizeView, - button_data_selection=seed_views.SeedFinalizeView.FINALIZE - ), - ] - ) - assert destination.View_cls == seed_views.SeedOptionsView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + ]) def test_mnemonic_entry_flow(self): @@ -44,41 +33,24 @@ class TestSeedFlows(FlowTest): """ def test_with_mnemonic(mnemonic): sequence = [ - FlowStep( - button_data_selection=MainMenuView.SEEDS - ), - FlowStep( - expected_view=seed_views.SeedsMenuView, - # Nothing to return; auto-forwards to LoadSeedView - ), - FlowStep( - expected_view=seed_views.LoadSeedView, - button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, - ), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD), ] # Now add each manual word entry step for word in mnemonic: sequence.append( - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value=word - ) + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word) ) # With the mnemonic completely entered, we land on the SeedFinalizeView - sequence.append( - FlowStep( - expected_view=seed_views.SeedFinalizeView, - button_data_selection=seed_views.SeedFinalizeView.FINALIZE - ) - ) - - destination = self.run_sequence( - Destination(MainMenuView), - sequence=sequence - ) - assert destination.View_cls == seed_views.SeedOptionsView + sequence += [ + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + ] + + self.run_sequence(sequence) # Test data from iancoleman.io; 12- and 24-word mnemonic test_with_mnemonic("tone flat shed cool census soul paddle boy flight fantasy stem social".split()) @@ -93,51 +65,26 @@ class TestSeedFlows(FlowTest): # Test data from iancoleman.io mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() sequence = [ - FlowStep( - button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD, - ), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView, button_data_selection=seed_views.LoadSeedView.TYPE_12WORD if len(mnemonic) == 12 else seed_views.LoadSeedView.TYPE_24WORD), ] for word in mnemonic[:-1]: - sequence.append( - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value=word - ) - ) + sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)) sequence += [ - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value="zoo" # But finish with an INVALID checksum word - ), - FlowStep( - expected_view=seed_views.SeedMnemonicInvalidView, - button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT - ), + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zoo"), # But finish with an INVALID checksum word + FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.EDIT), ] # Restarts from first word for word in mnemonic[:-1]: - sequence.append( - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value=word - ) - ) + sequence.append(FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word)) sequence += [ - FlowStep( - expected_view=seed_views.SeedMnemonicEntryView, - screen_return_value="zebra" # provide yet another invalid checksum word - ), - FlowStep( - expected_view=seed_views.SeedMnemonicInvalidView, - button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD - ), + FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word + FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), ] - destination = self.run_sequence( - Destination(seed_views.LoadSeedView), - sequence - ) - assert destination.View_cls == MainMenuView \ No newline at end of file + self.run_sequence(sequence) diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 8c68d36a..9603453f 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,13 +1,14 @@ -from mock import MagicMock -from typing import Type +import os # Must import this before any seedsigner imports from base import FlowTest, FlowStep +from seedsigner.controller import StopControllerCommand from seedsigner.models import SettingsDefinition +from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON -from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views.view import MainMenuView from seedsigner.views import settings_views @@ -19,27 +20,19 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__PERSISTENT_SETTINGS) - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_entry.display_name - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED), - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=RET_CODE__BACK_BUTTON - ) - ] - ) - assert destination.View_cls == settings_views.SettingsMenuView + # No settings file should exist before we enable persistent settings + assert os.path.exists(Settings.SETTINGS_FILENAME) == False + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) + + # Settings file should now exist + assert os.path.exists(Settings.SETTINGS_FILENAME) == True def test_multiselect(self): @@ -47,76 +40,32 @@ class TestSettingsFlows(FlowTest): # Which option are we testing? settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__COORDINATORS) - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_entry.display_name - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=0, # select/deselect first option - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=1, # select/deselect second option - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=1, # select/deselect second option - ), - FlowStep( - expected_view=settings_views.SettingsEntryUpdateSelectionView, - screen_return_value=RET_CODE__BACK_BUTTON, # BACK to exit - ), - ] - ) - assert destination.View_cls == settings_views.SettingsMenuView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=0), # select/deselect first option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option + FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) def test_io_test(self): """ Basic flow from MainMenuView to I/O Test View """ - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS, - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_views.SettingsMenuView.IO_TEST - ), - FlowStep( - expected_view=settings_views.IOTestView, - # ret value is ignored - ), - ] - ) - # Exiting IOTestView should return us to the main SettingsMenuView - assert destination.View_cls == settings_views.SettingsMenuView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), + FlowStep(settings_views.IOTestView, screen_return_value=None), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) def test_donate(self): """ Basic flow from MainMenuView to Donate View """ - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.SETTINGS, - ), - FlowStep( - expected_view=settings_views.SettingsMenuView, - button_data_selection=settings_views.SettingsMenuView.DONATE - ), - FlowStep( - expected_view=settings_views.DonateView, - # ret value is ignored - ), - ] - ) - # Exiting IOTestView should return us to the main SettingsMenuView - assert destination.View_cls == settings_views.SettingsMenuView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), + FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), + FlowStep(settings_views.DonateView, screen_return_value=None), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 39f07961..5ef82e4b 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -2,13 +2,12 @@ # Must import this before the Controller from typing import Type -from mock import MagicMock, Mock from base import FlowTest, FlowStep -from seedsigner.controller import Controller +from seedsigner.controller import Controller, StopControllerCommand from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition -from seedsigner.views.view import Destination, MainMenuView, View +from seedsigner.views.view import Destination, MainMenuView from seedsigner.views import scan_views, seed_views, tools_views @@ -24,49 +23,17 @@ class TestToolsFlows(FlowTest): controller.storage.set_pending_seed(seed) controller.storage.finalize_pending_seed() - def mock_addrlistview_screen(view: Type[View]): - # ToolsAddressExplorerAddressListView reaches into its `self.screen` so we need to mock it out - view.screen = MagicMock() - - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.TOOLS - ), - FlowStep( - expected_view=tools_views.ToolsMenuView, - button_data_selection=tools_views.ToolsMenuView.EXPLORER - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerSelectSourceView, - screen_return_value=0 # ret 1st onboard seed - ), - FlowStep( - expected_view=seed_views.SeedExportXpubScriptTypeView, - button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT) - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressTypeView, - button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressListView, - screen_return_value=10 # ret NEXT page of addrs - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressListView, - run_before=mock_addrlistview_screen, - screen_return_value=4 # ret a specific addr from the list - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerAddressView, - # QRDisplayScreen runs until dismissed; no ret value - ), - ] - ) - # After dismissing QRDisplayScreen, should return to the addr list view - assert destination.View_cls == tools_views.ToolsAddressExplorerAddressListView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT)), + FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list + FlowStep(tools_views.ToolsAddressExplorerAddressView, screen_return_value=None), # QRDisplayScreen runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=StopControllerCommand()), + ]) def test_addressexplorer_loadseed_sideflow(self): @@ -80,36 +47,16 @@ class TestToolsFlows(FlowTest): view.decoder.add_data("0000" * 11 + "0003") # Finalize the new seed w/out passphrase - destination = self.run_sequence( - Destination(MainMenuView), - sequence=[ - FlowStep( - button_data_selection=MainMenuView.TOOLS - ), - FlowStep( - expected_view=tools_views.ToolsMenuView, - button_data_selection=tools_views.ToolsMenuView.EXPLORER - ), - FlowStep( - expected_view=tools_views.ToolsAddressExplorerSelectSourceView, - button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED - ), - FlowStep( - expected_view=scan_views.ScanView, - run_before=load_seed_into_decoder, # simulate read SeedQR - ), - FlowStep( - expected_view=seed_views.SeedFinalizeView, - button_data_selection=seed_views.SeedFinalizeView.FINALIZE - ), - FlowStep( - expected_view=seed_views.SeedOptionsView, - # should auto-route away w/out a selection - ), - ] - ) - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), + FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), + FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), + ]) + assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER # Reset @@ -117,25 +64,12 @@ class TestToolsFlows(FlowTest): controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"])) # Finalize the new seed w/passphrase - destination = self.run_sequence( - Destination(seed_views.SeedFinalizeView), + self.run_sequence( sequence=[ - FlowStep( - button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE - ), - FlowStep( - expected_view=seed_views.SeedAddPassphraseView, - screen_return_value="mypassphrase", - ), - FlowStep( - expected_view=seed_views.SeedReviewPassphraseView, - button_data_selection=seed_views.SeedReviewPassphraseView.DONE - ), - FlowStep( - expected_view=seed_views.SeedOptionsView, - # SeedOptionsView should auto-route away - ), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE), + FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="mypassphrase"), + FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), ] ) - # Flow should resume at Script Type selection - assert destination.View_cls == seed_views.SeedExportXpubScriptTypeView From eee5b5e9ce1d2f4982b1ab2bfa686ea4db96388a Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 31 Mar 2023 17:12:35 -0500 Subject: [PATCH 20/67] Indentation level bugfix --- src/seedsigner/views/tools_views.py | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index b453bebc..de4d5e03 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -579,7 +579,7 @@ class ToolsAddressExplorerAddressListView(View): addr_storage_key = "change_addrs" if addr_storage_key in data and len(data[addr_storage_key]) >= self.start_index + addrs_per_screen: - # We already calculated this range addresses; just retrieve them + # We already calculated this range of addresses; just retrieve them addresses = data[addr_storage_key][self.start_index:self.start_index + addrs_per_screen] else: @@ -617,20 +617,20 @@ class ToolsAddressExplorerAddressListView(View): # Everything is set. Stop the loading screen self.loading_screen.stop() - for i, address in enumerate(addresses): - cur_index = i + self.start_index + for i, address in enumerate(addresses): + cur_index = i + self.start_index - # Adjust the trailing addr display length based on available room - # (the index number will push it out on each order of magnitude) - if cur_index < 10: - end_digits = -6 - elif cur_index < 100: - end_digits = -5 - else: - end_digits = -4 - button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") + # Adjust the trailing addr display length based on available room + # (the index number will push it out on each order of magnitude) + if cur_index < 10: + end_digits = -6 + elif cur_index < 100: + end_digits = -5 + else: + end_digits = -4 + button_data.append(f"{cur_index}:{address[:8]}...{address[end_digits:]}") - button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + button_data.append(("Next {}".format(addrs_per_screen), None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) selected_menu_num = self.run_screen( ButtonListScreen, From c2f3c944606844606a41ff3ec9cea40940a955b0 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 31 Mar 2023 18:32:21 -0500 Subject: [PATCH 21/67] Adds test flows for view.py; cleanup --- src/seedsigner/views/view.py | 24 ++++++------ tests/base.py | 2 +- tests/test_flows_settings.py | 4 +- tests/test_flows_tools.py | 2 +- tests/test_flows_view.py | 76 ++++++++++++++++++++++++++++++++++++ 5 files changed, 93 insertions(+), 15 deletions(-) create mode 100644 tests/test_flows_view.py diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index eb638158..6b699e23 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -178,23 +178,23 @@ class MainMenuView(View): class PowerOptionsView(View): RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) + button_data = [RESET, POWER_OFF] def run(self): - button_data = [PowerOptionsView.RESET, PowerOptionsView.POWER_OFF] selected_menu_num = self.run_screen( LargeButtonScreen, title="Reset / Power", show_back_button=True, - button_data=button_data + button_data=self.button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == PowerOptionsView.RESET: + elif self.button_data[selected_menu_num] == PowerOptionsView.RESET: return Destination(RestartView) - elif button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: + elif self.button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: return Destination(PowerOffView) @@ -203,7 +203,7 @@ class RestartView(View): def run(self): thread = RestartView.DoResetThread() thread.start() - ResetScreen().display() + self.run_screen(ResetScreen) class DoResetThread(BaseThread): @@ -227,12 +227,12 @@ class RestartView(View): class PowerOffView(View): def run(self): if Settings.HOSTNAME == Settings.SEEDSIGNER_OS: - PowerOffNotRequiredScreen().display() + self.run_screen(PowerOffNotRequiredScreen) return Destination(BackStackView) else: thread = PowerOffView.PowerOffThread() thread.start() - PowerOffScreen().display() + self.run_screen(PowerOffScreen) class PowerOffThread(BaseThread): @@ -250,12 +250,13 @@ class NotYetImplementedView(View): Temporary View to use during dev. """ def run(self): - WarningScreen( + self.run_screen( + WarningScreen, title="Work In Progress", status_headline="Not Yet Implemented", text="This is still on our to-do list!", button_data=["Back to Main Menu"], - ).display() + ) return Destination(MainMenuView) @@ -267,13 +268,14 @@ class UnhandledExceptionView(View): def run(self): - DireWarningScreen( + self.run_screen( + DireWarningScreen, title="System Error", status_headline=self.error[0], text=self.error[1] + "\n" + self.error[2], button_data=["OK"], show_back_button=False, allow_text_overflow=True, # Fit what we can, let the rest go off the edges - ).display() + ) return Destination(MainMenuView, clear_history=True) diff --git a/tests/base.py b/tests/base.py index be45ac97..ed99519c 100644 --- a/tests/base.py +++ b/tests/base.py @@ -145,7 +145,7 @@ class FlowTest(BaseTest): flow_step = sequence.pop(0) if flow_step.button_data_selection: return view.button_data.index(flow_step.button_data_selection) - elif type(flow_step.screen_return_value) == StopControllerCommand: + elif type(flow_step.screen_return_value) in [StopControllerCommand, Exception]: raise flow_step.screen_return_value return flow_step.screen_return_value diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 9603453f..824aee1d 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -56,7 +56,7 @@ class TestSettingsFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), - FlowStep(settings_views.IOTestView, screen_return_value=None), + FlowStep(settings_views.IOTestView), FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), ]) @@ -66,6 +66,6 @@ class TestSettingsFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), - FlowStep(settings_views.DonateView, screen_return_value=None), + FlowStep(settings_views.DonateView), FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index 5ef82e4b..d698eba9 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -31,7 +31,7 @@ class TestToolsFlows(FlowTest): FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list - FlowStep(tools_views.ToolsAddressExplorerAddressView, screen_return_value=None), # QRDisplayScreen runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressView), # QRDisplayScreen runs until dismissed; no ret value FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=StopControllerCommand()), ]) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py new file mode 100644 index 00000000..2c42d02d --- /dev/null +++ b/tests/test_flows_view.py @@ -0,0 +1,76 @@ +import pytest +from mock import patch + +# Must import this before any seedsigner imports +from base import FlowTest, FlowStep + +from seedsigner.controller import StopControllerCommand +from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON +from seedsigner.models.settings import Settings +from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView +from seedsigner.views.view import MainMenuView, NotYetImplementedView, PowerOptionsView, PowerOffView, RestartView, UnhandledExceptionView, View + + + +class TestViewFlows(FlowTest): + + def test_restart_flow(self): + """ + Basic flow from MainMenuView to RestartView + """ + with patch('seedsigner.views.view.RestartView.DoResetThread'): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), + FlowStep(RestartView, screen_return_value=StopControllerCommand()), + ]) + + + def test_power_off_flow(self): + """ + Basic flow from MainMenuView to PowerOffView + """ + with patch('seedsigner.views.view.PowerOffView.PowerOffThread'): + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView, screen_return_value=StopControllerCommand()), + ]) + + # And again, but this time as if we were in the SeedSigner OS + Settings.HOSTNAME = Settings.SEEDSIGNER_OS + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), + FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), + FlowStep(PowerOffView), # returns BackStackView + FlowStep(PowerOptionsView, screen_return_value=StopControllerCommand()), + ]) + + + def test_not_yet_implemented_flow(self): + """ + Run an incomplete View that returns None and ensure that we get the NotYetImplementedView + """ + class IncompleteTestView(View): + def run(self): + self.run_screen(None) + return None + + self.run_sequence([ + FlowStep(IncompleteTestView), + FlowStep(NotYetImplementedView), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + ]) + + + def test_unhandled_exception_flow(self): + """ + Basic flow from any arbitrary View to the UnhandledExceptionView + """ + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), + FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), + FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception + FlowStep(UnhandledExceptionView), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + ]) From 9c18e97063873fe7b521f6bc864d93f052b2d023 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Thu, 13 Apr 2023 08:08:51 -0500 Subject: [PATCH 22/67] Light cleanup; additional core FlowTest tests --- src/seedsigner/models/settings_definition.py | 4 +-- src/seedsigner/views/scan_views.py | 3 +- tests/base.py | 8 ++--- tests/test_flows.py | 37 +++++++++++++++++--- tests/test_flows_seed.py | 7 ++-- tests/test_flows_settings.py | 2 +- tests/test_flows_tools.py | 7 ++-- tests/test_flows_view.py | 7 ++-- 8 files changed, 48 insertions(+), 27 deletions(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index df02858b..619ea579 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Any, List, Tuple, Union +from typing import Any, List @@ -217,7 +217,7 @@ class SettingsEntry: visibility: str = SettingsConstants.VISIBILITY__GENERAL type: str = SettingsConstants.TYPE__ENABLED_DISABLED help_text: str = None - selection_options: List[Tuple[Union[str,int],str]] = None + selection_options: list[tuple[str | int], str] = None default_value: Any = None def __post_init__(self): diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index 95903040..cd297ead 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -1,10 +1,9 @@ import json import re -from typing import Type from embit.descriptor import Descriptor -from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, BaseScreen +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON from seedsigner.gui.screens import scan_screens from seedsigner.models import DecodeQR, Seed from seedsigner.models.settings import SettingsConstants diff --git a/tests/base.py b/tests/base.py index ed99519c..6598a03c 100644 --- a/tests/base.py +++ b/tests/base.py @@ -1,7 +1,7 @@ import sys from dataclasses import dataclass from mock import MagicMock, patch -from typing import Callable, List, Tuple, Type, Union +from typing import Callable # Prevent importing modules w/Raspi hardware dependencies. # These must precede any SeedSigner imports. @@ -83,9 +83,9 @@ class FlowStep: * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. """ expected_view: type[View] = None - run_before: Callable = None - screen_return_value: Union[int,str] = None - button_data_selection: Union[str,Tuple] = None + run_before: Callable[[View], None] = None + screen_return_value: int | str = None + button_data_selection: str | tuple = None is_redirect: bool = False def __post_init__(self): diff --git a/tests/test_flows.py b/tests/test_flows.py index 974b6368..da7d19e5 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,14 +1,13 @@ import pytest -from mock import patch -# Must import this before any seedsigner imports +# Must import test base before the Controller from base import FlowTest, FlowStep, FlowDidNotExpectView -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, StopControllerCommand from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models.seed import Seed from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView -from seedsigner.views.view import MainMenuView, PowerOptionsView +from seedsigner.views.view import MainMenuView, PowerOptionsView, UnhandledExceptionView from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView @@ -17,7 +16,8 @@ class TestFlowTest(FlowTest): def test_simple_flow(self): """ - Basic test to ensure the FlowTest can flow through a sequence of Views. + Basic test to ensure the FlowTest can flow through a sequence of Views and + terminate via the StopControllerCommand. """ self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), @@ -37,6 +37,22 @@ class TestFlowTest(FlowTest): FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. ]) + + + def test_run_before_executes(self): + """ + Ensure that the FlowTest can execute a function before running a View. + """ + def break_button_data(view): + # Intentionally break the FlowTest's integration with the View's expected button_data + view.button_data = [] + + self.run_sequence([ + # Offering a button_data_selection that doesn't exist in the View's button_data + # will cause the FlowTest to raise an Exception. + FlowStep(MainMenuView, run_before=break_button_data, button_data_selection=MainMenuView.TOOLS), + FlowStep(UnhandledExceptionView), + ]) def test_back_button_flow(self): @@ -75,5 +91,16 @@ class TestFlowTest(FlowTest): FlowStep(SeedBackupView, screen_return_value=StopControllerCommand()), ] ) + + def test_raise_exception_via_screen_return_value(self): + """ + Ensure that the FlowTest can raise an exception via the screen_return_value. + """ + # A generic Exception should be caught by the Controller and routed to the + # UnhandledExceptionView. + self.run_sequence([ + FlowStep(MainMenuView, screen_return_value=Exception("Test exception")), + FlowStep(UnhandledExceptionView), + ]) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 6d69a4ac..0cdc1d2a 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,9 +1,8 @@ - -# Must import this before the Controller +# Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand -from seedsigner.views.view import Destination, MainMenuView +from seedsigner.controller import StopControllerCommand +from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 824aee1d..3ab8a2fd 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -1,6 +1,6 @@ import os -# Must import this before any seedsigner imports +# Must import test base before the Controller from base import FlowTest, FlowStep from seedsigner.controller import StopControllerCommand diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index d698eba9..c283df01 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -1,13 +1,10 @@ - -# Must import this before the Controller -from typing import Type - +# Must import test base before the Controller from base import FlowTest, FlowStep from seedsigner.controller import Controller, StopControllerCommand from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition -from seedsigner.views.view import Destination, MainMenuView +from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, tools_views diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 2c42d02d..b033da90 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -1,7 +1,6 @@ -import pytest from mock import patch -# Must import this before any seedsigner imports +# Must import test base before the Controller from base import FlowTest, FlowStep from seedsigner.controller import StopControllerCommand @@ -51,13 +50,13 @@ class TestViewFlows(FlowTest): """ Run an incomplete View that returns None and ensure that we get the NotYetImplementedView """ - class IncompleteTestView(View): + class IncompleteView(View): def run(self): self.run_screen(None) return None self.run_sequence([ - FlowStep(IncompleteTestView), + FlowStep(IncompleteView), FlowStep(NotYetImplementedView), FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), ]) From d1119623812f75b77ccbe7730a1f774e16692093 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 14 Apr 2023 14:52:39 -0500 Subject: [PATCH 23/67] Updates after PR Review Club --- src/seedsigner/controller.py | 18 ++++++----- src/seedsigner/views/seed_views.py | 19 ++++++------ tests/base.py | 33 +++++++++----------- tests/test_flows.py | 18 +++++------ tests/test_flows_seed.py | 50 +++++++++++++++++++++++++++--- tests/test_flows_settings.py | 10 +++--- tests/test_flows_tools.py | 12 +++---- tests/test_flows_view.py | 12 +++---- 8 files changed, 106 insertions(+), 66 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 44509227..10053f43 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -32,7 +32,7 @@ class BackStack(List[Destination]): -class StopControllerCommand(Exception): +class StopFlowBasedTest(Exception): """ This is a special exception that is raised by the test suite to stop the Controller's main loop. It is not meant to be raised by any other code. @@ -40,6 +40,10 @@ class StopControllerCommand(Exception): pass +class FlowBasedTestUnexpectedViewError(Exception): + pass + + class Controller(Singleton): """ @@ -262,17 +266,15 @@ class Controller(Singleton): print(f"Executing {next_destination}") next_destination = next_destination.run() - except StopControllerCommand: + except StopFlowBasedTest: # This is a special exception that is only raised by the test suite # to stop the Controller loop and exit the test. - print("StopControllerCommand received; ending Controller loop.") return - except AssertionError as e: - # Only raised by the test suite via FlowDidNotExpectView - print(f"Test suite caught {e.__class__.__name__}: {e}") - - # Re-raise so that the test suite can catch it + except FlowBasedTestUnexpectedViewError as e: + # This is a special exception that is only raised by the test suite + # when a flow-based test fails to follow the expected sequence. + # Re-raise so the test suite can handle it. raise e except Exception as e: diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0da13470..2b13a98c 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -462,6 +462,10 @@ class SeedBackupView(View): Export Xpub flow ****************************************************************************""" class SeedExportXpubSigTypeView(View): + SINGLE_SIG = "Single Sig" + MULTISIG = "Multisig" + button_data=[SINGLE_SIG, MULTISIG] + def __init__(self, seed_num: int): super().__init__() self.seed_num = seed_num @@ -472,22 +476,19 @@ class SeedExportXpubSigTypeView(View): # Nothing to select; skip this screen return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True) - SINGLE_SIG = "Single Sig" - MULTISIG = "Multisig" - button_data=[SINGLE_SIG, MULTISIG] - - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", - button_data=button_data - ).display() + button_data=self.button_data + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if button_data[selected_menu_num] == SINGLE_SIG: + if self.button_data[selected_menu_num] == self.SINGLE_SIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG}) - elif button_data[selected_menu_num] == MULTISIG: + elif self.button_data[selected_menu_num] == self.MULTISIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) diff --git a/tests/base.py b/tests/base.py index 6598a03c..ebf520fe 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,7 +12,7 @@ sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError, StopFlowBasedTest from seedsigner.models import Settings from seedsigner.views.view import Destination, MainMenuView, View @@ -78,12 +78,12 @@ class FlowStep: Trivial helper class to express FlowTest sequences below. * expected_view: verify that the current step in the sequence instantiates the right View. - * run_before: function that takes a View instance as an arg and modifies it before running the View. + * before_run: function that takes a View instance as an arg and modifies it before running the View. * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. """ expected_view: type[View] = None - run_before: Callable[[View], None] = None + before_run: Callable[[View], None] = None screen_return_value: int | str = None button_data_selection: str | tuple = None is_redirect: bool = False @@ -94,12 +94,6 @@ class FlowStep: -class FlowDidNotExpectView(AssertionError): - """ Raised when the FlowTest sequence does not match the View that was run. """ - pass - - - class FlowTest(BaseTest): """ Base class for any tests that do flow-based testing """ @@ -109,19 +103,20 @@ class FlowTest(BaseTest): the Controller's flow control logic and the routing from View to View. """ def verify_next_View_cls_and_run_view(destination: Destination, *args, **kwargs): - if len(sequence) == 0: - # We've reached the end of the sequence, so raise StopControllerCommand - # to stop the Controller and exit the test. - raise StopControllerCommand() - # Verify that the View class specified in the test sequence matches the # View class that is being run. if destination.View_cls != sequence[0].expected_view: - raise FlowDidNotExpectView(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") + raise FlowBasedTestUnexpectedViewError(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") + print(f"Confirmed {sequence[0].expected_view} is the next View") + + if len(sequence) == 1: + # The last entry in the sequence just needs to confirm that we got the + # right View class; we can now stop the Controller and exit the test. + raise StopFlowBasedTest() # Run the optional pre-run function to modify the View. - if sequence[0].run_before: - sequence[0].run_before(destination.view) + if sequence[0].before_run: + sequence[0].before_run(destination.view) if sequence[0].is_redirect: # The current View is going to auto-redirect without calling run_screen(), @@ -145,8 +140,10 @@ class FlowTest(BaseTest): flow_step = sequence.pop(0) if flow_step.button_data_selection: return view.button_data.index(flow_step.button_data_selection) - elif type(flow_step.screen_return_value) in [StopControllerCommand, Exception]: + + elif type(flow_step.screen_return_value) in [StopFlowBasedTest, Exception]: raise flow_step.screen_return_value + return flow_step.screen_return_value diff --git a/tests/test_flows.py b/tests/test_flows.py index da7d19e5..3cc29e24 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,9 +1,9 @@ import pytest # Must import test base before the Controller -from base import FlowTest, FlowStep, FlowDidNotExpectView +from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models.seed import Seed from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView @@ -23,7 +23,7 @@ class TestFlowTest(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), FlowStep(ToolsCalcFinalWordNumWordsView, button_data_selection=ToolsCalcFinalWordNumWordsView.TWELVE), - FlowStep(SeedMnemonicEntryView, screen_return_value=StopControllerCommand()), + FlowStep(SeedMnemonicEntryView), ]) @@ -32,14 +32,14 @@ class TestFlowTest(FlowTest): Ensure that the FlowTest will raise an AssertionError if the next View in the sequence is not the expected View. """ - with pytest.raises(FlowDidNotExpectView): + with pytest.raises(FlowBasedTestUnexpectedViewError): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. ]) - def test_run_before_executes(self): + def test_before_run_executes(self): """ Ensure that the FlowTest can execute a function before running a View. """ @@ -50,7 +50,7 @@ class TestFlowTest(FlowTest): self.run_sequence([ # Offering a button_data_selection that doesn't exist in the View's button_data # will cause the FlowTest to raise an Exception. - FlowStep(MainMenuView, run_before=break_button_data, button_data_selection=MainMenuView.TOOLS), + FlowStep(MainMenuView, before_run=break_button_data, button_data_selection=MainMenuView.TOOLS), FlowStep(UnhandledExceptionView), ]) @@ -62,7 +62,7 @@ class TestFlowTest(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, screen_return_value=RET_CODE__BACK_BUTTON), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView), ]) @@ -72,7 +72,7 @@ class TestFlowTest(FlowTest): """ # Don't have to start at the MainMenuView; can jump straight in self.run_sequence([ - FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=StopControllerCommand()), + FlowStep(ToolsCalcFinalWordNumWordsView), ]) # And again, but this time with a View that requires input view_args @@ -88,7 +88,7 @@ class TestFlowTest(FlowTest): initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), - FlowStep(SeedBackupView, screen_return_value=StopControllerCommand()), + FlowStep(SeedBackupView), ] ) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 0cdc1d2a..74fd5dab 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand +from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -19,9 +19,9 @@ class TestSeedFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), - FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR; ret val is ignored FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), - FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedOptionsView), ]) @@ -46,7 +46,7 @@ class TestSeedFlows(FlowTest): # With the mnemonic completely entered, we land on the SeedFinalizeView sequence += [ FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), - FlowStep(seed_views.SeedOptionsView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedOptionsView), ] self.run_sequence(sequence) @@ -83,7 +83,47 @@ class TestSeedFlows(FlowTest): sequence += [ FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value="zebra"), # provide yet another invalid checksum word FlowStep(seed_views.SeedMnemonicInvalidView, button_data_selection=seed_views.SeedMnemonicInvalidView.DISCARD), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView), ] self.run_sequence(sequence) + + + def test_export_xpub_flow(self): + """ + Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the SeedOptionsView. + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + + def test_export_xpub_skip_sig_type_flow(self): + """ + + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + # TODO: Test is incomplete... diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 3ab8a2fd..4bf61212 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -3,7 +3,7 @@ import os # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand +from seedsigner.controller import StopFlowBasedTest from seedsigner.models import SettingsDefinition from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants @@ -28,7 +28,7 @@ class TestSettingsFlows(FlowTest): FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView, screen_return_value=StopFlowBasedTest()), ]) # Settings file should now exist @@ -47,7 +47,7 @@ class TestSettingsFlows(FlowTest): FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=1), # select/deselect second option FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), # BACK to exit - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView), ]) @@ -57,7 +57,7 @@ class TestSettingsFlows(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.IO_TEST), FlowStep(settings_views.IOTestView), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView), ]) @@ -67,5 +67,5 @@ class TestSettingsFlows(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.SETTINGS), FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_views.SettingsMenuView.DONATE), FlowStep(settings_views.DonateView), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopControllerCommand()), + FlowStep(settings_views.SettingsMenuView), ]) diff --git a/tests/test_flows_tools.py b/tests/test_flows_tools.py index c283df01..ba5abe8c 100644 --- a/tests/test_flows_tools.py +++ b/tests/test_flows_tools.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller from seedsigner.models.seed import Seed from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition from seedsigner.views.view import MainMenuView @@ -28,8 +28,8 @@ class TestToolsFlows(FlowTest): FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE), FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list - FlowStep(tools_views.ToolsAddressExplorerAddressView), # QRDisplayScreen runs until dismissed; no ret value - FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=StopControllerCommand()), + FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value + FlowStep(tools_views.ToolsAddressExplorerAddressListView), ]) @@ -48,10 +48,10 @@ class TestToolsFlows(FlowTest): FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS), FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.EXPLORER), FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED), - FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), # simulate read SeedQR + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), # simulate read SeedQR FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), - FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedExportXpubScriptTypeView), ]) assert controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER @@ -67,6 +67,6 @@ class TestToolsFlows(FlowTest): FlowStep(seed_views.SeedAddPassphraseView, screen_return_value="mypassphrase"), FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), - FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=StopControllerCommand()), + FlowStep(seed_views.SeedExportXpubScriptTypeView), ] ) diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index b033da90..43c7b9f0 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -3,7 +3,7 @@ from mock import patch # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopControllerCommand +from seedsigner.controller import StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON from seedsigner.models.settings import Settings from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView @@ -21,7 +21,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), - FlowStep(RestartView, screen_return_value=StopControllerCommand()), + FlowStep(RestartView, screen_return_value=StopFlowBasedTest()), ]) @@ -33,7 +33,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), - FlowStep(PowerOffView, screen_return_value=StopControllerCommand()), + FlowStep(PowerOffView, screen_return_value=StopFlowBasedTest()), ]) # And again, but this time as if we were in the SeedSigner OS @@ -42,7 +42,7 @@ class TestViewFlows(FlowTest): FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), FlowStep(PowerOffView), # returns BackStackView - FlowStep(PowerOptionsView, screen_return_value=StopControllerCommand()), + FlowStep(PowerOptionsView, screen_return_value=StopFlowBasedTest()), ]) @@ -58,7 +58,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(IncompleteView), FlowStep(NotYetImplementedView), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), ]) @@ -71,5 +71,5 @@ class TestViewFlows(FlowTest): FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception FlowStep(UnhandledExceptionView), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()), + FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), ]) From 8e56803063edd7dd476d46d8177f588a5cf5bcb2 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 14 Apr 2023 14:59:12 -0500 Subject: [PATCH 24/67] added comment --- src/seedsigner/controller.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 10053f43..cfa9c665 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -34,13 +34,18 @@ class BackStack(List[Destination]): class StopFlowBasedTest(Exception): """ - This is a special exception that is raised by the test suite to stop the - Controller's main loop. It is not meant to be raised by any other code. + This is a special exception that is only raised by the test suite to stop the + Controller's main loop. It should not be raised by any other code. """ pass class FlowBasedTestUnexpectedViewError(Exception): + """ + This is a special exception that is only raised by the test suite to indicate + that the Controller's current View does not match the expected TestFlow sequence. + It should not be raised by any other code. + """ pass From 698273e3f9d596c13264b156e18c31ccb56f768e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 14 Apr 2023 15:00:33 -0500 Subject: [PATCH 25/67] Update test_flows_seed.py --- tests/test_flows_seed.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 74fd5dab..a9692187 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -104,6 +104,7 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), FlowStep(seed_views.SeedExportXpubScriptTypeView), + # TODO: Test is incomplete... ] ) @@ -123,7 +124,6 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), FlowStep(seed_views.SeedExportXpubScriptTypeView), + # TODO: Test is incomplete... ] ) - - # TODO: Test is incomplete... From 696f59992deedb843b30a14e4f25c3c7d038b538 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Tue, 18 Apr 2023 07:20:22 -0500 Subject: [PATCH 26/67] Interim commit; refactoring View.run_screen patch --- src/seedsigner/views/seed_views.py | 23 ++++++++++++++--------- src/seedsigner/views/view.py | 21 ++++++++++----------- tests/base.py | 9 ++++++++- tests/test_flows.py | 12 ++---------- tests/test_flows_seed.py | 22 ++++++++++++++++++++++ 5 files changed, 56 insertions(+), 31 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 2b13a98c..efdcca81 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -31,6 +31,8 @@ from .view import NotYetImplementedView, View, Destination, BackStackView, MainM class SeedsMenuView(View): + LOAD = "Load a seed" + def __init__(self): super().__init__() self.seeds = [] @@ -50,11 +52,12 @@ class SeedsMenuView(View): button_data.append((seed["fingerprint"], SeedSignerCustomIconConstants.FINGERPRINT, "blue")) button_data.append("Load a seed") - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="In-Memory Seeds", is_button_text_centered=False, button_data=button_data - ).display() + ) if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): return Destination(SeedOptionsView, view_args={"seed_num": selected_menu_num}) @@ -291,6 +294,9 @@ class SeedReviewPassphraseView(View): class SeedDiscardView(View): + KEEP = "Keep Seed" + DISCARD = ("Discard", None, None, "red") + def __init__(self, seed_num: int = None): super().__init__() self.seed_num = seed_num @@ -301,27 +307,26 @@ class SeedDiscardView(View): def run(self): - KEEP = "Keep Seed" - DISCARD = ("Discard", None, None, "red") - button_data = [KEEP, DISCARD] + button_data = [self.KEEP, self.DISCARD] fingerprint = self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, title="Discard Seed?", status_headline=None, text=f"Wipe seed {fingerprint} from the device?", show_back_button=False, button_data=button_data, - ).display() + ) - if button_data[selected_menu_num] == KEEP: + if button_data[selected_menu_num] == self.KEEP: # Use skip_current_view=True to prevent BACK from landing on this warning screen if self.seed_num is not None: return Destination(SeedOptionsView, view_args={"seed_num": self.seed_num}, skip_current_view=True) else: return Destination(SeedFinalizeView, skip_current_view=True) - elif button_data[selected_menu_num] == DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: if self.seed_num is not None: self.controller.discard_seed(self.seed_num) else: diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index 6b699e23..f9a40f89 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -141,15 +141,14 @@ class MainMenuView(View): SEEDS = ("Seeds", FontAwesomeIconConstants.KEY) TOOLS = ("Tools", FontAwesomeIconConstants.SCREWDRIVER_WRENCH) SETTINGS = ("Settings", FontAwesomeIconConstants.GEAR) - button_data = [SCAN, SEEDS, TOOLS, SETTINGS] - def run(self): + button_data = [self.SCAN, self.SEEDS, self.TOOLS, self.SETTINGS] selected_menu_num = self.run_screen( LargeButtonScreen, title="Home", title_font_size=26, - button_data=self.button_data, + button_data=button_data, show_back_button=False, show_power_button=True, ) @@ -157,19 +156,19 @@ class MainMenuView(View): if selected_menu_num == RET_CODE__POWER_BUTTON: return Destination(PowerOptionsView) - if self.button_data[selected_menu_num] == self.SCAN: + if button_data[selected_menu_num] == self.SCAN: from .scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] == self.SEEDS: + elif button_data[selected_menu_num] == self.SEEDS: from .seed_views import SeedsMenuView return Destination(SeedsMenuView) - elif self.button_data[selected_menu_num] == self.TOOLS: + elif button_data[selected_menu_num] == self.TOOLS: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) - elif self.button_data[selected_menu_num] == self.SETTINGS: + elif button_data[selected_menu_num] == self.SETTINGS: from .settings_views import SettingsMenuView return Destination(SettingsMenuView) @@ -178,23 +177,23 @@ class MainMenuView(View): class PowerOptionsView(View): RESET = ("Restart", FontAwesomeIconConstants.ROTATE_RIGHT) POWER_OFF = ("Power Off", FontAwesomeIconConstants.POWER_OFF) - button_data = [RESET, POWER_OFF] def run(self): + button_data = [self.RESET, self.POWER_OFF] selected_menu_num = self.run_screen( LargeButtonScreen, title="Reset / Power", show_back_button=True, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == PowerOptionsView.RESET: + elif button_data[selected_menu_num] == self.RESET: return Destination(RestartView) - elif self.button_data[selected_menu_num] == PowerOptionsView.POWER_OFF: + elif button_data[selected_menu_num] == self.POWER_OFF: return Destination(PowerOffView) diff --git a/tests/base.py b/tests/base.py index ebf520fe..27dd2d4e 100644 --- a/tests/base.py +++ b/tests/base.py @@ -138,8 +138,15 @@ class FlowTest(BaseTest): # Return the return value specified in the test sequence and # remove the completed test step from the sequence. flow_step = sequence.pop(0) + if flow_step.button_data_selection: - return view.button_data.index(flow_step.button_data_selection) + # We're mocking out the View.run_screen() method, so we'll get all of the + # input args that are normally passed into the Screen.run() method, + # including the button_data kwarg. + if "button_data" in kwargs: + return kwargs.get("button_data").index(flow_step.button_data_selection) + else: + raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") elif type(flow_step.screen_return_value) in [StopFlowBasedTest, Exception]: raise flow_step.screen_return_value diff --git a/tests/test_flows.py b/tests/test_flows.py index 3cc29e24..f900d1bc 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -43,16 +43,8 @@ class TestFlowTest(FlowTest): """ Ensure that the FlowTest can execute a function before running a View. """ - def break_button_data(view): - # Intentionally break the FlowTest's integration with the View's expected button_data - view.button_data = [] - - self.run_sequence([ - # Offering a button_data_selection that doesn't exist in the View's button_data - # will cause the FlowTest to raise an Exception. - FlowStep(MainMenuView, before_run=break_button_data, button_data_selection=MainMenuView.TOOLS), - FlowStep(UnhandledExceptionView), - ]) + # TODO + pass def test_back_button_flow(self): diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index a9692187..a8ea4e03 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -127,3 +127,25 @@ class TestSeedFlows(FlowTest): # TODO: Test is incomplete... ] ) + + + def test_discard_seed_flow(self): + """ + Selecting "Discard Seed" from the SeedOptionsView should enter the Discard Seed flow and + remove the in-memory seed from the Controller. + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.DISCARD), + FlowStep(seed_views.SeedDiscardView, button_data_selection=seed_views.SeedDiscardView.DISCARD), + FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS), + FlowStep(seed_views.SeedsMenuView, is_redirect=True), # When no seeds are loaded it auto-redirects to LoadSeedView + FlowStep(seed_views.LoadSeedView), + ] + ) From 57ece3cc1c9f8f4a6997763e2eb84fc1c797454f Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:31:39 -0700 Subject: [PATCH 27/67] removing now-unnecessary `button_data` edits --- src/seedsigner/views/seed_views.py | 139 ++++++++++++++-------------- src/seedsigner/views/tools_views.py | 59 ++++++------ 2 files changed, 101 insertions(+), 97 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index efdcca81..c564f516 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -78,37 +78,37 @@ class LoadSeedView(View): TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) CREATE = (" Create a seed", FontAwesomeIconConstants.PLUS) - button_data=[ - SEED_QR, - TYPE_12WORD, - TYPE_24WORD, - CREATE, - ] def run(self): + button_data = [ + self.SEED_QR, + self.TYPE_12WORD, + self.TYPE_24WORD, + self.CREATE, + ] selected_menu_num = self.run_screen( ButtonListScreen, title="Load A Seed", is_button_text_centered=False, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if self.button_data[selected_menu_num] == self.SEED_QR: + if button_data[selected_menu_num] == self.SEED_QR: from .scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] == self.TYPE_12WORD: + elif button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) return Destination(SeedMnemonicEntryView) - elif self.button_data[selected_menu_num] == self.TYPE_24WORD: + elif button_data[selected_menu_num] == self.TYPE_24WORD: self.controller.storage.init_pending_mnemonic(num_words=24) return Destination(SeedMnemonicEntryView) - elif self.button_data[selected_menu_num] == self.CREATE: + elif button_data[selected_menu_num] == self.CREATE: from .tools_views import ToolsMenuView return Destination(ToolsMenuView) @@ -174,7 +174,6 @@ class SeedMnemonicEntryView(View): class SeedMnemonicInvalidView(View): EDIT = "Review & Edit" DISCARD = ("Discard", None, None, "red") - button_data = [EDIT, DISCARD] def __init__(self): super().__init__() @@ -182,19 +181,20 @@ class SeedMnemonicInvalidView(View): def run(self): + button_data = [self.EDIT, self.DISCARD] selected_menu_num = self.run_screen( WarningScreen, title="Invalid Mnemonic!", status_headline=None, text=f"Checksum failure; not a valid seed phrase.", show_back_button=False, - button_data=self.button_data, + button_data=button_data, ) - if self.button_data[selected_menu_num] == self.EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedMnemonicEntryView, view_args={"cur_word_index": 0}) - elif self.button_data[selected_menu_num] == self.DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: self.controller.storage.discard_pending_mnemonic() return Destination(MainMenuView) @@ -203,29 +203,29 @@ class SeedMnemonicInvalidView(View): class SeedFinalizeView(View): FINALIZE = "Done" PASSPHRASE = "BIP-39 Passphrase" - button_data = [FINALIZE] def __init__(self): super().__init__() self.seed = self.controller.storage.get_pending_seed() self.fingerprint = self.seed.get_fingerprint(network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: - self.button_data.append(self.PASSPHRASE) - def run(self): + button_data = [self.FINALIZE] + if self.settings.get_value(SettingsConstants.SETTING__PASSPHRASE) != SettingsConstants.OPTION__DISABLED: + button_data.append(self.PASSPHRASE) + selected_menu_num = self.run_screen( seed_screens.SeedFinalizeScreen, fingerprint=self.fingerprint, - button_data=self.button_data, + button_data=button_data, ) - if self.button_data[selected_menu_num] == self.FINALIZE: + if button_data[selected_menu_num] == self.FINALIZE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) - elif self.button_data[selected_menu_num] == self.PASSPHRASE: + elif button_data[selected_menu_num] == self.PASSPHRASE: return Destination(SeedAddPassphraseView) @@ -257,7 +257,6 @@ class SeedReviewPassphraseView(View): """ EDIT = "Edit passphrase" DONE = "Done" - button_data = [EDIT, DONE] def __init__(self): super().__init__() @@ -273,6 +272,8 @@ class SeedReviewPassphraseView(View): fingerprint_without = self.seed.get_fingerprint(network=network) self.seed.set_passphrase(passphrase) + button_data = [self.EDIT, self.DONE] + # Because we have an explicit "Edit" button, we disable "BACK" to keep the # routing options sane. selected_menu_num = self.run_screen( @@ -280,14 +281,14 @@ class SeedReviewPassphraseView(View): fingerprint_without=fingerprint_without, fingerprint_with=fingerprint_with, passphrase=self.seed.passphrase, - button_data=self.button_data, + button_data=button_data, show_back_button=False, ) - if self.button_data[selected_menu_num] == self.EDIT: + if button_data[selected_menu_num] == self.EDIT: return Destination(SeedAddPassphraseView) - elif self.button_data[selected_menu_num] == self.DONE: + elif button_data[selected_menu_num] == self.DONE: seed_num = self.controller.storage.finalize_pending_seed() return Destination(SeedOptionsView, view_args={"seed_num": seed_num}, clear_history=True) @@ -352,25 +353,6 @@ class SeedOptionsView(View): super().__init__() self.seed_num = seed_num self.seed = self.controller.get_seed(self.seed_num) - self.button_data = [] - - if self.controller.unverified_address: - addr = self.controller.unverified_address["address"][:7] - self.VERIFY_ADDRESS += f" {addr}" - self.button_data.append(self.VERIFY_ADDRESS) - - self.button_data.append(self.SCAN_PSBT) - - if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: - self.button_data.append(self.EXPORT_XPUB) - - self.button_data.append(self.EXPLORER) - self.button_data.append(self.BACKUP) - - if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: - self.button_data.append(self.BIP85_CHILD_SEED) - - self.button_data.append(self.DISCARD) def run(self): @@ -396,9 +378,29 @@ class SeedOptionsView(View): self.controller.psbt_seed = self.seed return Destination(PSBTOverviewView, skip_current_view=True) + button_data = [] + + if self.controller.unverified_address: + addr = self.controller.unverified_address["address"][:7] + self.VERIFY_ADDRESS += f" {addr}" + button_data.append(self.VERIFY_ADDRESS) + + button_data.append(self.SCAN_PSBT) + + if self.settings.get_value(SettingsConstants.SETTING__XPUB_EXPORT) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.EXPORT_XPUB) + + button_data.append(self.EXPLORER) + button_data.append(self.BACKUP) + + if self.settings.get_value(SettingsConstants.SETTING__BIP85_CHILD_SEEDS) == SettingsConstants.OPTION__ENABLED: + button_data.append(self.BIP85_CHILD_SEED) + + button_data.append(self.DISCARD) + selected_menu_num = self.run_screen( seed_screens.SeedOptionsScreen, - button_data=self.button_data, + button_data=button_data, fingerprint=self.seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)), has_passphrase=self.seed.passphrase is not None, ) @@ -407,28 +409,28 @@ class SeedOptionsView(View): # Force BACK to always return to the Main Menu return Destination(MainMenuView) - if self.button_data[selected_menu_num] == self.SCAN_PSBT: + if button_data[selected_menu_num] == self.SCAN_PSBT: from seedsigner.views.scan_views import ScanView self.controller.psbt_seed = self.controller.get_seed(self.seed_num) return Destination(ScanView) - elif self.button_data[selected_menu_num] == self.VERIFY_ADDRESS: + elif button_data[selected_menu_num] == self.VERIFY_ADDRESS: return Destination(SeedAddressVerificationView, view_args=dict(seed_num=self.seed_num)) - elif self.button_data[selected_menu_num] == self.EXPORT_XPUB: + elif button_data[selected_menu_num] == self.EXPORT_XPUB: return Destination(SeedExportXpubSigTypeView, view_args=dict(seed_num=self.seed_num)) - elif self.button_data[selected_menu_num] == self.EXPLORER: + elif button_data[selected_menu_num] == self.EXPLORER: self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER return Destination(SeedExportXpubScriptTypeView, view_args=dict(seed_num=self.seed_num, sig_type=SettingsConstants.SINGLE_SIG)) - elif self.button_data[selected_menu_num] == self.BACKUP: + elif button_data[selected_menu_num] == self.BACKUP: return Destination(SeedBackupView, view_args=dict(seed_num=self.seed_num)) - elif self.button_data[selected_menu_num] == self.BIP85_CHILD_SEED: + elif button_data[selected_menu_num] == self.BIP85_CHILD_SEED: return Destination(SeedBIP85ApplicationModeView, view_args={"seed_num": self.seed_num}) - elif self.button_data[selected_menu_num] == self.DISCARD: + elif button_data[selected_menu_num] == self.DISCARD: return Destination(SeedDiscardView, view_args=dict(seed_num=self.seed_num)) @@ -436,7 +438,6 @@ class SeedOptionsView(View): class SeedBackupView(View): VIEW_WORDS = "View Seed Words" EXPORT_SEEDQR = "Export as SeedQR" - button_data = [VIEW_WORDS, EXPORT_SEEDQR] def __init__(self, seed_num): super().__init__() @@ -445,20 +446,22 @@ class SeedBackupView(View): def run(self): + button_data = [self.VIEW_WORDS, self.EXPORT_SEEDQR] + selected_menu_num = self.run_screen( ButtonListScreen, title="Backup Seed", - button_data=self.button_data, + button_data=button_data, is_bottom_list=True, ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.VIEW_WORDS: + elif button_data[selected_menu_num] == self.VIEW_WORDS: return Destination(SeedWordsWarningView, view_args={"seed_num": self.seed_num}) - elif self.button_data[selected_menu_num] == self.EXPORT_SEEDQR: + elif button_data[selected_menu_num] == self.EXPORT_SEEDQR: return Destination(SeedTranscribeSeedQRFormatView, view_args={"seed_num": self.seed_num}) @@ -469,7 +472,6 @@ class SeedBackupView(View): class SeedExportXpubSigTypeView(View): SINGLE_SIG = "Single Sig" MULTISIG = "Multisig" - button_data=[SINGLE_SIG, MULTISIG] def __init__(self, seed_num: int): super().__init__() @@ -481,19 +483,21 @@ class SeedExportXpubSigTypeView(View): # Nothing to select; skip this screen return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": self.settings.get_value(SettingsConstants.SETTING__SIG_TYPES)[0]}, skip_current_view=True) + button_data=[self.SINGLE_SIG, self.MULTISIG] + selected_menu_num = self.run_screen( ButtonListScreen, title="Export Xpub", - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - if self.button_data[selected_menu_num] == self.SINGLE_SIG: + if button_data[selected_menu_num] == self.SINGLE_SIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.SINGLE_SIG}) - elif self.button_data[selected_menu_num] == self.MULTISIG: + elif button_data[selected_menu_num] == self.MULTISIG: return Destination(SeedExportXpubScriptTypeView, view_args={"seed_num": self.seed_num, "sig_type": SettingsConstants.MULTISIG}) @@ -504,10 +508,6 @@ class SeedExportXpubScriptTypeView(View): self.seed_num = seed_num self.sig_type = sig_type - self.button_data = [] - for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): - self.button_data.append(script_type) - def run(self): from .tools_views import ToolsAddressExplorerAddressTypeView @@ -526,11 +526,15 @@ class SeedExportXpubScriptTypeView(View): if self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER: title = "Address Explorer" + button_data = [] + for script_type in self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES): + button_data.append(script_type) + selected_menu_num = self.run_screen( ButtonListScreen, title=title, is_button_text_centered=False, - button_data=self.button_data, + button_data=button_data, is_bottom_list=True, ) @@ -542,7 +546,7 @@ class SeedExportXpubScriptTypeView(View): else: script_types_settings_entry = SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES) - selected_display_name = self.button_data[selected_menu_num] + selected_display_name = button_data[selected_menu_num] args["script_type"] = script_types_settings_entry.get_selection_option_value_by_display_name(selected_display_name) if args["script_type"] == SettingsConstants.CUSTOM_DERIVATION: @@ -564,6 +568,7 @@ class SeedExportXpubCustomDerivationView(View): self.script_type = script_type self.custom_derivation_path = "m/" + def run(self): ret = seed_screens.SeedExportXpubCustomDerivationScreen( initial_value=self.custom_derivation_path, diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index de4d5e03..6e5404c5 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -27,29 +27,30 @@ class ToolsMenuView(View): DICE = ("New seed", FontAwesomeIconConstants.DICE) KEYBOARD = ("Calc 12th/24th word", FontAwesomeIconConstants.KEYBOARD) EXPLORER = "Address Explorer" - button_data = [IMAGE, DICE, KEYBOARD, EXPLORER] def run(self): + button_data = [self.IMAGE, self.DICE, self.KEYBOARD, self.EXPLORER] + selected_menu_num = self.run_screen( ButtonListScreen, title="Tools", is_button_text_centered=False, - button_data=self.button_data + button_data=button_data ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.IMAGE: + elif button_data[selected_menu_num] == self.IMAGE: return Destination(ToolsImageEntropyLivePreviewView) - elif self.button_data[selected_menu_num] == self.DICE: + elif button_data[selected_menu_num] == self.DICE: return Destination(ToolsDiceEntropyMnemonicLengthView) - elif self.button_data[selected_menu_num] == self.KEYBOARD: + elif button_data[selected_menu_num] == self.KEYBOARD: return Destination(ToolsCalcFinalWordNumWordsView) - elif self.button_data[selected_menu_num] == self.EXPLORER: + elif button_data[selected_menu_num] == self.EXPLORER: return Destination(ToolsAddressExplorerSelectSourceView) @@ -233,27 +234,28 @@ class ToolsDiceEntropyEntryView(View): class ToolsCalcFinalWordNumWordsView(View): TWELVE = "12 words" TWENTY_FOUR = "24 words" - button_data = [TWELVE, TWENTY_FOUR] - def run(self): + def run(self): + button_data = [self.TWELVE, self.TWENTY_FOUR] + selected_menu_num = self.run_screen( ButtonListScreen, title="Mnemonic Length", is_bottom_list=True, is_button_text_centered=True, - button_data=self.button_data, + button_data=button_data, ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] == self.TWELVE: + elif button_data[selected_menu_num] == self.TWELVE: self.controller.storage.init_pending_mnemonic(12) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) - elif self.button_data[selected_menu_num] == self.TWENTY_FOUR: + elif button_data[selected_menu_num] == self.TWENTY_FOUR: self.controller.storage.init_pending_mnemonic(24) # return Destination(SeedMnemonicEntryView, view_args=dict(is_calc_final_word=True)) @@ -427,23 +429,19 @@ class ToolsAddressExplorerSelectSourceView(View): SCAN_DESCRIPTOR = ("Scan wallet descriptor", FontAwesomeIconConstants.QRCODE) TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [SCAN_SEED, SCAN_DESCRIPTOR, TYPE_12WORD, TYPE_24WORD] - - def __init__(self): - super().__init__() - self.seeds = self.controller.storage.seeds - seed_buttons =[] - for seed in self.seeds: - button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - seed_buttons.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - self.button_data = seed_buttons + self.button_data def run(self): + seeds = self.controller.storage.seeds + button_data = [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] + for seed in seeds: + button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) + button_data.insert(0, (button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + selected_menu_num = self.run_screen( ButtonListScreen, title="Address Explorer", - button_data=self.button_data, + button_data=button_data, is_button_text_centered=False, is_bottom_list=True, ) @@ -456,7 +454,7 @@ class ToolsAddressExplorerSelectSourceView(View): # knows to re-route us once the side flow is complete. self.controller.resume_main_flow = Controller.FLOW__ADDRESS_EXPLORER - if len(self.seeds) > 0 and selected_menu_num < len(self.seeds): + if len(seeds) > 0 and selected_menu_num < len(seeds): # User selected one of the n seeds return Destination( SeedExportXpubScriptTypeView, @@ -466,13 +464,13 @@ class ToolsAddressExplorerSelectSourceView(View): ) ) - elif self.button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: + elif button_data[selected_menu_num] in [self.SCAN_SEED, self.SCAN_DESCRIPTOR]: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif self.button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if self.button_data[selected_menu_num] == self.TYPE_12WORD: + if button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -483,7 +481,6 @@ class ToolsAddressExplorerSelectSourceView(View): class ToolsAddressExplorerAddressTypeView(View): RECEIVE = "Receive Addresses" CHANGE = "Change Addresses" - button_data = [RECEIVE, CHANGE] def __init__(self, seed_num: int = None, script_type: str = None, custom_derivation: str = None): @@ -540,9 +537,11 @@ class ToolsAddressExplorerAddressTypeView(View): script_type = data["script_type"] if "script_type" in data else None + button_data = [self.RECEIVE, self.CHANGE] + selected_menu_num = self.run_screen( ToolsAddressExplorerAddressTypeScreen, - button_data=self.button_data, + button_data=button_data, fingerprint=self.seed.get_fingerprint() if self.seed_num is not None else None, wallet_descriptor_display_name=wallet_descriptor_display_name, script_type=script_type, @@ -552,8 +551,8 @@ class ToolsAddressExplorerAddressTypeView(View): if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif self.button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: - return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=self.button_data[selected_menu_num] == self.CHANGE)) + elif button_data[selected_menu_num] in [self.RECEIVE, self.CHANGE]: + return Destination(ToolsAddressExplorerAddressListView, view_args=dict(is_change=button_data[selected_menu_num] == self.CHANGE)) From 81ea0787c8e82316f906a7906988f7ce9e99c0ea Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:47:25 -0700 Subject: [PATCH 28/67] More `button_data` cleanup --- src/seedsigner/views/scan_views.py | 3 +++ src/seedsigner/views/settings_views.py | 26 +++++++++++++------------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/seedsigner/views/scan_views.py b/src/seedsigner/views/scan_views.py index cd297ead..dc81b508 100644 --- a/src/seedsigner/views/scan_views.py +++ b/src/seedsigner/views/scan_views.py @@ -15,6 +15,9 @@ from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Dest class ScanView(View): def __init__(self): super().__init__() + + # Set up the QR decoder here so we can inject data into it in the test suite's + # `before_run`. self.wordlist_language_code = self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE) self.decoder = DecodeQR(wordlist_language_code=self.wordlist_language_code) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 929c3c81..49753eeb 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -19,14 +19,16 @@ class SettingsMenuView(View): # Used to preserve the rendering position in the list self.initial_scroll = initial_scroll - self.settings_entries = SettingsDefinition.get_settings_entries( + + def run(self): + settings_entries = SettingsDefinition.get_settings_entries( visibility=self.visibility ) - self.button_data=[e.display_name for e in self.settings_entries] + button_data=[e.display_name for e in settings_entries] self.selected_button = 0 if self.selected_attr: - for i, entry in enumerate(self.settings_entries): + for i, entry in enumerate(settings_entries): if entry.attr_name == self.selected_attr: self.selected_button = i break @@ -35,11 +37,11 @@ class SettingsMenuView(View): self.title = "Settings" # Set up the next nested level of menuing - self.button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - self.button_data.append(self.IO_TEST) - self.button_data.append(self.DONATE) + button_data.append(self.IO_TEST) + button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: self.title = "Advanced" @@ -53,13 +55,11 @@ class SettingsMenuView(View): self.title = "Dev Options" self.next_destination = None - - def run(self): selected_menu_num = self.run_screen( ButtonListScreen, title=self.title, is_button_text_centered=False, - button_data=self.button_data, + button_data=button_data, selected_button=self.selected_button, scroll_y_initial_offset=self.initial_scroll, ) @@ -75,17 +75,17 @@ class SettingsMenuView(View): else: return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) - elif selected_menu_num == len(self.settings_entries): + elif selected_menu_num == len(settings_entries): return self.next_destination - elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.IO_TEST: + elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) - elif len(self.button_data) > selected_menu_num and self.button_data[selected_menu_num] == self.DONATE: + elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.DONATE: return Destination(DonateView) else: - return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=self.settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) + return Destination(SettingsEntryUpdateSelectionView, view_args=dict(attr_name=settings_entries[selected_menu_num].attr_name, parent_initial_scroll=initial_scroll)) From 50e3a3698f62b870f4eb011c0c65665f1c8b3fc5 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:48:48 -0700 Subject: [PATCH 29/67] Update settings_views.py --- src/seedsigner/views/settings_views.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 49753eeb..90a056cb 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -26,11 +26,11 @@ class SettingsMenuView(View): ) button_data=[e.display_name for e in settings_entries] - self.selected_button = 0 + selected_button = 0 if self.selected_attr: for i, entry in enumerate(settings_entries): if entry.attr_name == self.selected_attr: - self.selected_button = i + selected_button = i break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: @@ -60,7 +60,7 @@ class SettingsMenuView(View): title=self.title, is_button_text_centered=False, button_data=button_data, - selected_button=self.selected_button, + selected_button=selected_button, scroll_y_initial_offset=self.initial_scroll, ) From 63ff9c06d909d71b78058117c721ffc3cf73c628 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:52:45 -0700 Subject: [PATCH 30/67] Update settings_views.py --- src/seedsigner/views/settings_views.py | 48 +++++++++++++------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 90a056cb..4ccfe66a 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -34,30 +34,30 @@ class SettingsMenuView(View): break if self.visibility == SettingsConstants.VISIBILITY__GENERAL: - self.title = "Settings" + title = "Settings" # Set up the next nested level of menuing button_data.append(("Advanced", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) + next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) button_data.append(self.IO_TEST) button_data.append(self.DONATE) elif self.visibility == SettingsConstants.VISIBILITY__ADVANCED: - self.title = "Advanced" + title = "Advanced" # So far there are no real Developer options; disabling for now - # self.button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) - # self.next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) - self.next_destination = None + # button_data.append(("Developer Options", None, None, None, SeedSignerCustomIconConstants.SMALL_CHEVRON_RIGHT)) + # next_destination = Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__DEVELOPER}) + next_destination = None elif self.visibility == SettingsConstants.VISIBILITY__DEVELOPER: - self.title = "Dev Options" - self.next_destination = None + title = "Dev Options" + next_destination = None selected_menu_num = self.run_screen( ButtonListScreen, - title=self.title, + title=title, is_button_text_centered=False, button_data=button_data, selected_button=selected_button, @@ -76,7 +76,7 @@ class SettingsMenuView(View): return Destination(SettingsMenuView, view_args={"visibility": SettingsConstants.VISIBILITY__ADVANCED}) elif selected_menu_num == len(settings_entries): - return self.next_destination + return next_destination elif len(button_data) > selected_menu_num and button_data[selected_menu_num] == self.IO_TEST: return Destination(IOTestView) @@ -100,19 +100,21 @@ class SettingsEntryUpdateSelectionView(View): self.selected_button = selected_button self.parent_initial_scroll = parent_initial_scroll - self.button_data = [] - self.checked_buttons = [] - self.initial_value = self.settings.get_value(self.settings_entry.attr_name) + + def run(self): + button_data = [] + checked_buttons = [] + initial_value = self.settings.get_value(self.settings_entry.attr_name) for i, value in enumerate(self.settings_entry.selection_options): if type(value) == tuple: value, display_name = value else: display_name = value - self.button_data.append(display_name) + button_data.append(display_name) - if (type(self.initial_value) == list and value in self.initial_value) or value == self.initial_value: - self.checked_buttons.append(i) + if (type(initial_value) == list and value in initial_value) or value == initial_value: + checked_buttons.append(i) if self.selected_button is None: # Highlight the selection (for multiselect highlight the first @@ -121,16 +123,14 @@ class SettingsEntryUpdateSelectionView(View): if self.selected_button is None: self.selected_button = 0 - - - def run(self): + ret_value = self.run_screen( settings_screens.SettingsEntryUpdateSelectionScreen, display_name=self.settings_entry.display_name, help_text=self.settings_entry.help_text, - button_data=self.button_data, + button_data=button_data, selected_button=self.selected_button, - checked_buttons=self.checked_buttons, + checked_buttons=checked_buttons, settings_entry_type=self.settings_entry.type, ) @@ -154,8 +154,8 @@ class SettingsEntryUpdateSelectionView(View): destination = settings_menu_view_destination elif self.settings_entry.type == SettingsConstants.TYPE__MULTISELECT: - updated_value = list(self.initial_value) - if ret_value not in self.checked_buttons: + updated_value = list(initial_value) + if ret_value not in checked_buttons: # This is a new selection to add updated_value.append(value) else: @@ -164,7 +164,7 @@ class SettingsEntryUpdateSelectionView(View): else: # All other types are single selects (e.g. Enabled/Disabled, SELECT_1) - if value == self.initial_value: + if value == initial_value: # No change, return to menu return settings_menu_view_destination else: From 78904fd7ff8e35cb492489cd85f3af1bdb3afe46 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 19:54:47 -0700 Subject: [PATCH 31/67] Update settings_views.py --- src/seedsigner/views/settings_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/views/settings_views.py b/src/seedsigner/views/settings_views.py index 4ccfe66a..ccb19150 100644 --- a/src/seedsigner/views/settings_views.py +++ b/src/seedsigner/views/settings_views.py @@ -102,9 +102,9 @@ class SettingsEntryUpdateSelectionView(View): def run(self): + initial_value = self.settings.get_value(self.settings_entry.attr_name) button_data = [] checked_buttons = [] - initial_value = self.settings.get_value(self.settings_entry.attr_name) for i, value in enumerate(self.settings_entry.selection_options): if type(value) == tuple: value, display_name = value From 2e91267a43af7b6abe3498f0dcca9e820558e24e Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 20:02:23 -0700 Subject: [PATCH 32/67] Update tools_views.py --- src/seedsigner/views/tools_views.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/tools_views.py b/src/seedsigner/views/tools_views.py index 6e5404c5..6cd0c0bd 100644 --- a/src/seedsigner/views/tools_views.py +++ b/src/seedsigner/views/tools_views.py @@ -433,10 +433,11 @@ class ToolsAddressExplorerSelectSourceView(View): def run(self): seeds = self.controller.storage.seeds - button_data = [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] + button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) - button_data.insert(0, (button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + button_data = button_data + [self.SCAN_SEED, self.SCAN_DESCRIPTOR, self.TYPE_12WORD, self.TYPE_24WORD] selected_menu_num = self.run_screen( ButtonListScreen, From 2aa851dd493c0f8bddaec4bdcb52844f32257b4d Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 23 Apr 2023 20:07:06 -0700 Subject: [PATCH 33/67] Update view.py --- src/seedsigner/views/view.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/seedsigner/views/view.py b/src/seedsigner/views/view.py index f9a40f89..de4bac71 100644 --- a/src/seedsigner/views/view.py +++ b/src/seedsigner/views/view.py @@ -1,5 +1,5 @@ from dataclasses import dataclass -from typing import Callable, List, Type, Union +from typing import Type from seedsigner.gui.components import FontAwesomeIconConstants from seedsigner.gui.screens import RET_CODE__POWER_BUTTON, RET_CODE__BACK_BUTTON @@ -59,7 +59,7 @@ class View: self.screen = None - def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> Union[int,str]: + def run_screen(self, Screen_cls: Type[BaseScreen], **kwargs) -> int | str: """ Instantiates the provided Screen_cls and runs its interactive display. Returns the user's input upon completion. @@ -262,7 +262,7 @@ class NotYetImplementedView(View): class UnhandledExceptionView(View): - def __init__(self, error: List[str]): + def __init__(self, error: list[str]): self.error = error From f9d37879428b98bf5227592e906f6601b8f54140 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Mon, 22 May 2023 09:49:46 -0400 Subject: [PATCH 34/67] handle exceptions which have no message --- src/seedsigner/controller.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 4976d6f7..94cc2f6f 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -309,7 +309,10 @@ class Controller(Singleton): # So we extract the Exception type and trim off any "foo.bar." namespacing: last_line = traceback.format_exc().splitlines()[-1] exception_type = last_line.split(":")[0].split(".")[-1] - exception_msg = last_line.split(":")[1] + if ":" in last_line: + exception_msg = last_line.split(":")[1] + else: + exception_msg = "" # Scan for the last debugging line that includes a line number reference line_info = None From dae4c476a5b20908495e1fa80005d6e42260fab3 Mon Sep 17 00:00:00 2001 From: overcat <4catcode@gmail.com> Date: Mon, 22 May 2023 22:12:37 +0800 Subject: [PATCH 35/67] Fix seed word letter suggestion bug --- src/seedsigner/gui/screens/seed_screens.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/seedsigner/gui/screens/seed_screens.py b/src/seedsigner/gui/screens/seed_screens.py index ab5d8ed4..74f0cacf 100644 --- a/src/seedsigner/gui/screens/seed_screens.py +++ b/src/seedsigner/gui/screens/seed_screens.py @@ -379,7 +379,8 @@ class SeedMnemonicEntryScreen(BaseTopNavScreen): self.keyboard.render_keys() - elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN: + elif input in HardwareButtonsConstants.KEYS__LEFT_RIGHT_UP_DOWN \ + or input in (Keyboard.ENTER_TOP, Keyboard.ENTER_BOTTOM): if ret_val in self.possible_alphabet: # Live joystick movement; haven't locked this new letter in yet. # Replace the last letter w/the currently selected one. But don't From 3aab03801bde36bc9b9d211529e3d3e7dfe8adce Mon Sep 17 00:00:00 2001 From: Jean Do Date: Mon, 22 May 2023 11:54:25 -0400 Subject: [PATCH 36/67] dire-warning detour if invalid BIP-85 child index --- src/seedsigner/views/seed_views.py | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index aca803ba..8643699a 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -942,6 +942,16 @@ class SeedBIP85SelectChildIndexView(View): if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) + if not 0 <= int(ret) < 2**31: + return Destination( + SeedBIP85InvalidChildIndexView, + view_args=dict( + seed_num=self.seed_num, + num_words=self.num_words + ), + skip_current_view=True + ) + return Destination( SeedWordsWarningView, view_args=dict( @@ -951,6 +961,32 @@ class SeedBIP85SelectChildIndexView(View): ) +class SeedBIP85InvalidChildIndexView(View): + def __init__(self, seed_num: int, num_words: int): + super().__init__() + self.seed_num = seed_num + self.num_words = num_words + + + def run(self): + DireWarningScreen( + title="BIP-85 Index Error", + show_back_button=False, + status_headline=f"Invalid Child Index", + text=f"BIP-85 Child Index must be between 0 and {2**31-1}.", + button_data=["Try Again"] + ).display() + + return Destination( + SeedBIP85SelectChildIndexView, + view_args=dict( + seed_num=self.seed_num, + num_words=self.num_words + ), + skip_current_view=True + ) + + """**************************************************************************** Seed Words Backup Test From 5abd9fadf29c713d0e8c555fb0dc231513603ef6 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 4 Jun 2023 11:32:21 -0400 Subject: [PATCH 37/67] Update manual_installation.md --- docs/manual_installation.md | 193 +++++++++++++++++++++++------------- 1 file changed, 126 insertions(+), 67 deletions(-) diff --git a/docs/manual_installation.md b/docs/manual_installation.md index 3fcdc6ba..00f0f5c4 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -15,11 +15,12 @@ For the following steps you'll need to either connect a keyboard & monitor to th ### Configure the Pi First things first, verify that you are using the correct version of the Raspberry Pi Lite operating system by typing the command: -``` +```bash cat /etc/os-release ``` + The output of this command should match the following text: -``` +```bash PRETTY_NAME="Raspbian GNU/Linux 10 (buster)" NAME="Raspbian GNU/Linux" VERSION_ID="10" @@ -33,7 +34,7 @@ BUG_REPORT_URL="http://www.raspbian.org/RaspbianBugs" ``` Now launch the Raspberry Pi's System Configuration tool using the command: -``` +```bash sudo raspi-config ``` @@ -45,6 +46,8 @@ Set the following: * `Locale`: arrow up and down through the list and select or deselect languages with the spacebar. * Deselect the default language option that is selected * Select `en_US.UTF-8 UTF-8` for US English + * Use the `TAB` button to select `Ok` and press `ENTER` + * On the next screen select `en_US.UTF-8` for the default locale * You will also need to configure the WiFi settings if you are using the #1 option above to connect to the internet When you exit the System Configuration tool, you will be prompted to reboot the system; allow the system to reboot and continue with these instructions. @@ -52,7 +55,7 @@ When you exit the System Configuration tool, you will be prompted to reboot the ### Change the default password Change the system's default password from the default "raspberry". Run the command: -``` +```bash passwd ``` You will be prompted to enter the current password ("raspberry") and then to enter a new password twice. In our prepared release image, the password used is `AirG@pped!`. @@ -60,7 +63,7 @@ You will be prompted to enter the current password ("raspberry") and then to ent ### Install python3.10 ```bash -# install compiler dependencies +# install compiler dependencies; takes ~1 minute on a Pi Zero 1.3 # openssl, libssl-dev: ssl support when pip fetches packages # libsqlite3-dev: required by `coverage` sudo apt update && sudo apt install -y build-essential zlib1g-dev \ @@ -72,26 +75,33 @@ wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz tar -xzvf Python-3.10.10.tgz cd Python-3.10.10 -# configure takes ~6 minutes to check what is available +# configure takes ~6 minutes on a Pi Zero 1.3 to check what is available ./configure --enable-optimizations -# compiling takes ~80 minutes(!!) on the Pi Zero 1.3 +# compiling takes ~80 minutes(!!) on a Pi Zero 1.3 sudo make altinstall # cleanup cd .. -rm -rf Python-3.10.10* +sudo rm -rf Python-3.10.10* # Make python3.10 the default version sudo update-alternatives --install /usr/bin/python python /usr/local/bin/python3.10 1 sudo update-alternatives --install /usr/bin/python3 python3 /usr/local/bin/python3.10 1 ``` +Manually re-install `python3-apt` to avoid error messages in later steps: +```bash +sudo apt remove --purge python3-apt -y +sudo apt autoremove -y +sudo apt install python3-apt -y +``` + ### Install dependencies Copy this entire box and run it as one command (will take 15-20min to complete): -``` -sudo apt update && sudo apt install -y wiringpi python3-pip \ +```bash +sudo apt update && time sudo apt install -y wiringpi python3-pip \ python3-numpy python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \ git python3-opencv python3-picamera libatlas-base-dev qrencode ``` @@ -102,17 +112,17 @@ sudo apt update && sudo apt install -y wiringpi python3-pip \ SeedSigner requires `zbar` at 0.23.x or higher. Download the binary: -``` +```bash curl -L http://raspbian.raspberrypi.org/raspbian/pool/main/z/zbar/libzbar0_0.23.90-1_armhf.deb --output libzbar0_0.23.90-1_armhf.deb ``` And then install it: -``` +```bash sudo apt install ./libzbar0_0.23.90-1_armhf.deb ``` Cleanup: -``` +```bash rm libzbar0_0.23.90-1_armhf.deb ``` @@ -120,7 +130,7 @@ rm libzbar0_0.23.90-1_armhf.deb This library "provides functions for reading digital inputs and setting digital outputs, using SPI and I2C, and for accessing the system timers." Run each of the following individual steps: -``` +```bash wget http://www.airspayce.com/mikem/bcm2835/bcm2835-1.60.tar.gz tar zxvf bcm2835-1.60.tar.gz cd bcm2835-1.60/ @@ -132,12 +142,12 @@ sudo rm -rf bcm2835-1.60 ``` ### Set up `virtualenv` -``` +```bash python -m pip install virtualenvwrapper ``` Edit your bash profile with the command `nano ~/.profile` and add the following to the end: -``` +```bash export WORKON_HOME=$HOME/.envs export VIRTUALENVWRAPPER_PYTHON=/usr/bin/python3 source /home/pi/.local/bin/virtualenvwrapper.sh @@ -145,58 +155,27 @@ source /home/pi/.local/bin/virtualenvwrapper.sh Then `CTRL-X` and `y` to exit and save changes. Now create the python virtualenv for SeedSigner with these two commands: -``` +```bash source ~/.profile mkvirtualenv seedsigner-env ``` For convenience you can configure your `.profile` to auto-activate the SeedSigner virtualenv when you ssh in. Once again `nano ~/.profile` and add at the end: -``` +```bash workon seedsigner-env ``` -Optional: If you're going to be testing new code on the SeedSigner, you'll find yourself often needing to kill the SeedSigner code that automatically runs at startup (we'll be configuring this further down). As an extra convenience you can list the process id so that you can then kill it from the terminal: -``` -ps aux | grep main.py -``` - -Save your changes with `CTRL-X` and `y`. - -Now when you `ssh` in you'll see something like: -``` -pi 297 65.4 9.7 74096 36736 ? Rsl 09:26 10:29 /home/pi/.envs/seedsigner-env/bin/python main.py -pi 857 0.0 0.4 7332 1876 pts/0 S+ 09:42 0:00 grep --color=auto main.py -``` - -The top line is our SeedSigner code running. To stop it, run: -``` -kill 297 -``` -Where `297` is the process id listed in the output above (it'll be different each time). - ### Download the SeedSigner code: -``` +```bash git clone https://github.com/SeedSigner/seedsigner cd seedsigner ``` -If you want to run a specific branch within the main SeedSigner repo, switch to it with: -``` -git checkout yourtargetbranch -``` - -And if you want to test a pull request (PR), for example PR #123: -``` -git fetch origin pull/123/head:pr_123 -git checkout pr_123 -``` - -where `pr_123` is any name you want to give to the new branch in your local repo that will hold the PR. - ### Install Python `pip` dependencies: ```bash +# Takes 1hr 45min on a Pi Zero 1.3 pip install -r requirements.txt ``` @@ -207,13 +186,37 @@ The fork is required because the main `pyzbar` repo has been abandoned. This [gi * [PR 76](https://github.com/NaturalHistoryMuseum/pyzbar/pull/76/files): enables scanning to continue even when a null byte (`x\00`) is found. * [PR 82](https://github.com/NaturalHistoryMuseum/pyzbar/pull/82): enable `zbar`'s new binary mode. Note that this PR has a trivial bug that was fixed in our fork. -### Configure `systemd` to run SeedSigner at boot: + +### Finish configuring the virtualenv +Set the SeedSigner `src/` directory as the project directory for the virtualenv (this is where the virtualenv will take you when you activate it): +```bash +cd src +setvirtualenvproject ``` + +Test it out: +```bash +# exit the virtualenv +deactivate + +# change dirs to somewhere else +cd ~ + +# activate the virtualenv +workon seedsigner-env + +# you should now be back in the SeedSigner src/ directory +pwd +``` + + +### Configure `systemd` to run SeedSigner at boot: +```bash sudo nano /etc/systemd/system/seedsigner.service ``` Add the following contents to the text file that was created: -``` +```ini [Unit] Description=Seedsigner @@ -229,46 +232,96 @@ WantedBy=multi-user.target _Note: For local dev you'll want to edit the `Restart=always` line to `Restart=no`. This way when your dev code crashes it won't keep trying to restart itself. Note that the UI "Reset" will no longer work when auto-restarts are disabled._ -_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you're better off disabling the `systemd` SeedSigner service and just directly running the app so you can see all the debugging output live._ +_Note: Debugging output is completely wiped via routing the output to `/dev/null 2>&1`. When working in local dev, you'll `kill` the `systemd` SeedSigner service and just directly run the code on demand so you can see all the debugging output live._ Use `CTRL-X` and `y` to exit and save changes. Configure the service to start running (this will restart the seedsigner code automatically at startup and if it crashes): -``` +```bash sudo systemctl enable seedsigner.service ``` Now reboot the Raspberry Pi: -``` +```bash sudo reboot ``` After the Raspberry Pi reboots, you should see the SeedSigner splash screen and the SeedSigner menu subsequently appear on the LCD screen (note that it can take up to 60 seconds for the menu to appear). + +#### Optional: kill `systemd` SeedSigner process on login +If you're going to be testing new code on the device, you'll find yourself often needing to kill the SeedSigner instance that `systemd` automatically runs at startup. + +You can configure your `~/.profile` to find and kill the SeedSigner process when you ssh in. + +`nano ~/.profile` and add at the end: +```bash +# Find the SeedSigner process and kill it +kill $(ps aux | grep '[m]ain.py' | awk '{print $2}') +``` + + ### Further OS modifications Disable and remove the system's virtual memory / swap file with the commands: -``` + +```bash sudo apt remove dphys-swapfile -y sudo apt autoremove -y sudo rm /var/swap ``` -## Local testing and development -For those who will use the SeedSigner installation for testing/development, it can be helpful to change the system's host name so it doesn't potentially conflict with other Raspberry Pis that may already be present on your network. (For those who don't plan to use the installation for testing or development, you can skip this portion of the process.) To change the host name first edit the "hostname" with the command: +## Manually start the SeedSigner code +```bash +# activate the virtualenv if you haven't already +workon seedsigner-env + +# You should now be in the SeedSigner src/ directory. List its contents: +ls + +# You should see the main.py file. Run it: +python main.py ``` + + +## Local testing and development + +### Run specific branches or PRs +The default branch is `dev`. If you want to run a specific release tag or a specific branch: +```bash +# release tag for v0.6.0: +git checkout 0.6.0 +``` + +And if you want to test a pull request (PR), for example PR #123: +```bash +git fetch origin pull/123/head:pr_123 +git checkout pr_123 +``` + +where `pr_123` is any name you want to give to the new branch in your local repo that will hold the PR. + + +### Change the host name +For those who will use the SeedSigner installation for testing/development, it can be helpful to change the system's host name so it doesn't potentially conflict with other Raspberry Pis that may already be present on your network. (For those who don't plan to use the installation for testing or development, you can skip this portion of the process.) To change the host name first edit the "hostname" with the command: + +```bash sudo nano /etc/hostname ``` -and change "raspberrypi" to "seedsigner" (or another name). Use `CTRL-X` and `y` to exit and save changes. You'll also need to edit the "hosts" file with the command: -``` + +and change "raspberrypi" to "seedsigner" (or another name). Use `CTRL-X` and `y` to exit and save changes. + +You'll also need to edit the "hosts" file with the command: +```bash sudo nano /etc/hosts ``` + and change "raspberrypi" to "seedsigner" (or the other name you previously chose). Use `CTRL-X` and `y` to exit and save changes. ### Set a static IP Your local machine that `ssh`s into the SeedSigner can sometimes get confused if you're connecting to different SeedSigners that are all identified as `pi@seedsigner.local`. In this case it helps to set a static ip and just `ssh` directly to that instead. First find your current `nameserver`: -``` +```bash sudo cat /etc/resolv.conf ``` @@ -290,21 +343,26 @@ static domain_name_servers=192.168.1.254 `CTRL-X` and `y` to save changes. After your next reboot, access this SeedSigner using its new static ip: -``` +```bash +# Use the static ip you set above: ssh pi@192.168.1.200 + +# But the hostname will still work, too: +ssh pi@seedsigner.local ``` ### More convenient `ssh` access: Power SeedSigner devs will find themselves connecting to a lot of different SeedSigners. This can cause headaches with `ssh`'s built-in protections; a different device that uses the same `ssh` credentials is normally a potential spoofing attack. But we're doing this to ourselves on purpose and so we can carve out exceptions. On your local machine, run `nano ~/.ssh/config` and add to the end: -``` +```conf host seedsigner.local StrictHostKeyChecking no UserKnownHostsFile /dev/null User pi LogLevel QUIET +# Set this to the static ip you set above: host 192.168.1.200 StrictHostKeyChecking no UserKnownHostsFile /dev/null @@ -323,7 +381,7 @@ The second entry does the same for a specific static ip; you'll want this if you You can also configure the SeedSigner so that you don't have to enter the `pi` password when you `ssh` in. run `ssh-copy-id` with the same values that you connect via `ssh`: -``` +```bash ssh-copy-id pi@seedsigner.local # or if you're connecting over static ip, something like: @@ -337,7 +395,7 @@ _Note: If you don't have any ssh keys on your local machine, you'll need to crea ## Disable wifi/Bluetooth when using other Raspi boards If you plan to use your installation on a Raspberry Pi that is not a Zero version 1.3, but rather on a Raspberry Pi that has WiFi and Bluetooth capabilities, it is a good idea to disable the following WiFi & Bluetooth, as well as other relevant services (assuming you are not creating this installation for testing/development purposes). Enter the followiing commands to disable WiFi, Bluetooth, & other relevant services: -``` +```bash sudo systemctl disable bluetooth.service sudo systemctl disable wpa_supplicant.service sudo systemctl disable dhcpcd.service @@ -346,12 +404,13 @@ sudo systemctl disable networking.service sudo systemctl disable dphys-swapfile.service sudo ifconfig wlan0 down ``` + Please note that if you are using WiFi to connect/interact with your Raspberry Pi, the last command will sever that connection. You can now safely power the Raspberry Pi off from the SeedSigner main menu. If you do not plan to use your installation for testing/development, it is also a good idea to disable WiFi and Bluetooth by editing the config.txt file found in the installation's "boot" partition. You can add the following text to the end of that file with any simple text editor (Windows: Notepad, Mac: TextEdit, Linux: nano): -``` +```ini dtoverlay=disable-bt dtoverlay=pi3-disable-wifi ``` From e64c2dc5ba14f71720e50e4a15c5900556e5d3ad Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 4 Jun 2023 11:42:19 -0400 Subject: [PATCH 38/67] Update manual_installation.md --- docs/manual_installation.md | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/manual_installation.md b/docs/manual_installation.md index 00f0f5c4..addba92c 100644 --- a/docs/manual_installation.md +++ b/docs/manual_installation.md @@ -64,8 +64,8 @@ You will be prompted to enter the current password ("raspberry") and then to ent ### Install python3.10 ```bash # install compiler dependencies; takes ~1 minute on a Pi Zero 1.3 -# openssl, libssl-dev: ssl support when pip fetches packages -# libsqlite3-dev: required by `coverage` +# * openssl, libssl-dev: ssl support when pip fetches packages +# * libsqlite3-dev: required by `coverage` sudo apt update && sudo apt install -y build-essential zlib1g-dev \ libncurses5-dev libgdbm-dev libnss3-dev openssl libssl-dev \ libreadline-dev libffi-dev wget libsqlite3-dev @@ -75,7 +75,7 @@ wget https://www.python.org/ftp/python/3.10.10/Python-3.10.10.tgz tar -xzvf Python-3.10.10.tgz cd Python-3.10.10 -# configure takes ~6 minutes on a Pi Zero 1.3 to check what is available +# Takes ~6 minutes on a Pi Zero 1.3 to check what is available ./configure --enable-optimizations # compiling takes ~80 minutes(!!) on a Pi Zero 1.3 @@ -90,7 +90,7 @@ sudo update-alternatives --install /usr/bin/python python /usr/local/bin/python3 sudo update-alternatives --install /usr/bin/python3 python3 /usr/local/bin/python3.10 1 ``` -Manually re-install `python3-apt` to avoid error messages in later steps: +Manually re-install `python3-apt` to avoid error messages in later steps (though, ironically, you will see the "ModuleNotFoundError: No module named 'apt_pkg'" error message during the `apt remove` step): ```bash sudo apt remove --purge python3-apt -y sudo apt autoremove -y @@ -99,9 +99,9 @@ sudo apt install python3-apt -y ### Install dependencies -Copy this entire box and run it as one command (will take 15-20min to complete): +Copy this entire box and run it as one command (will take a while to complete): ```bash -sudo apt update && time sudo apt install -y wiringpi python3-pip \ +sudo apt update && sudo apt install -y wiringpi python3-pip \ python3-numpy python-pil libjpeg-dev zlib1g-dev libopenjp2-7 \ git python3-opencv python3-picamera libatlas-base-dev qrencode ``` @@ -154,7 +154,7 @@ source /home/pi/.local/bin/virtualenvwrapper.sh ``` Then `CTRL-X` and `y` to exit and save changes. -Now create the python virtualenv for SeedSigner with these two commands: +Now create the virtualenv for SeedSigner: ```bash source ~/.profile mkvirtualenv seedsigner-env @@ -280,6 +280,8 @@ ls # You should see the main.py file. Run it: python main.py + +# To kill the process, use CTRL-C ``` From acc8517faccb8f905006c74260d42ce18a48fab9 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 6 Jun 2023 13:44:54 -0400 Subject: [PATCH 39/67] added test_handle_exception() to test_controller.py --- src/seedsigner/controller.py | 2 ++ tests/test_controller.py | 55 ++++++++++++++++++++++++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 94cc2f6f..8f68c264 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -309,6 +309,8 @@ class Controller(Singleton): # So we extract the Exception type and trim off any "foo.bar." namespacing: last_line = traceback.format_exc().splitlines()[-1] exception_type = last_line.split(":")[0].split(".")[-1] + + # Extract the error message, if there is one if ":" in last_line: exception_msg = last_line.split(":")[1] else: diff --git a/tests/test_controller.py b/tests/test_controller.py index bd8b2c18..4dda4f98 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -43,6 +43,61 @@ def test_singleton_get_instance_preserves_state(reset_controller): assert controller.unverified_address == "123abc" +def test_handle_exception(reset_controller): + """ Handle exceptions that get caught by the controller """ + + def process_exception_return_error(exception_type, error_msg=None): + try: + if error_msg: + raise exception_type(error_msg) + else: + raise exception_type() + except Exception as e: + destination = controller.handle_exception(e) + return destination.view_args["error"] + + def is_valid_error_structure(error, exception_type, exception_msg): + """ + 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] + """ + if len(error) != 3: + return False + + if error[0] not in str(exception_type): + return False + + if type(error[1]) != str: + return False + + if exception_msg: + if exception_msg not in error[2]: + return False + else: + if error[2] != "": + return False + + return True + + # Initialize the controller + controller = Controller.get_instance() + + # Test exceptions with an exception_msg + error = process_exception_return_error(Exception, "foo") + assert is_valid_error_structure(error, Exception, "foo") + + error = process_exception_return_error(KeyError, "key not found") + assert is_valid_error_structure(error, KeyError, "key not found") + + # Test exceptions without an exception_msg + error = process_exception_return_error(Exception, "") + assert is_valid_error_structure(error, Exception, "") + + error = process_exception_return_error(Exception, None) + assert is_valid_error_structure(error, Exception, None) + + def test_missing_settings_get_defaults(reset_controller): """ Should gracefully handle all missing fields from `settings.json` """ From f683bc355e609cefea5c515e97a473eae826eb45 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 6 Jun 2023 14:46:37 -0400 Subject: [PATCH 40/67] 2 helpers become 1, looping over test cases --- tests/test_controller.py | 63 ++++++++++++++++------------------------ 1 file changed, 25 insertions(+), 38 deletions(-) diff --git a/tests/test_controller.py b/tests/test_controller.py index 4dda4f98..2fce0387 100644 --- a/tests/test_controller.py +++ b/tests/test_controller.py @@ -46,56 +46,43 @@ def test_singleton_get_instance_preserves_state(reset_controller): def test_handle_exception(reset_controller): """ Handle exceptions that get caught by the controller """ - def process_exception_return_error(exception_type, error_msg=None): - try: - if error_msg: - raise exception_type(error_msg) - else: - raise exception_type() - except Exception as e: - destination = controller.handle_exception(e) - return destination.view_args["error"] - - def is_valid_error_structure(error, exception_type, exception_msg): + 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] """ - if len(error) != 3: - return False - - if error[0] not in str(exception_type): - return False - - if type(error[1]) != str: - return False + 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: - if exception_msg not in error[2]: - return False + assert exception_msg in error[2] else: - if error[2] != "": - return False - - return True + assert error[2] == "" # Initialize the controller controller = Controller.get_instance() - # Test exceptions with an exception_msg - error = process_exception_return_error(Exception, "foo") - assert is_valid_error_structure(error, Exception, "foo") - - error = process_exception_return_error(KeyError, "key not found") - assert is_valid_error_structure(error, KeyError, "key not found") - - # Test exceptions without an exception_msg - error = process_exception_return_error(Exception, "") - assert is_valid_error_structure(error, Exception, "") - - error = process_exception_return_error(Exception, None) - assert is_valid_error_structure(error, Exception, None) + 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): From 97a49908f8bdba2a56418541ba56487edf893dc4 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 6 Jun 2023 15:33:10 -0400 Subject: [PATCH 41/67] updated comment --- src/seedsigner/hardware/ST7789.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/hardware/ST7789.py b/src/seedsigner/hardware/ST7789.py index 0d31b9f1..19ebaaa3 100644 --- a/src/seedsigner/hardware/ST7789.py +++ b/src/seedsigner/hardware/ST7789.py @@ -153,7 +153,7 @@ class ST7789(object): if imwidth != self.width or imheight != self.height: raise ValueError('Image must be same dimensions as display \ ({0}x{1}).' .format(self.width, self.height)) - # 24-bit RGB-8:8:8 becomes 16-bit BGR-5:6:5 plus endianness toggle. + # convert 24-bit RGB-8:8:8 to GBRg-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5 arr = array.array("H", Image.convert("BGR;16").tobytes()) arr.byteswap() pix = arr.tobytes() From 6a2e02171314d7aec25fbbd27d7dd7fe840b2ab5 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Wed, 7 Jun 2023 08:31:37 -0400 Subject: [PATCH 42/67] gBRG: G=most-significant, g=lesser not least --- src/seedsigner/hardware/ST7789.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/hardware/ST7789.py b/src/seedsigner/hardware/ST7789.py index 19ebaaa3..c9cb1cdb 100644 --- a/src/seedsigner/hardware/ST7789.py +++ b/src/seedsigner/hardware/ST7789.py @@ -153,7 +153,7 @@ class ST7789(object): if imwidth != self.width or imheight != self.height: raise ValueError('Image must be same dimensions as display \ ({0}x{1}).' .format(self.width, self.height)) - # convert 24-bit RGB-8:8:8 to GBRg-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5 + # convert 24-bit RGB-8:8:8 to gBRG-3:5:5:3; then per-pixel byteswap to 16-bit RGB-5:6:5 arr = array.array("H", Image.convert("BGR;16").tobytes()) arr.byteswap() pix = arr.tobytes() From def08fab59089b5ab6ac7a9687defdd291498d0a Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sun, 11 Jun 2023 10:38:21 -0400 Subject: [PATCH 43/67] added tests for embit_utils.get_xpub() --- tests/test_embit_utils.py | 168 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 168 insertions(+) create mode 100644 tests/test_embit_utils.py diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py new file mode 100644 index 00000000..a93353b1 --- /dev/null +++ b/tests/test_embit_utils.py @@ -0,0 +1,168 @@ +import pytest + +from seedsigner.models.settings_definition import SettingsConstants as SC +from seedsigner.helpers import embit_utils + + +def test_get_standard_derivation_path(): + """tests seedsigner.helpers.embit_utils.get_standard_derivation_path()""" + + vectors_args_expected = { + # single sig + tuple(): "m/84'/0'/0'", + (SC.MAINNET,): "m/84'/0'/0'", + (SC.MAINNET, SC.SINGLE_SIG, ): "m/84'/0'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.NATIVE_SEGWIT): "m/84'/1'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.NESTED_SEGWIT): "m/49'/1'/0'", + + (SC.MAINNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/0'/0'", + (SC.TESTNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'", + (SC.REGTEST, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'", + + # multi sig + (SC.MAINNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/0'/0'/2'", + (SC.TESTNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'", + (SC.REGTEST, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'", + + (SC.MAINNET, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/0'/0'/1'", + (SC.TESTNET, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/1'/0'/1'", + (SC.REGTEST, SC.MULTISIG, SC.NESTED_SEGWIT): "m/48'/1'/0'/1'", + + (SC.MAINNET, SC.MULTISIG, SC.TAPROOT): Exception, + (SC.TESTNET, SC.MULTISIG, SC.TAPROOT): Exception, + (SC.REGTEST, SC.MULTISIG, SC.TAPROOT): Exception, + + # nonsense arguments + ("A",): Exception, + ("B", "A"): Exception, + ("C", "B", "A"): Exception, + (True,): Exception, + (False, True): Exception, + (tuple(),): Exception, + } + func = embit_utils.get_standard_derivation_path + + print() + for args, expected in vectors_args_expected.items(): + + # test successful returns + if type(expected) is str: + # call with positional args + print(f"asserting {func.__name__}(*{args}) == {repr(expected)}") + assert func(*args) == expected + + # call with named args + a_dict = {} + if len(args) == 1: a_dict = {'network': args[0]} + elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]} + elif len(args) == 3: a_dict = {'network': args[0], 'wallet_type': args[1], 'script_type': args[2]} + print(f"asserting {func.__name__}(**{a_dict}) == {repr(expected)}") + assert func(**a_dict) == expected + + # test exceptions + else: + # call with positional args + with pytest.raises(expected): + print(f"asserting {func.__name__}(*{args}) raises Exception") + func(*args) + + # call with named args + a_dict = {} + if len(args) == 1: a_dict = {'network': args[0]} + elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]} + elif len(args) == 3: a_dict = {'network': args[0], 'wallet_type': args[1], 'script_type': args[2]} + print(f"asserting {func.__name__}(**{a_dict}) raises Exception") + with pytest.raises(expected): + func(**a_dict) + +def test_get_xpub(): + """tests seedsigner.helpers.embit_utils.get_xpub()""" + + from binascii import unhexlify + from embit import bip39, bip32 + + # test vectors originate from: + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki + # https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + vector_seeds = ( + unhexlify("000102030405060708090a0b0c0d0e0f"), + unhexlify("fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"), + unhexlify("4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be"), + unhexlify("3ddd5602285899a946114506157c7997e5444528f3003f6134712147db19b678"), + bip39.mnemonic_to_seed('abandon '*11+'about'), + ) + + vectors_args_expected = { + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-1 + (vector_seeds[0], "m/", "main"): "xpub661MyMwAqRbcFtXgS5sYJABqqG9YLmC4Q1Rdap9gSE8NqtwybGhePY2gZ29ESFjqJoCu1Rupje8YtGqsefD265TMg7usUDFdp6W1EGMcet8", + (vector_seeds[0], "m/0'", "main"): "xpub68Gmy5EdvgibQVfPdqkBBCHxA5htiqg55crXYuXoQRKfDBFA1WEjWgP6LHhwBZeNK1VTsfTFUHCdrfp1bgwQ9xv5ski8PX9rL2dZXvgGDnw", + (vector_seeds[0], "m/0h/1", "main"): "xpub6ASuArnXKPbfEwhqN6e3mwBcDTgzisQN1wXN9BJcM47sSikHjJf3UFHKkNAWbWMiGj7Wf5uMash7SyYq527Hqck2AxYysAA7xmALppuCkwQ", + (vector_seeds[0], "m/0'/1/2h", "main"): "xpub6D4BDPcP2GT577Vvch3R8wDkScZWzQzMMUm3PWbmWvVJrZwQY4VUNgqFJPMM3No2dFDFGTsxxpG5uJh7n7epu4trkrX7x7DogT5Uv6fcLW5", + (vector_seeds[0], "m/0'/1/2h/2", "main"): "xpub6FHa3pjLCk84BayeJxFW2SP4XRrFd1JYnxeLeU8EqN3vDfZmbqBqaGJAyiLjTAwm6ZLRQUMv1ZACTj37sR62cfN7fe5JnJ7dh8zL4fiyLHV", + (vector_seeds[0], "m/0'/1/2h/2/1000000000", "main"): "xpub6H1LXWLaKsWFhvm6RVpEL9P4KfRZSW7abD2ttkWP3SSQvnyA8FSVqNTEcYFgJS2UaFcxupHiYkro49S8yGasTvXEYBVPamhGW6cFJodrTHy", + + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-2 + (vector_seeds[1], "m/", "main"): "xpub661MyMwAqRbcFW31YEwpkMuc5THy2PSt5bDMsktWQcFF8syAmRUapSCGu8ED9W6oDMSgv6Zz8idoc4a6mr8BDzTJY47LJhkJ8UB7WEGuduB", + (vector_seeds[1], "m/0", "main"): "xpub69H7F5d8KSRgmmdJg2KhpAK8SR3DjMwAdkxj3ZuxV27CprR9LgpeyGmXUbC6wb7ERfvrnKZjXoUmmDznezpbZb7ap6r1D3tgFxHmwMkQTPH", + (vector_seeds[1], "m/0/2147483647'", "main"): "xpub6ASAVgeehLbnwdqV6UKMHVzgqAG8Gr6riv3Fxxpj8ksbH9ebxaEyBLZ85ySDhKiLDBrQSARLq1uNRts8RuJiHjaDMBU4Zn9h8LZNnBC5y4a", + (vector_seeds[1], "m/0/2147483647h/1", "main"): "xpub6DF8uhdarytz3FWdA8TvFSvvAh8dP3283MY7p2V4SeE2wyWmG5mg5EwVvmdMVCQcoNJxGoWaU9DCWh89LojfZ537wTfunKau47EL2dhHKon", + (vector_seeds[1], "m/0/2147483647'/1/2147483646h", "main"): "xpub6ERApfZwUNrhLCkDtcHTcxd75RbzS1ed54G1LkBUHQVHQKqhMkhgbmJbZRkrgZw4koxb5JaHWkY4ALHY2grBGRjaDMzQLcgJvLJuZZvRcEL", + (vector_seeds[1], "m/0/2147483647h/1/2147483646'/2", "main"): "xpub6FnCn6nSzZAw5Tw7cgR9bi15UV96gLZhjDstkXXxvCLsUXBGXPdSnLFbdpq8p9HmGsApME5hQTZ3emM2rnY5agb9rXpVGyy3bdW6EEgAtqt", + + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-3 + (vector_seeds[2], "m/", "main"): "xpub661MyMwAqRbcEZVB4dScxMAdx6d4nFc9nvyvH3v4gJL378CSRZiYmhRoP7mBy6gSPSCYk6SzXPTf3ND1cZAceL7SfJ1Z3GC8vBgp2epUt13", + (vector_seeds[2], "m/0h", "main"): "xpub68NZiKmJWnxxS6aaHmn81bvJeTESw724CRDs6HbuccFQN9Ku14VQrADWgqbhhTHBaohPX4CjNLf9fq9MYo6oDaPPLPxSb7gwQN3ih19Zm4Y", + + # https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki#test-vector-4 + (vector_seeds[3], "m/", "main"): "xpub661MyMwAqRbcGczjuMoRm6dXaLDEhW1u34gKenbeYqAix21mdUKJyuyu5F1rzYGVxyL6tmgBUAEPrEz92mBXjByMRiJdba9wpnN37RLLAXa", + (vector_seeds[3], "m/0'", "main"): "xpub69AUMk3qDBi3uW1sXgjCmVjJ2G6WQoYSnNHyzkmdCHEhSZ4tBok37xfFEqHd2AddP56Tqp4o56AePAgCjYdvpW2PU2jbUPFKsav5ut6Ch1m", + (vector_seeds[3], "m/0h/1'", "main"): "xpub6BJA1jSqiukeaesWfxe6sNK9CCGaujFFSJLomWHprUL9DePQ4JDkM5d88n49sMGJxrhpjazuXYWdMf17C9T5XnxkopaeS7jGk1GyyVziaMt", + + # + # embit_utils.get_xpub() returns the generic bip32 version "xpub", NOT the zpub/Zpub/ypub/Ypub extended versions + # + + # https://github.com/bitcoin/bips/blob/master/bip-0084.mediawiki#test-vectors + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors + (vector_seeds[4], "m/84'/0'/0'", "main"): + bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs").to_base58(version=b'\x04\x88\xb2\x1e'), + + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors + (vector_seeds[4], "m/49'/0'/0'", "main"): + bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP").to_base58(version=b'\x04\x88\xb2\x1e'), + + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors + (vector_seeds[4], "m/49'/1'/0'", "test"): + bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY").to_base58(version=b'\x04\x35\x87\xcf'), + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors + (vector_seeds[4], "m/86'/0'/0'", "main"): "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ", + + } + + func = embit_utils.get_xpub + for args, expected in vectors_args_expected.items(): + print("\nasserting...") + + # test calling w/o last param (default is "main") + if args[2] == "main": + print(f' {func.__name__}({args[0]}, "{args[1]}") returns "{expected}"') + assert str(func(args[0], args[1])) == expected + + # test calling w/ ordered params + print(f' {func.__name__}(*{args}) returns "{expected}"') + assert str(func(*args)) == expected + + # test calling w/ named params + print(f' {func.__name__}(seed_bytes={args[0]}, derivation_path="{args[1]}", embit_network="{args[2]}") returns "{expected}"') + assert str(func(seed_bytes=args[0], derivation_path=args[1], embit_network=args[2])) == expected + From 72dd6b80a45b12c41a4017bbd6d206ebd5305d77 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sun, 11 Jun 2023 12:59:50 -0400 Subject: [PATCH 44/67] added tests for embit_utils.get_single_sig_address() --- tests/test_embit_utils.py | 75 ++++++++++++++++++++++++++++++++++++--- 1 file changed, 71 insertions(+), 4 deletions(-) diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index a93353b1..68bd967d 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -81,6 +81,7 @@ def test_get_standard_derivation_path(): with pytest.raises(expected): func(**a_dict) + def test_get_xpub(): """tests seedsigner.helpers.embit_utils.get_xpub()""" @@ -98,7 +99,7 @@ def test_get_xpub(): unhexlify("fffcf9f6f3f0edeae7e4e1dedbd8d5d2cfccc9c6c3c0bdbab7b4b1aeaba8a5a29f9c999693908d8a8784817e7b7875726f6c696663605d5a5754514e4b484542"), unhexlify("4b381541583be4423346c643850da4b320e46a87ae3d2a4e6da11eba819cd4acba45d239319ac14f863b8d5ab5a0d0c64d2e8a1e7d1457df2e5a3c51c73235be"), unhexlify("3ddd5602285899a946114506157c7997e5444528f3003f6134712147db19b678"), - bip39.mnemonic_to_seed('abandon '*11+'about'), + bip39.mnemonic_to_seed("abandon "*11 + "about"), ) vectors_args_expected = { @@ -155,14 +156,80 @@ def test_get_xpub(): # test calling w/o last param (default is "main") if args[2] == "main": - print(f' {func.__name__}({args[0]}, "{args[1]}") returns "{expected}"') + print(f' {func.__name__}({args[0]}, "{args[1]}") == "{expected}"') assert str(func(args[0], args[1])) == expected # test calling w/ ordered params - print(f' {func.__name__}(*{args}) returns "{expected}"') + print(f' {func.__name__}(*{args}) == "{expected}"') assert str(func(*args)) == expected # test calling w/ named params - print(f' {func.__name__}(seed_bytes={args[0]}, derivation_path="{args[1]}", embit_network="{args[2]}") returns "{expected}"') + print(f' {func.__name__}(seed_bytes={args[0]}, derivation_path="{args[1]}", embit_network="{args[2]}") == "{expected}"') assert str(func(seed_bytes=args[0], derivation_path=args[1], embit_network=args[2])) == expected + +def test_get_single_sig_address(): + from embit import bip32 + + # test vectors originate from: + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md + # https://iancoleman.io/bip39/ + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki + + vectors_args_expected = { + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of native segwit on mainnet) + (bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, False, "main"): + "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", + # jdlcdl: derived via iancoleman test vector for first change address of native segwit on mainnet + (bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, True, "main"): + "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el", + + # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of nested segwit on mainnet) + (bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, False, "main"): + "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", + # jdlcdl: derived via iancoleman test vector for first change address of nested segwit on mainnet + (bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, True, "main"): + "34K56kSjgUCUSD8GTtuF7c9Zzwokbs6uZ7", + + # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors (first payment address of nested segwit on testnet) + (bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, False, "test"): + "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2", + # jdlcdl: derived test vector for first change address of nested segwit on testnet + (bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, True, "test"): + "2MvdUi5o3f2tnEFh9yGvta6FzptTZtkPJC8", + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first payment address of taproot on mainnet) + (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, False, "main"): + "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (second payment address of taproot on mainnet) + (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 1, False, "main"): + "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", + + # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first change address of taproot on mainnet) + (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, True, "main"): + "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7", + + } + + func = embit_utils.get_single_sig_address + for args, expected in vectors_args_expected.items(): + print("\nasserting...") + + # test calling w/o optional params (defaults: script_type="nat", index=0, is_change=False, embit_network="main") + if args[1:5] == ("nat", 0, False, "main"): + print(f' {func.__name__}({args[0]}) == "{expected}"') + assert str(func(args[0], args[1])) == expected + + # test calling w/ ordered params + print(f' {func.__name__}(*{args}) == "{expected}"') + assert str(func(*args)) == expected + + # test calling w/ named params + print(f' {func.__name__}(xpub={args[0]}, script_type="{args[1]}", index={args[2]}, is_change={args[3]}, embit_network="{args[4]}") == "{expected}"') + assert str(func(xpub=args[0], script_type=args[1], index=args[2], is_change=args[3], embit_network=args[4])) == expected + + +def test_get_multisig_address(): + pass From 96ca99ba7fb6e6a78ebb1f737e113e0d53f88abc Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sun, 11 Jun 2023 18:17:26 -0400 Subject: [PATCH 45/67] added tests for embit_utils.get_multisig_address() --- tests/test_embit_utils.py | 37 ++++++++++++++++++++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index 68bd967d..4364b3a3 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -232,4 +232,39 @@ def test_get_single_sig_address(): def test_get_multisig_address(): - pass + from embit.descriptor import Descriptor + + # jdlcdl: these vectors created with electrum & sparrow as a 2 of 3 multisig based on bip39-bip32-standard-path wallets + # keystore1 = 0x00*16 = 'abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about' + # keystore2 = 0x11*16 = 'baby mass dust captain baby mass dust captain baby mass dust casino' + # keystore3 = 0x22*16 = 'captain baby mass dust captain baby mass dust captain baby mass dutch' + + vector_args_expected = { + # multisig native segwit on testnet, first payment and change addresses + ("wsh(sortedmulti(2,[8d55ff0d/48h/1h/0h/2h]tpubDDxNVWk924RTUhdkVB2uLHw1hGMPNMGufpZefhkkswjbZppVZcuMdjYKQN4ewUog9vbL6RBLFPRWcgTGT7kYP79N6thyJ43ELUs4N2szXMg/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*,[0be174ee/48h/1h/0h/2h]tpubDEsePyLPkbxbrDiZSTTWdsviiNtiQjrvvzZnkLtG72QYLBygEsXePRsTdXi8DeMA7taCuuvoEBjUAfFrsNZeQJqfvG9fFoujYWbFPYUn7ux/{0,1}/*))#zw6cnrlk" , 0, False, "test"): "tb1q7tpecll8jhp77yqdeyt2t8q5swxmmqeh2v22cqpms5dxlp6p27dqlftet8", + ("wsh(sortedmulti(2,[8d55ff0d/48h/1h/0h/2h]tpubDDxNVWk924RTUhdkVB2uLHw1hGMPNMGufpZefhkkswjbZppVZcuMdjYKQN4ewUog9vbL6RBLFPRWcgTGT7kYP79N6thyJ43ELUs4N2szXMg/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*,[0be174ee/48h/1h/0h/2h]tpubDEsePyLPkbxbrDiZSTTWdsviiNtiQjrvvzZnkLtG72QYLBygEsXePRsTdXi8DeMA7taCuuvoEBjUAfFrsNZeQJqfvG9fFoujYWbFPYUn7ux/{0,1}/*))#zw6cnrlk" , 0, True, "test"): "tb1q7h94ywhfjrpxdfzwl4dcawrg80r4rywswjrh447x4n3e5t3m0jms9jh7pm", + + # multisig nested segwit on testnet, first payment and change addresses + ("sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73", 0, False, "test"): "2MtgJH28mZWNWU7VRU4ba6ciFbRRGYWZDt3", + ("sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73", 0, True, "test"): "2NAjjwUQqwD9XRGLeQ6TitSUyMHUz3cLiWm", + } + + func = embit_utils.get_multisig_address + for args, expected in vector_args_expected.items(): + descriptor = Descriptor.from_string(args[0]) + + print("\nasserting...") + + # test calling w/o optional params (defaults: index=0, is_change=False, embit_network="main") + if args[1:4] == (0, False, 'main'): + print(f' {func.__name__}({descriptor}) == "{expected}"') + assert func(descriptor) == expected + + # test calling w/ ordered params + print(f' {func.__name__}({descriptor}, *{args[1:4]}) == "{expected}"') + assert func(descriptor, *args[1:4]) == expected + + # test calling w/ named params + print(f' {func.__name__}(descriptor={descriptor}, index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"') + assert func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) == expected + From 7fe5d00cec1b3ac00152fc34635d8c04cfb04978 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Mon, 12 Jun 2023 06:43:02 -0400 Subject: [PATCH 46/67] cleanup prior to pull request --- tests/test_embit_utils.py | 103 ++++++++++++++++++++++++++++---------- 1 file changed, 76 insertions(+), 27 deletions(-) diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index 4364b3a3..07c10f80 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -5,7 +5,9 @@ from seedsigner.helpers import embit_utils def test_get_standard_derivation_path(): - """tests seedsigner.helpers.embit_utils.get_standard_derivation_path()""" + """ + tests seedsigner.helpers.embit_utils.get_standard_derivation_path() + """ vectors_args_expected = { # single sig @@ -25,6 +27,7 @@ def test_get_standard_derivation_path(): (SC.TESTNET, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'", (SC.REGTEST, SC.SINGLE_SIG, SC.TAPROOT): "m/86'/1'/0'", + # multi sig (SC.MAINNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/0'/0'/2'", (SC.TESTNET, SC.MULTISIG, SC.NATIVE_SEGWIT): "m/48'/1'/0'/2'", @@ -38,6 +41,11 @@ def test_get_standard_derivation_path(): (SC.TESTNET, SC.MULTISIG, SC.TAPROOT): Exception, (SC.REGTEST, SC.MULTISIG, SC.TAPROOT): Exception, + # intentionally fall into exceptions + (SC.MAINNET, SC.SINGLE_SIG, 'invalid'): Exception, + (SC.MAINNET, SC.MULTISIG, 'invalid'): Exception, + (SC.MAINNET, 'invalid', SC.NATIVE_SEGWIT): Exception, + # nonsense arguments ("A",): Exception, ("B", "A"): Exception, @@ -51,13 +59,13 @@ def test_get_standard_derivation_path(): print() for args, expected in vectors_args_expected.items(): - # test successful returns + # test successful calls if type(expected) is str: - # call with positional args + # call with ordered params print(f"asserting {func.__name__}(*{args}) == {repr(expected)}") assert func(*args) == expected - # call with named args + # call with named params a_dict = {} if len(args) == 1: a_dict = {'network': args[0]} elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]} @@ -67,12 +75,12 @@ def test_get_standard_derivation_path(): # test exceptions else: - # call with positional args + # call with ordered params with pytest.raises(expected): print(f"asserting {func.__name__}(*{args}) raises Exception") func(*args) - # call with named args + # call with named params a_dict = {} if len(args) == 1: a_dict = {'network': args[0]} elif len(args) == 2: a_dict = {'network': args[0], 'wallet_type': args[1]} @@ -83,7 +91,9 @@ def test_get_standard_derivation_path(): def test_get_xpub(): - """tests seedsigner.helpers.embit_utils.get_xpub()""" + """ + tests seedsigner.helpers.embit_utils.get_xpub() + """ from binascii import unhexlify from embit import bip39, bip32 @@ -149,26 +159,31 @@ def test_get_xpub(): (vector_seeds[4], "m/86'/0'/0'", "main"): "xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ", } - func = embit_utils.get_xpub + + print() for args, expected in vectors_args_expected.items(): print("\nasserting...") - # test calling w/o last param (default is "main") + # call without optional params (default is "main") if args[2] == "main": print(f' {func.__name__}({args[0]}, "{args[1]}") == "{expected}"') assert str(func(args[0], args[1])) == expected - # test calling w/ ordered params + # call with ordered params print(f' {func.__name__}(*{args}) == "{expected}"') assert str(func(*args)) == expected - # test calling w/ named params + # call with named params print(f' {func.__name__}(seed_bytes={args[0]}, derivation_path="{args[1]}", embit_network="{args[2]}") == "{expected}"') assert str(func(seed_bytes=args[0], derivation_path=args[1], embit_network=args[2])) == expected def test_get_single_sig_address(): + """ + tests seedsigner.helpers.embit_utils.get_single_sig_address() + """ + from embit import bip32 # test vectors originate from: @@ -195,7 +210,7 @@ def test_get_single_sig_address(): # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors (first payment address of nested segwit on testnet) (bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, False, "test"): "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2", - # jdlcdl: derived test vector for first change address of nested segwit on testnet + # jdlcdl: derived via iancoleman test vector for first change address of nested segwit on testnet (bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, True, "test"): "2MvdUi5o3f2tnEFh9yGvta6FzptTZtkPJC8", @@ -211,27 +226,39 @@ def test_get_single_sig_address(): (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, True, "main"): "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7", - } + # jdlcdl: derived via electrum m/44'/1'/0 (first payment address p2pkh on testnet) + (bip32.HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, False, "test"): + "mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV", + # jdlcdl: derived via electrum m/44'/1'/0 (first change address p2pkh on testnet) + (bip32.HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, True, "test"): + "mi8nhzZgGZQthq6DQHbru9crMDerUdTKva", + } func = embit_utils.get_single_sig_address + + print() for args, expected in vectors_args_expected.items(): print("\nasserting...") - # test calling w/o optional params (defaults: script_type="nat", index=0, is_change=False, embit_network="main") + # call without optional params (defaults: script_type="nat", index=0, is_change=False, embit_network="main") if args[1:5] == ("nat", 0, False, "main"): print(f' {func.__name__}({args[0]}) == "{expected}"') assert str(func(args[0], args[1])) == expected - # test calling w/ ordered params + # call with ordered params print(f' {func.__name__}(*{args}) == "{expected}"') assert str(func(*args)) == expected - # test calling w/ named params + # call with named params print(f' {func.__name__}(xpub={args[0]}, script_type="{args[1]}", index={args[2]}, is_change={args[3]}, embit_network="{args[4]}") == "{expected}"') assert str(func(xpub=args[0], script_type=args[1], index=args[2], is_change=args[3], embit_network=args[4])) == expected def test_get_multisig_address(): + """ + tests seedsigner.helpers.embit_utils.get_multisig_address() + """ + from embit.descriptor import Descriptor # jdlcdl: these vectors created with electrum & sparrow as a 2 of 3 multisig based on bip39-bip32-standard-path wallets @@ -247,24 +274,46 @@ def test_get_multisig_address(): # multisig nested segwit on testnet, first payment and change addresses ("sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73", 0, False, "test"): "2MtgJH28mZWNWU7VRU4ba6ciFbRRGYWZDt3", ("sh(wsh(sortedmulti(2,[73c5da0a/48h/1h/1h/0h/1h]tpubDFH9dgzveyD8yHQb8VrpG8FYAuwcLMHMje2CCcbBo1FpaGzYVtJeYYxcYgRqSTta5utUFts8nPPHs9C2bqoxrey5jia6Dwf9mpwrPq7YvcJ/{0,1}/*,[0be174ee/48h/1h/0h/1h]tpubDEsePyLPkbxbnj6XuKvWwdERHaKkikZxaGJ9sJqmM7okbZXgkNSFiGU6GX6qEes6kD8f9Z9FosYB9UEnBSgBEyEwwJhj4uUcFE1WE8VtKoh/{0,1}/*,[8d55ff0d/48h/1h/0h/1h]tpubDDxNVWk924RTT3vyGLHdSDoZ2JUVX7jUsPcwCQ9MrKHAtJrW5zECTF9rFHCvqu526E4PjHp61hBknts2c5aGexvX7hvCZ8TGPvQFdzxxy59/{0,1}/*)))#2ujlfp73", 0, True, "test"): "2NAjjwUQqwD9XRGLeQ6TitSUyMHUz3cLiWm", - } + # legacy multisig p2sh on testnet, not supported + ("sh(sortedmulti(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#p5t8sa8c", 0, False, "test"): Exception, + + # multisig taproot on testnet, not supported + # TODO: find what a multisig-taproot descriptor would look like and add a test so we can fall into the last condition exception. + } func = embit_utils.get_multisig_address + + print() for args, expected in vector_args_expected.items(): descriptor = Descriptor.from_string(args[0]) print("\nasserting...") - # test calling w/o optional params (defaults: index=0, is_change=False, embit_network="main") - if args[1:4] == (0, False, 'main'): - print(f' {func.__name__}({descriptor}) == "{expected}"') - assert func(descriptor) == expected + # test successful calls + if type(expected) == str: + # call with optional params (defaults: index=0, is_change=False, embit_network="main") + if args[1:4] == (0, False, 'main'): + print(f' {func.__name__}({descriptor}) == "{expected}"') + assert func(descriptor) == expected - # test calling w/ ordered params - print(f' {func.__name__}({descriptor}, *{args[1:4]}) == "{expected}"') - assert func(descriptor, *args[1:4]) == expected + # call with ordered params + print(f' {func.__name__}({descriptor}, *{args[1:4]}) == "{expected}"') + assert func(descriptor, *args[1:4]) == expected - # test calling w/ named params - print(f' {func.__name__}(descriptor={descriptor}, index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"') - assert func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) == expected + # call with named params + print(f' {func.__name__}(descriptor={descriptor}, index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"') + assert func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) == expected + + # test exceptions + else: + # call with ordered params + with pytest.raises(expected): + print(f"asserting {func.__name__}(*{args}) raises Exception") + func(descriptor, *args[1:4]) + + # call with named params + with pytest.raises(expected): + print(f"asserting {func.__name__}(*{args}) raises Exception") + print(f' {func.__name__}(descriptor={descriptor}, index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") raises Exception"') + func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) From 19f182b1e54c47707edfcd5f26d22be198764c26 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Mon, 12 Jun 2023 08:11:14 -0400 Subject: [PATCH 47/67] printed output via 'pytest -s' works when cut/pasted --- tests/test_embit_utils.py | 43 +++++++++++++++++++-------------------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index 07c10f80..1398c407 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -184,7 +184,7 @@ def test_get_single_sig_address(): tests seedsigner.helpers.embit_utils.get_single_sig_address() """ - from embit import bip32 + from embit.bip32 import HDKey # test vectors originate from: # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki @@ -194,44 +194,44 @@ def test_get_single_sig_address(): vectors_args_expected = { # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of native segwit on mainnet) - (bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, False, "main"): + (HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, False, "main"): "bc1qcr8te4kr609gcawutmrza0j4xv80jy8z306fyu", # jdlcdl: derived via iancoleman test vector for first change address of native segwit on mainnet - (bip32.HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, True, "main"): + (HDKey.from_string("zpub6rFR7y4Q2AijBEqTUquhVz398htDFrtymD9xYYfG1m4wAcvPhXNfE3EfH1r1ADqtfSdVCToUG868RvUUkgDKf31mGDtKsAYz2oz2AGutZYs"), "nat", 0, True, "main"): "bc1q8c6fshw2dlwun7ekn9qwf37cu2rn755upcp6el", # https://github.com/satoshilabs/slips/blob/master/slip-0132.md#bitcoin-test-vectors (first payment address of nested segwit on mainnet) - (bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, False, "main"): + (HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, False, "main"): "37VucYSaXLCAsxYyAPfbSi9eh4iEcbShgf", # jdlcdl: derived via iancoleman test vector for first change address of nested segwit on mainnet - (bip32.HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, True, "main"): + (HDKey.from_string("ypub6Ww3ibxVfGzLrAH1PNcjyAWenMTbbAosGNB6VvmSEgytSER9azLDWCxoJwW7Ke7icmizBMXrzBx9979FfaHxHcrArf3zbeJJJUZPf663zsP"), "nes", 0, True, "main"): "34K56kSjgUCUSD8GTtuF7c9Zzwokbs6uZ7", # https://github.com/bitcoin/bips/blob/master/bip-0049.mediawiki#test-vectors (first payment address of nested segwit on testnet) - (bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, False, "test"): + (HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, False, "test"): "2Mww8dCYPUpKHofjgcXcBCEGmniw9CoaiD2", # jdlcdl: derived via iancoleman test vector for first change address of nested segwit on testnet - (bip32.HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, True, "test"): + (HDKey.from_string("upub5EFU65HtV5TeiSHmZZm7FUffBGy8UKeqp7vw43jYbvZPpoVsgU93oac7Wk3u6moKegAEWtGNF8DehrnHtv21XXEMYRUocHqguyjknFHYfgY"), "nes", 0, True, "test"): "2MvdUi5o3f2tnEFh9yGvta6FzptTZtkPJC8", # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first payment address of taproot on mainnet) - (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, False, "main"): + (HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, False, "main"): "bc1p5cyxnuxmeuwuvkwfem96lqzszd02n6xdcjrs20cac6yqjjwudpxqkedrcr", # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (second payment address of taproot on mainnet) - (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 1, False, "main"): + (HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 1, False, "main"): "bc1p4qhjn9zdvkux4e44uhx8tc55attvtyu358kutcqkudyccelu0was9fqzwh", # https://github.com/bitcoin/bips/blob/master/bip-0086.mediawiki#test-vectors (first change address of taproot on mainnet) - (bip32.HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, True, "main"): + (HDKey.from_string("xpub6BgBgsespWvERF3LHQu6CnqdvfEvtMcQjYrcRzx53QJjSxarj2afYWcLteoGVky7D3UKDP9QyrLprQ3VCECoY49yfdDEHGCtMMj92pReUsQ"), "tr", 0, True, "main"): "bc1p3qkhfews2uk44qtvauqyr2ttdsw7svhkl9nkm9s9c3x4ax5h60wqwruhk7", # jdlcdl: derived via electrum m/44'/1'/0 (first payment address p2pkh on testnet) - (bip32.HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, False, "test"): + (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, False, "test"): "mkpZhYtJu2r87Js3pDiWJDmPte2NRZ8bJV", # jdlcdl: derived via electrum m/44'/1'/0 (first change address p2pkh on testnet) - (bip32.HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, True, "test"): + (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, True, "test"): "mi8nhzZgGZQthq6DQHbru9crMDerUdTKva", } func = embit_utils.get_single_sig_address @@ -242,15 +242,15 @@ def test_get_single_sig_address(): # call without optional params (defaults: script_type="nat", index=0, is_change=False, embit_network="main") if args[1:5] == ("nat", 0, False, "main"): - print(f' {func.__name__}({args[0]}) == "{expected}"') - assert str(func(args[0], args[1])) == expected + print(f' {func.__name__}(HDKey.from_string("{args[0]}")) == "{expected}"') + assert str(func(args[0])) == expected # call with ordered params - print(f' {func.__name__}(*{args}) == "{expected}"') + print(f' {func.__name__}(HDKey.from_string("{args[0]}"), *{args[1:5]}) == "{expected}"') assert str(func(*args)) == expected # call with named params - print(f' {func.__name__}(xpub={args[0]}, script_type="{args[1]}", index={args[2]}, is_change={args[3]}, embit_network="{args[4]}") == "{expected}"') + print(f' {func.__name__}(xpub=HDKey.from_string("{args[0]}"), script_type="{args[1]}", index={args[2]}, is_change={args[3]}, embit_network="{args[4]}") == "{expected}"') assert str(func(xpub=args[0], script_type=args[1], index=args[2], is_change=args[3], embit_network=args[4])) == expected @@ -293,27 +293,26 @@ def test_get_multisig_address(): if type(expected) == str: # call with optional params (defaults: index=0, is_change=False, embit_network="main") if args[1:4] == (0, False, 'main'): - print(f' {func.__name__}({descriptor}) == "{expected}"') + print(f' {func.__name__}(Descriptor.from_string("{descriptor}")) == "{expected}"') assert func(descriptor) == expected # call with ordered params - print(f' {func.__name__}({descriptor}, *{args[1:4]}) == "{expected}"') + print(f' {func.__name__}(Descriptor.from_string("{descriptor}"), *{args[1:4]}) == "{expected}"') assert func(descriptor, *args[1:4]) == expected # call with named params - print(f' {func.__name__}(descriptor={descriptor}, index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"') + print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") == "{expected}"') assert func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) == expected # test exceptions else: # call with ordered params with pytest.raises(expected): - print(f"asserting {func.__name__}(*{args}) raises Exception") + print(f' {func.__name__}(Descriptor.from_string("{descriptor}"), *{args[1:4]}) raises Exception') func(descriptor, *args[1:4]) # call with named params with pytest.raises(expected): - print(f"asserting {func.__name__}(*{args}) raises Exception") - print(f' {func.__name__}(descriptor={descriptor}, index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") raises Exception"') + print(f' {func.__name__}(descriptor=Descriptor.from_string("{descriptor}"), index={args[1]}, is_change={args[2]}, embit_network="{args[3]}") raises Exception"') func(descriptor=descriptor, index=args[1], is_change=args[2], embit_network=args[3]) From 315f3996e42c83fa7a806f47df80b755bb7a0af5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Thomas=20Monteiro?= Date: Wed, 21 Jun 2023 21:27:26 +0200 Subject: [PATCH 48/67] =?UTF-8?q?change=20default=20camera=20orientation?= =?UTF-8?q?=20to=20180=C2=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/seedsigner/models/settings_definition.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/models/settings_definition.py b/src/seedsigner/models/settings_definition.py index 6c2af745..b4be014e 100644 --- a/src/seedsigner/models/settings_definition.py +++ b/src/seedsigner/models/settings_definition.py @@ -439,7 +439,7 @@ class SettingsDefinition: type=SettingsConstants.TYPE__SELECT_1, visibility=SettingsConstants.VISIBILITY__ADVANCED, selection_options=SettingsConstants.ALL_CAMERA_ROTATIONS, - default_value=SettingsConstants.CAMERA_ROTATION__0), + default_value=SettingsConstants.CAMERA_ROTATION__180), SettingsEntry(category=SettingsConstants.CATEGORY__FEATURES, attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR, From 3748a4a6db8c814b6b39d93be205ce935aa7b700 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Fri, 14 Apr 2023 14:22:18 -0400 Subject: [PATCH 49/67] add psbt scan to sign flow happy path --- src/seedsigner/views/psbt_views.py | 132 ++++++++++++++++------------- tests/test_flows_psbt.py | 56 ++++++++++++ 2 files changed, 130 insertions(+), 58 deletions(-) create mode 100644 tests/test_flows_psbt.py diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 59bda243..a4eb7408 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -11,9 +11,8 @@ from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.settings import SettingsConstants -from seedsigner.gui.screens import psbt_screens -from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, - LoadingScreenThread, QRDisplayScreen, WarningScreen) +from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen +from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen, WarningScreen) from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -22,6 +21,11 @@ logger = logging.getLogger(__name__) class PSBTSelectSeedView(View): + SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) + TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) + TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) + button_data = [] + def run(self): # Note: we can't just autoroute to the PSBT Overview because we might have a # multisig where we want to sign with more than one key on this device. @@ -31,32 +35,29 @@ class PSBTSelectSeedView(View): seeds = self.controller.storage.seeds - SCAN_SEED = ("Scan a seed", FontAwesomeIconConstants.QRCODE) - TYPE_12WORD = ("Enter 12-word seed", FontAwesomeIconConstants.KEYBOARD) - TYPE_24WORD = ("Enter 24-word seed", FontAwesomeIconConstants.KEYBOARD) - button_data = [] for seed in seeds: button_str = seed.get_fingerprint(self.settings.get_value(SettingsConstants.SETTING__NETWORK)) if not PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # Doesn't look like this seed can sign the current PSBT button_str += " (?)" - button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) + self.button_data.append((button_str, SeedSignerCustomIconConstants.FINGERPRINT, "blue")) - button_data.append(SCAN_SEED) - button_data.append(TYPE_12WORD) - button_data.append(TYPE_24WORD) + self.button_data.append(self.SCAN_SEED) + self.button_data.append(self.TYPE_12WORD) + self.button_data.append(self.TYPE_24WORD) if self.controller.psbt_seed: if PSBTParser.has_matching_input_fingerprint(psbt=self.controller.psbt, seed=self.controller.psbt_seed, network=self.settings.get_value(SettingsConstants.SETTING__NETWORK)): # skip the seed prompt if a seed was previous selected and has matching input fingerprint return Destination(PSBTOverviewView) - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Select Signer", is_button_text_centered=False, - button_data=button_data - ).display() + button_data=self.button_data + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -69,13 +70,13 @@ class PSBTSelectSeedView(View): # The remaining flows are a sub-flow; resume PSBT flow once the seed is loaded. self.controller.resume_main_flow = Controller.FLOW__PSBT - if button_data[selected_menu_num] == SCAN_SEED: + if self.button_data[selected_menu_num] == self.SCAN_SEED: from seedsigner.views.scan_views import ScanView return Destination(ScanView) - elif button_data[selected_menu_num] in [TYPE_12WORD, TYPE_24WORD]: + elif button_data[selected_menu_num] in [self.TYPE_12WORD, self.TYPE_24WORD]: from seedsigner.views.seed_views import SeedMnemonicEntryView - if button_data[selected_menu_num] == TYPE_12WORD: + if button_data[selected_menu_num] == self.TYPE_12WORD: self.controller.storage.init_pending_mnemonic(num_words=12) else: self.controller.storage.init_pending_mnemonic(num_words=24) @@ -92,8 +93,10 @@ class PSBTOverviewView(View): if not self.controller.psbt_parser or self.controller.psbt_parser.seed != self.controller.psbt_seed: # The PSBTParser takes a while to read the PSBT. Run the loading screen while # we wait. + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Parsing PSBT...") self.loading_screen.start() + try: self.controller.psbt_parser = PSBTParser( self.controller.psbt, @@ -128,22 +131,27 @@ class PSBTOverviewView(View): else: num_self_transfer_outputs += 1 - screen = psbt_screens.PSBTOverviewScreen( - spend_amount=psbt_parser.spend_amount, - change_amount=psbt_parser.change_amount, - fee_amount=psbt_parser.fee_amount, - num_inputs=psbt_parser.num_inputs, - num_self_transfer_outputs=num_self_transfer_outputs, - num_change_outputs=num_change_outputs, - destination_addresses=psbt_parser.destination_addresses, - ) + spend_amount = psbt_parser.spend_amount + change_amount = psbt_parser.change_amount + fee_amount = psbt_parser.fee_amount + num_inputs = psbt_parser.num_inputs + destination_addresses = psbt_parser.destination_addresses # Everything is set. Stop the loading screen if self.loading_screen: self.loading_screen.stop() # Run the overview screen - selected_menu_num = screen.display() + selected_menu_num = self.run_screen( + PSBTOverviewScreen, + spend_amount=spend_amount, + change_amount=change_amount, + fee_amount=fee_amount, + num_inputs=num_inputs, + num_self_transfer_outputs=num_self_transfer_outputs, + num_change_outputs=num_change_outputs, + destination_addresses=destination_addresses + ) if selected_menu_num == RET_CODE__BACK_BUTTON: self.controller.psbt_seed = None @@ -214,14 +222,15 @@ class PSBTMathView(View): # Should not be able to get here return Destination(MainMenuView) - selected_menu_num = psbt_screens.PSBTMathScreen( + selected_menu_num = self.run_screen( + PSBTMathScreen, input_amount=psbt_parser.input_amount, num_inputs=psbt_parser.num_inputs, spend_amount=psbt_parser.spend_amount, num_recipients=psbt_parser.num_destinations, fee_amount=psbt_parser.fee_amount, change_amount=psbt_parser.change_amount, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) @@ -238,7 +247,10 @@ class PSBTAddressDetailsView(View): """ Shows the recipient's address and amount they will receive """ - def __init__(self, address_num, is_change=False): + NEXT = "Next" + button_data = [] + + def __init__(self, address_num): super().__init__() self.address_num = address_num @@ -254,23 +266,27 @@ class PSBTAddressDetailsView(View): if psbt_parser.num_destinations > 1: title += f" (#{self.address_num + 1})" - button_data = [] if self.address_num < psbt_parser.num_destinations - 1: - button_data.append("Next Recipient") + self.NEXT = "Next Recipient" else: - button_data.append("Next") + self.NEXT = "Next" + self.button_data.append(self.NEXT) - selected_menu_num = psbt_screens.PSBTAddressDetailsScreen( + selected_menu_num = self.run_screen( + PSBTAddressDetailsScreen, title=title, - button_data=button_data, + button_data=self.button_data, address=psbt_parser.destination_addresses[self.address_num], amount=psbt_parser.destination_amounts[self.address_num], - ).display() + ) + + if selected_menu_num == RET_CODE__BACK_BUTTON: + return Destination(BackStackView) - if selected_menu_num == 0: + if self.button_data[selected_menu_num] == self.NEXT: if self.address_num < len(psbt_parser.destination_addresses) - 1: # Show the next receive addr - return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1, "is_change": False}) + return Destination(PSBTAddressDetailsView, view_args={"address_num": self.address_num + 1}) elif psbt_parser.change_amount > 0: # Move on to display change @@ -280,14 +296,13 @@ class PSBTAddressDetailsView(View): # There's no change output to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - if selected_menu_num == RET_CODE__BACK_BUTTON: - return Destination(BackStackView) - class PSBTChangeDetailsView(View): - """ - """ + NEXT = "Next" + VERIFY_MULTISIG = "Verify Multisig Change" + button_data = [NEXT] + def __init__(self, change_address_num): super().__init__() self.change_address_num = change_address_num @@ -327,14 +342,12 @@ class PSBTChangeDetailsView(View): is_change_derivation_path = int(derivation_path.split("/")[-2]) == 1 derivation_path_addr_index = int(derivation_path.split("/")[-1]) - NEXT = "Next" - if is_change_derivation_path: title = "Your Change" - VERIFY_MULTISIG = "Verify Multisig Change" + self.VERIFY_MULTISIG = "Verify Multisig Change" else: title = "Self-Transfer" - VERIFY_MULTISIG = "Verify Multisig Addr" + self.VERIFY_MULTISIG = "Verify Multisig Addr" # if psbt_parser.num_change_outputs > 1: # title += f" (#{self.change_address_num + 1})" @@ -343,11 +356,11 @@ class PSBTChangeDetailsView(View): # if the known-good multisig descriptor is already onboard: if self.controller.multisig_wallet_descriptor: is_change_addr_verified = psbt_parser.verify_multisig_output(self.controller.multisig_wallet_descriptor, change_num=self.change_address_num) - button_data = [NEXT] + self.button_data = [self.NEXT] else: # Have the Screen offer to load in the multisig descriptor. - button_data = [VERIFY_MULTISIG, NEXT] + self.button_data = [self.VERIFY_MULTISIG, self.NEXT] else: # Single sig @@ -356,6 +369,7 @@ class PSBTChangeDetailsView(View): loading_screen_text = "Verifying Change..." else: loading_screen_text = "Verifying Self-Transfer..." + from seedsigner.gui.screens.screen import LoadingScreenThread loading_screen = LoadingScreenThread(text=loading_screen_text) loading_screen.start() @@ -389,7 +403,7 @@ class PSBTChangeDetailsView(View): if change_data["address"] == calc_address: is_change_addr_verified = True - button_data = [NEXT] + self.button_data = [self.NEXT] finally: loading_screen.stop() @@ -397,9 +411,10 @@ class PSBTChangeDetailsView(View): if is_change_addr_verified == False and (not psbt_parser.is_multisig or self.controller.multisig_wallet_descriptor is not None): return Destination(PSBTAddressVerificationFailedView, view_args=dict(is_change=is_change_derivation_path, is_multisig=psbt_parser.is_multisig), clear_history=True) - selected_menu_num = psbt_screens.PSBTChangeDetailsScreen( + selected_menu_num = self.run_screen( + PSBTChangeDetailsScreen, title=title, - button_data=button_data, + button_data=self.button_data, address=change_data.get("address"), amount=change_data.get("amount"), is_multisig=psbt_parser.is_multisig, @@ -408,19 +423,19 @@ class PSBTChangeDetailsView(View): is_change_derivation_path=is_change_derivation_path, derivation_path_addr_index=derivation_path_addr_index, is_change_addr_verified=is_change_addr_verified, - ).display() + ) if selected_menu_num == RET_CODE__BACK_BUTTON: return Destination(BackStackView) - elif button_data[selected_menu_num] == NEXT: + elif self.button_data[selected_menu_num] == self.NEXT: if self.change_address_num < psbt_parser.num_change_outputs - 1: return Destination(PSBTChangeDetailsView, view_args={"change_address_num": self.change_address_num + 1}) else: # There's no more change to verify. Move on to sign the PSBT. return Destination(PSBTFinalizeView) - elif button_data[selected_menu_num] == VERIFY_MULTISIG: + elif self.button_data[selected_menu_num] == self.VERIFY_MULTISIG: from seedsigner.views.seed_views import LoadMultisigWalletDescriptorView self.controller.resume_main_flow = Controller.FLOW__PSBT return Destination(LoadMultisigWalletDescriptorView) @@ -467,9 +482,10 @@ class PSBTFinalizeView(View): # Should not be able to get here return Destination(MainMenuView) - selected_menu_num = psbt_screens.PSBTFinalizeScreen( + selected_menu_num = self.run_screen( + PSBTFinalizeScreen, button_data=["Approve PSBT"] - ).display() + ) if selected_menu_num == 0: # Sign PSBT @@ -500,7 +516,7 @@ class PSBTSignedQRDisplayView(View): qr_density=self.settings.get_value(SettingsConstants.SETTING__QR_DENSITY), wordlist_language_code=self.settings.get_value(SettingsConstants.SETTING__WORDLIST_LANGUAGE), ) - QRDisplayScreen(qr_encoder=qr_encoder).display() + self.run_screen(QRDisplayScreen, qr_encoder=qr_encoder) # We're done with this PSBT. Route back to MainMenuView which always # clears all ephemeral data (except in-memory seeds). diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py new file mode 100644 index 00000000..8fe6d862 --- /dev/null +++ b/tests/test_flows_psbt.py @@ -0,0 +1,56 @@ +from base import FlowTest, FlowStep + +from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.views.view import MainMenuView +from seedsigner.views import scan_views, seed_views, psbt_views + +class TestPSBTFlows(FlowTest): + + def test_scan_psbt_first_then_correct_seedqr_flow(self): + """ + Selecting "Scan" from the MainMenuView and scanning a PSBT should enter the PSBTSelectSeedView flow + when Scan a Seed is selected from PSBTSelectSeedView it should enter the ScanView flow + when a SeedQR is scanned it should enter the PSBTOverviewView flow + since the PSBT has change no warning is displayed and it should enter the PSBTMathView flow + since the PSBT is not a self transfer it should enter the PSBTAddressDetailsView flow + """ + def load_psbt_into_decoder(view: scan_views.ScanView): + """ + PSBT Tx and Wallet Details + - Single Sig Wallet P2WPKH (Native Segwit) with no passphrase + - Regtest c751dc07 m/84'/1'/0' tpubDDZBrnxMxbVzqt8EoEiABPxeKzFWma5pra5UEbg3Wst1hrwr6feuvcy7Sov7cpuYx94ypuy1PQ9NDNoQagFs37wGALzLb5Ei3FvyJWPPPKZ + - 2 Inputs + - 56,522,834 sats + - 1,990,245,069 sats + - 4 Outputs + - 1 Output to another wallet (bcrt1q7cw0wzy8g6mq5qvkpvhnk5gsps5ncy3srp0n2j) of 123,456 sats + - 3 Outputs change + - 3 outputs to emulate a fake mix to increase privacy + - Change addresses are index 1/7, 1/8, 1/9 + - 1/7 address bcrt1q53j0xwuskuf5gnvynadh0hlazyy8srydlucrhg with amount 123,456 sats + - 1/8 address bcrt1q5gtw3zfp4cx67yk5q42q6j6rfza8aqcwpyyslv with amount 1,990,121,477 sats + - 1/9 address bcrt1q9rrg7399m43cn0yg4tz0v0ate89jgf2d6kpz7v with amount 56,399,242 sats + - Fee 272 sats + """ + view.decoder.add_data("cHNidP8BANgCAAAAAsTXZs3fz/dmGb6M80+jjvJZdYya+cw5bT/dGuhZFdSlAAAAAAD9////qo6xg/UZAvUkcbse1F+C9zbP/FeZNjThx7SCIn6eMCgBAAAAAP3///8EQOIBAAAAAAAWABSkZPM7kLcTRE2En1t33/0RCHgMjQXYnnYAAAAAFgAUKMaPRKXdY4m8iKrE9j+rycskJU1A4gEAAAAAABYAFPYc9wiHRrYKAZYLLztREAwpPBIwipVcAwAAAAAWABSiFuiJIa4NrxLUBVQNS0NIun6DDtoRAABPAQQ1h88DBcQGZIAAAAA+0J+jlNL3dpWwlnBi8Dx+Ipg4e6uvB3HdjzFPX7r9CAOOlAIxgII+/xCcj+XoEenKH7wj5s5wlu7Q7CCZWFLGLhA5Su0UVAAAgAEAAIAAAACAAAEA7QIAAAAEE6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0BAAAAAP3///8TqeNf9+e+fuFuQohFzHM1gU5J9sJ015ad3sV7/VRxDQMAAAAA/f///xOp41/3575+4W5CiEXMczWBTkn2wnTXlp3exXv9VHENBAAAAAD9////E6njX/fnvn7hbkKIRcxzNYFOSfbCdNeWnd7Fe/1UcQ0GAAAAAP3///8CUnheAwAAAAAWABRCfygPJ+Fjsx4BknYvvm3A3qKn2xJ/XQcAAAAAF6kU1I4TAst5nAj15ey7vwe5cM3OFq+HlhEAAAEBH1J4XgMAAAAAFgAUQn8oDyfhY7MeAZJ2L75twN6ip9sBAwQBAAAAIgYCo7sfm78RQY3B5n0ac/QF8VtMAzFnci+h5D1MtpgRY7oYOUrtFFQAAIABAACAAAAAgAEAAAAGAAAAAAEAcQIAAAABxY7wh0nsfJQfzWrD/9rN9BYsM+iOmPaO6I0ANFgO/PcAAAAAAP3///8CptiUAAAAAAAWABRIm4HhQY/TzOjeWSPRrbuJo9MlW826oHYAAAAAFgAU0z+0L2QSLGtyQTn8FhbCpcI7jbliAQAAAQEfzbqgdgAAAAAWABTTP7QvZBIsa3JBOfwWFsKlwjuNuQEDBAEAAAAiBgITHmebEANk81CraV4xZIpqkNjjw0tIvezl1Ism1NRH3Rg5Su0UVAAAgAEAAIAAAACAAQAAAAAAAAAAIgICuTT7WnuiUTpObjWnZFHzIeEvW9PTB+1LLVFNQJVFeIIYOUrtFFQAAIABAACAAAAAgAEAAAAHAAAAACICAk8f3hpc5C35chgSg+Pe2zZ9IhHREd4aKW2+yAMRIFeqGDlK7RRUAACAAQAAgAAAAIABAAAACQAAAAAAIgIDjt1CjvrnMMnjbmTNKUAYoKEDRbmKjNjbq+6Ppqj3bqQYOUrtFFQAAIABAACAAAAAgAEAAAAIAAAAAA==") + + def load_seed_into_decoder(view: scan_views.ScanView): + view.decoder.add_data("080115060387063104071857067618681125136207731354") + + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), + FlowStep(scan_views.ScanView, run_before=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), + FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), + FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), + FlowStep(seed_views.SeedOptionsView, is_redirect=True), + FlowStep(psbt_views.PSBTOverviewView), + FlowStep(psbt_views.PSBTMathView), + FlowStep(psbt_views.PSBTAddressDetailsView, button_data_selection=psbt_views.PSBTAddressDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), + FlowStep(psbt_views.PSBTFinalizeView, screen_return_value=StopControllerCommand()), + FlowStep(psbt_views.PSBTSignedQRDisplayView), + FlowStep(MainMenuView, screen_return_value=StopControllerCommand()) + ]) \ No newline at end of file From 91400bc4cee2c0623cf70dadf846be455b1c32b5 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Fri, 14 Apr 2023 14:35:21 -0400 Subject: [PATCH 50/67] remove extra StopControllerCommand in test and fix PSBTFinalizeView --- src/seedsigner/views/psbt_views.py | 9 ++++++--- tests/test_flows_psbt.py | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index a4eb7408..7c7e2bf4 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -474,6 +474,9 @@ class PSBTAddressVerificationFailedView(View): class PSBTFinalizeView(View): """ """ + APPROVE_PSBT = "Approve PSBT" + button_data = [APPROVE_PSBT] + def run(self): psbt_parser: PSBTParser = self.controller.psbt_parser psbt: PSBT = self.controller.psbt @@ -481,13 +484,13 @@ class PSBTFinalizeView(View): if not psbt_parser: # Should not be able to get here return Destination(MainMenuView) - + selected_menu_num = self.run_screen( PSBTFinalizeScreen, - button_data=["Approve PSBT"] + button_data=self.button_data ) - if selected_menu_num == 0: + if self.button_data[selected_menu_num] == self.APPROVE_PSBT: # Sign PSBT sig_cnt = PSBTParser.sig_count(psbt) psbt.sign_with(psbt_parser.root) diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 8fe6d862..075b0d04 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -50,7 +50,7 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), - FlowStep(psbt_views.PSBTFinalizeView, screen_return_value=StopControllerCommand()), + FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView, screen_return_value=StopControllerCommand()) ]) \ No newline at end of file From 69ab92dbf7083d23f6e2b5bb8f516c422ff6d736 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Wed, 28 Jun 2023 22:11:02 -0400 Subject: [PATCH 51/67] update test_flows_psbt.py to work with base.py breaking changes --- tests/test_flows_psbt.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index 075b0d04..f8d9c7fd 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -1,6 +1,6 @@ from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopControllerCommand +from seedsigner.controller import Controller, StopFlowBasedTest from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views @@ -39,9 +39,9 @@ class TestPSBTFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=MainMenuView.SCAN), - FlowStep(scan_views.ScanView, run_before=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored + FlowStep(scan_views.ScanView, before_run=load_psbt_into_decoder), # simulate read PSBT; ret val is ignored FlowStep(psbt_views.PSBTSelectSeedView, button_data_selection=psbt_views.PSBTSelectSeedView.SCAN_SEED), - FlowStep(scan_views.ScanView, run_before=load_seed_into_decoder), + FlowStep(scan_views.ScanView, before_run=load_seed_into_decoder), FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE), FlowStep(seed_views.SeedOptionsView, is_redirect=True), FlowStep(psbt_views.PSBTOverviewView), @@ -52,5 +52,5 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTChangeDetailsView, button_data_selection=psbt_views.PSBTChangeDetailsView.NEXT), FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), - FlowStep(MainMenuView, screen_return_value=StopControllerCommand()) + FlowStep(MainMenuView) ]) \ No newline at end of file From 28a4999a091c76cf3d87a53eee4ec1a79003b29c Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Thu, 29 Jun 2023 17:23:41 -0400 Subject: [PATCH 52/67] - remove unnecessary imports - add test doc string --- tests/test_psbt_parser.py | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/tests/test_psbt_parser.py b/tests/test_psbt_parser.py index 6afe3d00..e0fb5a84 100644 --- a/tests/test_psbt_parser.py +++ b/tests/test_psbt_parser.py @@ -2,12 +2,26 @@ import pytest from mock import MagicMock from seedsigner.models import PSBTParser, Seed from embit import psbt -from embit import bip32 -from embit import bip39 -from binascii import a2b_base64, b2a_base64 +from binascii import a2b_base64 from seedsigner.models.settings_definition import SettingsConstants def test_p2tr_change_detection(): + """ Should successfully detect change in a p2tr to p2tr psbt spend + + PSBT Tx and Wallet Details + - Single Sig Wallet P2TR (Taproot) with no passphrase + - Regtest 394aed14 m/86'/1'/0' tpubDCawGrRg7YdHdFb9p4mmD8GBaZjJegL53FPFRrMkGoLcgLATJfksUs2y1Q7dVzixAkgecazsxEsUuyj3LyDw7eVVYHQyojwrc2hfesK4wXW + - 1 Inputs + - 3,190,493,401 sats + - 2 Outputs + - 1 Output spend to another wallet (bcrt1p6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek) p2tr address + - 1 Output change + - addresss bcrt1prz4g6saush37epdwhvwpu78td3q7yfz3xxz37axlx7udck6wracq3rwq30) + - amount 2,871,443,918 sats + - Change addresses is index 1/1 + - Fee 155 sats + """ + psbt_base64 = "cHNidP8BAIkCAAAAAf8upuiIWF1VTgC/Q8ZWRrameRigaXpRcQcBe8ye+TK3AQAAAAAXCgAAAs7BJqsAAAAAIlEgGKqNQ7yF4+yFrrscHnjrbEHiJFExhR903ze43FtOH3BwTgQTAAAAACJRINBe93RcrOYO4UVLLE0y8pzvblOKQWcoQ0obCey8nA5GAAAAAE8BBDWHzwNMUx9OgAAAAJdr+WtwWfVa6IPbpKZ4KgRC0clbm11Gl155IPA27n2FAvQCrFGH6Ac2U0Gcy1IH5f5ltgUBDz2+fe8iqL6JzZdgEDlK7RRWAACAAQAAgAAAAIAAAQB9AgAAAAGAKOOUFIzw9pbRDaZ7F0DYhLImrdMn//OSm++ff5VNdAAAAAAAAQAAAAKsjLwAAAAAABYAFKEcuxvXmB3rWHSqSviP5mrKMZoL2RArvgAAAAAiUSBGU0Lg5fx/ECsB1Z4ZUqXQFSLFnlmpm0rm5R2l599h2AAAAAABASvZECu+AAAAACJRIEZTQuDl/H8QKwHVnhlSpdAVIsWeWambSublHaXn32HYAQMEAAAAACEWF7hZVn7pIDR429kAn/WDeQiWjZey1iGHztsL1H83QLMZADlK7RRWAACAAQAAgAAAAIABAAAAAAAAAAEXIBe4WVZ+6SA0eNvZAJ/1g3kIlo2XstYhh87bC9R/N0CzACEHbJdqWyMxF2eOPr6YRXUJmry04HUbgKyeM2IZeG+NI9AZADlK7RRWAACAAQAAgAAAAIABAAAAAQAAAAEFIGyXalsjMRdnjj6+mEV1CZq8tOB1G4CsnjNiGXhvjSPQAAA=" raw = a2b_base64(psbt_base64) From 5a847d683be4d1d95966b82be0af1a7faa22178e Mon Sep 17 00:00:00 2001 From: Jean Do Date: Wed, 5 Jul 2023 09:06:12 -0400 Subject: [PATCH 53/67] one more test for get_single_sig_address() --- tests/test_embit_utils.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_embit_utils.py b/tests/test_embit_utils.py index 1398c407..2e462bc2 100644 --- a/tests/test_embit_utils.py +++ b/tests/test_embit_utils.py @@ -233,6 +233,10 @@ def test_get_single_sig_address(): # jdlcdl: derived via electrum m/44'/1'/0 (first change address p2pkh on testnet) (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "leg", 0, True, "test"): "mi8nhzZgGZQthq6DQHbru9crMDerUdTKva", + + # jdlcdl: nonsense script_type falls off end of function returning None. TODO: Would it be preferred to "else: raise ValueError"? + (HDKey.from_string("tpubDC5FSnBiZDMmhiuCmWAYsLwgLYrrT9rAqvTySfuCCrgsWz8wxMXUS9Tb9iVMvcRbvFcAHGkMD5Kx8koh4GquNGNTfohfk7pgjhaPCdXpoba"), "NONSENSE", 0, True, "test"): + "None", } func = embit_utils.get_single_sig_address @@ -280,6 +284,11 @@ def test_get_multisig_address(): # multisig taproot on testnet, not supported # TODO: find what a multisig-taproot descriptor would look like and add a test so we can fall into the last condition exception. + + # some policy that is not supported: + # TODO: find anything non supported so we can drop off the function: Would it be preferred to "else: raise ValueError()"? + #("sh(multi(2,[8d55ff0d/45h]tpubDANogJ2yfnizHwX7fSi5kUVzybyuPXDhgHB2TR9TUvkSLZFW73cRq4STKFDpx7qjJJiisyq82tbu4CeiYtmKEmT1xoCq9P8BPvXV31HUh6d/{0,1}/*,[0be174ee/45h]tpubDBkeVF2tDNT1Pz7L47iJeBB6RokU12LX6x4E6Ph8T89hmjQfB77q1AMyGwL8qpREVGq9sCJEbWwmnemwNTxnpxGn1di7BGy8jx9wEi5Vahu/{0,1}/*,[73c5da0a/45h]tpubDBKsGC1UqBDNvx9aivFmxZNgeZTUnmsCFGhWrqkLzucUCDePvbWWm3n8tAaAwMmxBG2ihdKCG9fzBdUnMxKx5PrkiqSZFi6Vkv6msUs9ddN/{0,1}/*))#", 0, False, "test"): None, + } func = embit_utils.get_multisig_address From 9b3959050c106f2668fe43e878bdb97cada4598c Mon Sep 17 00:00:00 2001 From: kdmukai Date: Fri, 7 Jul 2023 08:00:12 -0500 Subject: [PATCH 54/67] Additional flow-specific test exceptions, reorg * detects when `View.run_screen` did not execute. * detects when the supplied button_data_selection isn't a valid option in `Screen.button_data`. * Renamed internal mock functions in `FlowTest.run_sequence()` for better clarity. --- src/seedsigner/controller.py | 10 +- src/seedsigner/views/seed_views.py | 5 +- tests/base.py | 152 +++++++++++++++++------------ tests/test_flows.py | 55 ++++++++--- 4 files changed, 143 insertions(+), 79 deletions(-) diff --git a/src/seedsigner/controller.py b/src/seedsigner/controller.py index 05fdee6a..3f6d4b9d 100644 --- a/src/seedsigner/controller.py +++ b/src/seedsigner/controller.py @@ -40,10 +40,9 @@ class StopFlowBasedTest(Exception): pass -class FlowBasedTestUnexpectedViewError(Exception): +class FlowBasedTestException(Exception): """ - This is a special exception that is only raised by the test suite to indicate - that the Controller's current View does not match the expected TestFlow sequence. + This is a special exception that is only raised by the test suite. It should not be raised by any other code. """ pass @@ -276,9 +275,8 @@ class Controller(Singleton): # to stop the Controller loop and exit the test. return - except FlowBasedTestUnexpectedViewError as e: - # This is a special exception that is only raised by the test suite - # when a flow-based test fails to follow the expected sequence. + except FlowBasedTestException as e: + # This is a special exception that is only raised by the test suite. # Re-raise so the test suite can handle it. raise e diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 0dda88da..02e160b5 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -814,9 +814,10 @@ class SeedWordsWarningView(View): # Forward straight to showing the words return destination - selected_menu_num = DireWarningScreen( + selected_menu_num = self.run_screen( + DireWarningScreen, text="""You must keep your seed words private & away from all online devices.""", - ).display() + ) if selected_menu_num == 0: # User clicked "I Understand" diff --git a/tests/base.py b/tests/base.py index 27dd2d4e..fb8f1bca 100644 --- a/tests/base.py +++ b/tests/base.py @@ -12,7 +12,8 @@ sys.modules['seedsigner.hardware.buttons'] = MagicMock() sys.modules['seedsigner.hardware.camera'] = MagicMock() sys.modules['seedsigner.hardware.microsd'] = MagicMock() -from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError, StopFlowBasedTest +from seedsigner.controller import Controller, FlowBasedTestException, StopFlowBasedTest +from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models import Settings from seedsigner.views.view import Destination, MainMenuView, View @@ -81,6 +82,7 @@ class FlowStep: * before_run: function that takes a View instance as an arg and modifies it before running the View. * screen_return_value: mocked Screen interaction result: raw return value as if from the Screen. * button_data_selection: mocked Screen interaction result: the View.button_data value of the desired option. + * is_redirect: expects the Destination to specify `skip_current_view=True`. """ expected_view: type[View] = None before_run: Callable[[View], None] = None @@ -94,75 +96,107 @@ class FlowStep: +class FlowTestInvalidButtonDataSelectionException(FlowBasedTestException): + """ The FlowStep's button_data_selection value was not found in the View's button_data """ + pass + + + +class FlowTestUnexpectedViewException(FlowBasedTestException): + """ The Controller's current View did not match the expected View in the FlowStep sequence """ + pass + + + +class FlowTestRunScreenNotExecutedException(FlowBasedTestException): + """ The View's run_screen() method was not called but the FlowStep expected it to need user input """ + pass + + + class FlowTest(BaseTest): """ Base class for any tests that do flow-based testing """ + def stop_test(self): + """ Raise our special Exception to stop the test """ + raise StopFlowBasedTest() + + def run_sequence(self, sequence: list[FlowStep], initial_destination_view_args: dict = None) -> None: """ Run a pre-set sequence of Views w/manually-specified return values in order to test the Controller's flow control logic and the routing from View to View. """ - def verify_next_View_cls_and_run_view(destination: Destination, *args, **kwargs): - # Verify that the View class specified in the test sequence matches the - # View class that is being run. - if destination.View_cls != sequence[0].expected_view: - raise FlowBasedTestUnexpectedViewError(f"Expected {sequence[0].expected_view}, got {destination.View_cls}") - print(f"Confirmed {sequence[0].expected_view} is the next View") - - if len(sequence) == 1: - # The last entry in the sequence just needs to confirm that we got the - # right View class; we can now stop the Controller and exit the test. - raise StopFlowBasedTest() - - # Run the optional pre-run function to modify the View. - if sequence[0].before_run: - sequence[0].before_run(destination.view) - - if sequence[0].is_redirect: - # The current View is going to auto-redirect without calling run_screen(), - # so we need to remove the current step from the sequence before the - # View.run() call below. - sequence.pop(0) - - # Some Views reach into their Screen's variables directly (e.g. - # Screen.buttons to preserve the scroll position), so we need to mock out the - # Screen instance that is created by the View. - destination.view.screen = MagicMock() - - # Run the View (we're mocking out View._run_view() so the Destination - # won't actually run the View unless we explicitly do so here). - return destination.view.run() - - - def next_return_value(view: View, *args, **kwargs): - # Return the return value specified in the test sequence and - # remove the completed test step from the sequence. - flow_step = sequence.pop(0) - - if flow_step.button_data_selection: - # We're mocking out the View.run_screen() method, so we'll get all of the - # input args that are normally passed into the Screen.run() method, - # including the button_data kwarg. - if "button_data" in kwargs: - return kwargs.get("button_data").index(flow_step.button_data_selection) - else: - raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") - - elif type(flow_step.screen_return_value) in [StopFlowBasedTest, Exception]: - raise flow_step.screen_return_value - - return flow_step.screen_return_value - - with patch("seedsigner.views.view.Destination._run_view", autospec=True) as mock_run_view: - # Mock out the View._run_view() method so we can verify the View class - # that is specified in the test sequence and then run the View. - mock_run_view.side_effect = verify_next_View_cls_and_run_view - with patch("seedsigner.views.view.View.run_screen", autospec=True) as mock_run_screen: + def run_view(destination: Destination, *args, **kwargs): + """ Replaces Destination._run_view() """ + if len(sequence) == 0: + self.stop_test() + + cur_flow_step = sequence[0] + + # Verify that the View class specified in the test sequence matches the + # View class that is being run. + if destination.View_cls != cur_flow_step.expected_view: + raise FlowTestUnexpectedViewException(f"Expected {cur_flow_step.expected_view}, got {destination.View_cls}") + + # Run the optional pre-run function to modify the View. + if cur_flow_step.before_run: + cur_flow_step.before_run(destination.view) + + if cur_flow_step.is_redirect: + # The current View is going to auto-redirect without calling run_screen(), + # so we need to remove the current step from the sequence before the + # View.run() call below. + sequence.pop(0) + + # Some Views reach into their Screen's variables directly (e.g. + # Screen.buttons to preserve the scroll position), so we need to mock out the + # Screen instance that is created by the View. + destination.view.screen = MagicMock() + + prev_mock_run_screen_call_count = mock_run_screen.call_count + + # Run the View (with our mocked run_screen) and get the next Destination that results + destination = destination.view.run() + + if (cur_flow_step.button_data_selection or cur_flow_step.screen_return_value is not None) and mock_run_screen.call_count == prev_mock_run_screen_call_count: + # The FlowStep was expecting some kind of user interaction, but the View + # never called run_screen(). + raise FlowTestRunScreenNotExecutedException(f"View.run_screen() was not run for {destination.View_cls.__name__}") + + return destination + + def run_screen(view: View, *args, **kwargs): + """ Replaces View.run_screen() """ + # Return the return value specified in the test sequence and + # remove the completed test step from the sequence. + flow_step = sequence.pop(0) + + if flow_step.button_data_selection: + # We're mocking out the View.run_screen() method, so we'll get all of the + # input args that are normally passed into the Screen.run() method, + # including the button_data kwarg. + if "button_data" in kwargs: + if flow_step.button_data_selection not in kwargs.get("button_data") and flow_step.button_data_selection not in [RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON]: + raise FlowTestInvalidButtonDataSelectionException(f"'{flow_step.button_data_selection}' not found in button_data: {kwargs.get('button_data')}") + return kwargs.get("button_data").index(flow_step.button_data_selection) + else: + raise Exception(f"Can't specify `FlowStep.button_data_selection` if `button_data` isn't a kwarg in {view.__class__.__name__}'s run_screen()") + + elif type(flow_step.screen_return_value) in [StopFlowBasedTest, FlowBasedTestException]: + raise flow_step.screen_return_value + + return flow_step.screen_return_value + + # Mock out the Destination._run_view() method so we can verify the View class + # that is specified in the test sequence and then run the View. + mock_run_view.side_effect = run_view + # Mock out the View.run_screen() method so we can provide the # return value that is specified in the test sequence. - mock_run_screen.side_effect = next_return_value + mock_run_screen.side_effect = run_screen # Start the Controller with the first View_cls specified in the test sequence if sequence[0].expected_view != MainMenuView: @@ -172,5 +206,3 @@ class FlowTest(BaseTest): # Start the Controller and run the sequence Controller.get_instance().start(initial_destination=initial_destination) - - diff --git a/tests/test_flows.py b/tests/test_flows.py index f900d1bc..3b99eb9d 100644 --- a/tests/test_flows.py +++ b/tests/test_flows.py @@ -1,12 +1,13 @@ import pytest # Must import test base before the Controller -from base import FlowTest, FlowStep +from base import FlowTest, FlowStep, FlowTestUnexpectedViewException, FlowTestInvalidButtonDataSelectionException, FlowTestRunScreenNotExecutedException -from seedsigner.controller import Controller, FlowBasedTestUnexpectedViewError +from seedsigner.controller import Controller from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, RET_CODE__POWER_BUTTON from seedsigner.models.seed import Seed -from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView +from seedsigner.models.settings_definition import SettingsConstants +from seedsigner.views.seed_views import SeedBackupView, SeedMnemonicEntryView, SeedOptionsView, SeedWordsWarningView from seedsigner.views.view import MainMenuView, PowerOptionsView, UnhandledExceptionView from seedsigner.views.tools_views import ToolsMenuView, ToolsCalcFinalWordNumWordsView @@ -27,17 +28,49 @@ class TestFlowTest(FlowTest): ]) - def test_wrong_View_cls(self): + def test_FlowTestUnexpectedViewException(self): """ - Ensure that the FlowTest will raise an AssertionError if the next View in the - sequence is not the expected View. + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the next + View in the sequence is not the expected View. """ - with pytest.raises(FlowBasedTestUnexpectedViewError): + with pytest.raises(FlowTestUnexpectedViewException): self.run_sequence([ FlowStep(MainMenuView, button_data_selection=RET_CODE__POWER_BUTTON), FlowStep(ToolsMenuView), # <-- Wrong target View! Should raise an AssertionError. ]) - + + + def test_FlowTestInvalidButtonDataSelectionException(self): + """ + Ensure that the FlowTest will raise a FlowTestUnexpectedViewException if the + specified button_data_selection in invalid. + """ + with pytest.raises(FlowTestInvalidButtonDataSelectionException): + self.run_sequence([ + FlowStep(MainMenuView, button_data_selection="this is not a real button option!"), + ]) + + + def test_FlowTestRunScreenNotExecutedException(self): + """ + Ensure that the FlowTest will raise a FlowTestRunScreenNotExecutedException if the next + View in the sequence doesn't call its View.run_screen(). + """ + # Disable dire warnings so that the SeedWordsWarningView won't execute its run_screen() + self.settings.set_value(SettingsConstants.SETTING__DIRE_WARNINGS, SettingsConstants.OPTION__DISABLED) + self.controller.storage.set_pending_seed(Seed(mnemonic=["bacon"] * 24)) + self.controller.storage.finalize_pending_seed() + + with pytest.raises(FlowTestRunScreenNotExecutedException): + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(SeedOptionsView, button_data_selection=SeedOptionsView.BACKUP), + FlowStep(SeedBackupView, button_data_selection=SeedBackupView.VIEW_WORDS), + FlowStep(SeedWordsWarningView, screen_return_value=0), + ], + ) + def test_before_run_executes(self): """ @@ -69,12 +102,12 @@ class TestFlowTest(FlowTest): # And again, but this time with a View that requires input view_args self.reset_controller() + self.controller = Controller.get_instance() # Load a seed into the Controller - controller = Controller.get_instance() seed = Seed(mnemonic=["abandon "* 11 + "about"]) - controller.storage.set_pending_seed(seed) - controller.storage.finalize_pending_seed() + self.controller.storage.set_pending_seed(seed) + self.controller.storage.finalize_pending_seed() self.run_sequence( initial_destination_view_args=dict(seed_num=0), From 3c4c91a0af713b5763c8d558e9911fd7c7da4245 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sat, 17 Jun 2023 15:36:32 -0400 Subject: [PATCH 55/67] baby steps for jdlcdl (post hard reset) --- src/seedsigner/views/seed_views.py | 20 +++++++++----- tests/test_flows_seed.py | 43 ++++++++++++++++++++++++------ 2 files changed, 48 insertions(+), 15 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 02e160b5..5545ad06 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -617,11 +617,12 @@ class SeedExportXpubCoordinatorView(View): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[0] return Destination(SeedExportXpubWarningView, view_args=args, skip_current_view=True) - selected_menu_num = ButtonListScreen( + selected_menu_num = self.run_screen( + ButtonListScreen, title="Export Xpub", is_button_text_centered=False, button_data=self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS), - ).display() + ) if selected_menu_num < len(self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)): args["coordinator"] = self.settings.get_value(SettingsConstants.SETTING__COORDINATORS)[selected_menu_num] @@ -659,10 +660,11 @@ class SeedExportXpubWarningView(View): # Skip the WarningView entirely return destination - selected_menu_num = WarningScreen( + selected_menu_num = self.run_screen( + WarningScreen, status_headline="Privacy Leak!", text="""Xpub can be used to view all future transactions.""", - ).display() + ) if selected_menu_num == 0: # User clicked "I Understand" @@ -727,12 +729,13 @@ class SeedExportXpubDetailsView(View): finally: self.loading_screen.stop() - selected_menu_num = seed_screens.SeedExportXpubDetailsScreen( + selected_menu_num = self.run_screen( + seed_screens.SeedExportXpubDetailsScreen, fingerprint=fingerprint, has_passphrase=self.seed.passphrase is not None, derivation_path=derivation_path, xpub=xpub_base58, - ).display() + ) if selected_menu_num == 0: return Destination( @@ -784,7 +787,10 @@ class SeedExportXpubQRDisplayView(View): def run(self): - QRDisplayScreen(qr_encoder=self.qr_encoder).display() + self.run_screen( + QRDisplayScreen, + qr_encoder=self.qr_encoder + ) return Destination(MainMenuView) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index a8ea4e03..df93c073 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep +from seedsigner.models.settings import SettingsConstants from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -91,40 +92,66 @@ class TestSeedFlows(FlowTest): def test_export_xpub_flow(self): """ - Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the SeedOptionsView. + Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ # Load a finalized Seed into the Controller mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() + SIG_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # 0: single-sig + SCRIPT_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[0] # 0: native segwit + COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0] # 0: bluwallet + COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # 0: specter + + # TEST PASSES BUT RAISES WARNING + # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ + # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): + # TypeError: '<' not supported between instances of 'int' and 'MagicMock' + # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), - FlowStep(seed_views.SeedExportXpubScriptTypeView), - # TODO: Test is incomplete... + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=SIG_TYPE), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SCRIPT_TYPE), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=COORDINATOR), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), ] ) def test_export_xpub_skip_sig_type_flow(self): """ - + Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators """ # Load a finalized Seed into the Controller mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() + # exclusively set only one choice for each of sig_types, script_types and coordinators + self.settings.update(dict(sig_types=['ms'], script_types=['nes'], coordinators=['spa']), False) + + # TEST PASSES BUT RAISES WARNING + # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ + # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): + # TypeError: '<' not supported between instances of 'int' and 'MagicMock' + # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=seed_views.SeedExportXpubSigTypeView.SINGLE_SIG), - FlowStep(seed_views.SeedExportXpubScriptTypeView), - # TODO: Test is incomplete... + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubCoordinatorView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), ] ) From 7edc14fce8aa22c4781bad287c283ebd0f0b3b44 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 05:57:22 -0400 Subject: [PATCH 56/67] exhaustive flow test for exporting standard xpubs --- tests/test_flows_seed.py | 69 ++++++++++++++++++++++++++-------------- 1 file changed, 45 insertions(+), 24 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index df93c073..b5d4b370 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.models.settings import SettingsConstants +from seedsigner.models.settings import SettingsConstants as SC from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -90,41 +90,62 @@ class TestSeedFlows(FlowTest): self.run_sequence(sequence) - def test_export_xpub_flow(self): + def test_export_xpub_standard_flow(self): """ Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - # Load a finalized Seed into the Controller - mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() - self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) - self.controller.storage.finalize_pending_seed() - - SIG_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # 0: single-sig - SCRIPT_TYPE = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[0] # 0: native segwit - COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[0] # 0: bluwallet - COORDINATOR = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # 0: specter # TEST PASSES BUT RAISES WARNING # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): # TypeError: '<' not supported between instances of 'int' and 'MagicMock' # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) - self.run_sequence( - initial_destination_view_args=dict(seed_num=0), - sequence=[ - FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), - FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=SIG_TYPE), - FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=SCRIPT_TYPE), - FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=COORDINATOR), - FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), - FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), - FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), - FlowStep(MainMenuView), - ] + def test_standard_xpubs(sig_tuple, script_tuple, coord_tuple): + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_tuple[1]), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_tuple[1]), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coord_tuple[1]), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] ) + + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # these are (constant_value, display_name) tuples + sig_types = SC.ALL_SIG_TYPES + script_types = SC.ALL_SCRIPT_TYPES + coordinators = SC.ALL_COORDINATORS + + # enable non-defaults so they're available in views + self.settings.set_value(SC.SETTING__SIG_TYPES, [x for x,y in sig_types]) + self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) + self.settings.set_value(SC.SETTING__COORDINATORS, [x for x,y in coordinators]) + + # exhaustively test flows thru standard sig_types, script_types, and coordinators + for sig_tuple in sig_types: + for script_tuple in script_types: + for coord_tuple in coordinators: + # skip custom derivation + if script_tuple[0] == 'cus': + continue + # skip multisig taproot + elif sig_tuple[0] == "ms" and script_tuple[0] == 'tr': + continue + else: + print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) + test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) - def test_export_xpub_skip_sig_type_flow(self): + def test_export_xpub_skip_non_option_flow(self): """ Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators """ From ac2d92c1d4e7263e9457653849395c8105f18341 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 06:37:49 -0400 Subject: [PATCH 57/67] settings constants instead of lazy hardcoding --- tests/test_flows_seed.py | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index b5d4b370..3896beb5 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -135,10 +135,10 @@ class TestSeedFlows(FlowTest): for script_tuple in script_types: for coord_tuple in coordinators: # skip custom derivation - if script_tuple[0] == 'cus': + if script_tuple[0] == SC.CUSTOM_DERIVATION: continue # skip multisig taproot - elif sig_tuple[0] == "ms" and script_tuple[0] == 'tr': + elif sig_tuple[0] == SC.MULTISIG and script_tuple[0] == SC.TAPROOT: continue else: print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) @@ -155,7 +155,11 @@ class TestSeedFlows(FlowTest): self.controller.storage.finalize_pending_seed() # exclusively set only one choice for each of sig_types, script_types and coordinators - self.settings.update(dict(sig_types=['ms'], script_types=['nes'], coordinators=['spa']), False) + self.settings.update({ + SC.SETTING__SIG_TYPES: SC.MULTISIG, + SC.SETTING__SCRIPT_TYPES: SC.NESTED_SEGWIT, + SC.SETTING__COORDINATORS: SC.COORDINATOR__SPECTER_DESKTOP, + }, disable_missing_entries=False) # TEST PASSES BUT RAISES WARNING # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ From 1509df72b8c02ef4121625123bb6995df7631f35 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 07:59:56 -0400 Subject: [PATCH 58/67] flow test: export xpub for custom derivation --- src/seedsigner/views/seed_views.py | 5 ++-- tests/test_flows_seed.py | 38 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 5545ad06..1e14f914 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -570,9 +570,10 @@ class SeedExportXpubCustomDerivationView(View): def run(self): - ret = seed_screens.SeedExportXpubCustomDerivationScreen( + ret = self.run_screen( + seed_screens.SeedExportXpubCustomDerivationScreen, initial_value=self.custom_derivation_path, - ).display() + ) if ret == RET_CODE__BACK_BUTTON: return Destination(BackStackView) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 3896beb5..0ae78d23 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -145,6 +145,44 @@ class TestSeedFlows(FlowTest): test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) + def test_export_xpub_custom_derivation_flow(self): + """ + Export XPUB flow for custom derivation finishes at MainMenuView + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # enable custom derivation script_type setting (plus at least one more for a choice) + self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [SC.NATIVE_SEGWIT, SC.NESTED_SEGWIT, SC.CUSTOM_DERIVATION]) + + # get display names to access button choices in the views (ugh: hardcoding, is there a better way?) + sig_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SIG_TYPES)[0] # single sig + script_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SCRIPT_TYPES)[2] # custom derivation + coordinator = self.settings.get_multiselect_value_display_names(SC.SETTING__COORDINATORS)[3] # specter + + # TEST PASSES BUT RAISES WARNING + # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ + # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): + # TypeError: '<' not supported between instances of 'int' and 'MagicMock' + # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=sig_type), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=script_type), + FlowStep(seed_views.SeedExportXpubCustomDerivationView, screen_return_value="m/0'/0'"), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=coordinator), + FlowStep(seed_views.SeedExportXpubWarningView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubDetailsView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubQRDisplayView, screen_return_value=0), + FlowStep(MainMenuView), + ] + ) + + def test_export_xpub_skip_non_option_flow(self): """ Export XPUB flows w/o user choices when no other options for sig_types, script_types, and/or coordinators From b842f1fe1aba3b5b124cd55002e20dfe2972a32c Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 20 Jun 2023 08:51:07 -0400 Subject: [PATCH 59/67] local import of LoadScreenThread to avoid flow-test warnings --- src/seedsigner/views/seed_views.py | 5 ++++- tests/test_flows_seed.py | 15 --------------- 2 files changed, 4 insertions(+), 16 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index 1e14f914..cad143de 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -13,7 +13,7 @@ from seedsigner.gui.components import FontAwesomeIconConstants, SeedSignerCustom from seedsigner.helpers import embit_utils from seedsigner.gui.screens import (RET_CODE__BACK_BUTTON, ButtonListScreen, WarningScreen, DireWarningScreen, seed_screens) -from seedsigner.gui.screens.screen import LargeIconStatusScreen, LoadingScreenThread, QRDisplayScreen +from seedsigner.gui.screens.screen import LargeIconStatusScreen, QRDisplayScreen from seedsigner.models.decode_qr import DecodeQR from seedsigner.models.encode_qr import EncodeQR from seedsigner.models.psbt_parser import PSBTParser @@ -707,6 +707,9 @@ class SeedExportXpubDetailsView(View): selected_menu_num = 0 else: + # imported locally instead of at module level to avoid flow-test warnings: "comparing int to MagicMock" + from seedsigner.gui.screens.screen import LoadingScreenThread + # The derivation calc takes a few moments. Run the loading screen while we wait. self.loading_screen = LoadingScreenThread(text="Generating xpub...") self.loading_screen.start() diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 0ae78d23..187900a4 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -95,11 +95,6 @@ class TestSeedFlows(FlowTest): Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - # TEST PASSES BUT RAISES WARNING - # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ - # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # TypeError: '<' not supported between instances of 'int' and 'MagicMock' - # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) def test_standard_xpubs(sig_tuple, script_tuple, coord_tuple): self.run_sequence( initial_destination_view_args=dict(seed_num=0), @@ -162,11 +157,6 @@ class TestSeedFlows(FlowTest): script_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SCRIPT_TYPES)[2] # custom derivation coordinator = self.settings.get_multiselect_value_display_names(SC.SETTING__COORDINATORS)[3] # specter - # TEST PASSES BUT RAISES WARNING - # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ - # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # TypeError: '<' not supported between instances of 'int' and 'MagicMock' - # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ @@ -199,11 +189,6 @@ class TestSeedFlows(FlowTest): SC.SETTING__COORDINATORS: SC.COORDINATOR__SPECTER_DESKTOP, }, disable_missing_entries=False) - # TEST PASSES BUT RAISES WARNING - # File "seedsigner-dev/src/seedsigner/gui/components.py", line 316, in __post_init__ - # if not self.auto_line_break or full_text_width < self.supersampled_width - (2 * self.edge_padding * self.supersampling_factor): - # TypeError: '<' not supported between instances of 'int' and 'MagicMock' - # warnings.warn(pytest.PytestUnhandledThreadExceptionWarning(msg)) self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ From 39962b98d9148bffbf752d34e06d5a0958832e9a Mon Sep 17 00:00:00 2001 From: Jean Do Date: Thu, 22 Jun 2023 04:31:50 -0400 Subject: [PATCH 60/67] more consistent with existing convention --- tests/test_flows_seed.py | 36 ++++++++++++++++++++---------------- 1 file changed, 20 insertions(+), 16 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 187900a4..d2f22876 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,7 +1,7 @@ # Must import test base before the Controller from base import BaseTest, FlowTest, FlowStep -from seedsigner.models.settings import SettingsConstants as SC +from seedsigner.models.settings import SettingsConstants from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView from seedsigner.views import seed_views, scan_views @@ -116,24 +116,24 @@ class TestSeedFlows(FlowTest): self.controller.storage.finalize_pending_seed() # these are (constant_value, display_name) tuples - sig_types = SC.ALL_SIG_TYPES - script_types = SC.ALL_SCRIPT_TYPES - coordinators = SC.ALL_COORDINATORS + sig_types = SettingsConstants.ALL_SIG_TYPES + script_types = SettingsConstants.ALL_SCRIPT_TYPES + coordinators = SettingsConstants.ALL_COORDINATORS # enable non-defaults so they're available in views - self.settings.set_value(SC.SETTING__SIG_TYPES, [x for x,y in sig_types]) - self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) - self.settings.set_value(SC.SETTING__COORDINATORS, [x for x,y in coordinators]) + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators]) # exhaustively test flows thru standard sig_types, script_types, and coordinators for sig_tuple in sig_types: for script_tuple in script_types: for coord_tuple in coordinators: # skip custom derivation - if script_tuple[0] == SC.CUSTOM_DERIVATION: + if script_tuple[0] == SettingsConstants.CUSTOM_DERIVATION: continue # skip multisig taproot - elif sig_tuple[0] == SC.MULTISIG and script_tuple[0] == SC.TAPROOT: + elif sig_tuple[0] == SettingsConstants.MULTISIG and script_tuple[0] == SettingsConstants.TAPROOT: continue else: print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) @@ -150,12 +150,16 @@ class TestSeedFlows(FlowTest): self.controller.storage.finalize_pending_seed() # enable custom derivation script_type setting (plus at least one more for a choice) - self.settings.set_value(SC.SETTING__SCRIPT_TYPES, [SC.NATIVE_SEGWIT, SC.NESTED_SEGWIT, SC.CUSTOM_DERIVATION]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [ + SettingsConstants.NATIVE_SEGWIT, + SettingsConstants.NESTED_SEGWIT, + SettingsConstants.CUSTOM_DERIVATION + ]) # get display names to access button choices in the views (ugh: hardcoding, is there a better way?) - sig_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SIG_TYPES)[0] # single sig - script_type = self.settings.get_multiselect_value_display_names(SC.SETTING__SCRIPT_TYPES)[2] # custom derivation - coordinator = self.settings.get_multiselect_value_display_names(SC.SETTING__COORDINATORS)[3] # specter + sig_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SIG_TYPES)[0] # single sig + script_type = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__SCRIPT_TYPES)[2] # custom derivation + coordinator = self.settings.get_multiselect_value_display_names(SettingsConstants.SETTING__COORDINATORS)[3] # specter self.run_sequence( initial_destination_view_args=dict(seed_num=0), @@ -184,9 +188,9 @@ class TestSeedFlows(FlowTest): # exclusively set only one choice for each of sig_types, script_types and coordinators self.settings.update({ - SC.SETTING__SIG_TYPES: SC.MULTISIG, - SC.SETTING__SCRIPT_TYPES: SC.NESTED_SEGWIT, - SC.SETTING__COORDINATORS: SC.COORDINATOR__SPECTER_DESKTOP, + SettingsConstants.SETTING__SIG_TYPES: SettingsConstants.MULTISIG, + SettingsConstants.SETTING__SCRIPT_TYPES: SettingsConstants.NESTED_SEGWIT, + SettingsConstants.SETTING__COORDINATORS: SettingsConstants.COORDINATOR__SPECTER_DESKTOP, }, disable_missing_entries=False) self.run_sequence( From e25de29a64cd2ce4d3a2ec403600eed2b3bed8cf Mon Sep 17 00:00:00 2001 From: Jean Do Date: Thu, 22 Jun 2023 07:24:31 -0400 Subject: [PATCH 61/67] Export xpub flow test for disabled sig/script/coord settings --- tests/test_flows_seed.py | 64 +++++++++++++++++++++++++++++++++++++++- 1 file changed, 63 insertions(+), 1 deletion(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index d2f22876..7090e2fd 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,6 +1,7 @@ # Must import test base before the Controller -from base import BaseTest, FlowTest, FlowStep +from base import BaseTest, FlowTest, FlowStep, FlowBasedTestUnexpectedViewError +import pytest from seedsigner.models.settings import SettingsConstants from seedsigner.models.seed import Seed from seedsigner.views.view import MainMenuView @@ -140,6 +141,67 @@ class TestSeedFlows(FlowTest): test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) + def test_export_xpub_disabled_not_available_flow(self): + """ + If sig_type/script_type/coordinator disabled, then these options are not available + """ + # Load a finalized Seed into the Controller + mnemonic = "blush twice taste dawn feed second opinion lazy thumb play neglect impact".split() + self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) + self.controller.storage.finalize_pending_seed() + + # these are (constant_value, display_name) tuples + sig_types = SettingsConstants.ALL_SIG_TYPES + script_types = SettingsConstants.ALL_SCRIPT_TYPES + coordinators = SettingsConstants.ALL_COORDINATORS + + # these are the disabled types that we will be testing + disabled_sig = SettingsConstants.MULTISIG + disabled_script = SettingsConstants.TAPROOT + disabled_coord = SettingsConstants.COORDINATOR__NUNCHUK + + # enable all but our target disabled type + self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types if x!=disabled_sig]) + self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script]) + self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord]) + + # test that multisig is not an option via exception raised when NOT redirected to next step w/o a choice + with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=disabled_sig), + FlowStep(seed_views.SeedExportXpubScriptTypeView), + ] + ) + + # test that taproot is not an option via exception raised when choice is taproot + with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=disabled_script), + FlowStep(seed_views.SeedExportXpubCoordinatorView), + ] + ) + + # test that nunchuk is not an option via exception raised when choice is nunchuk + with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + self.run_sequence( + initial_destination_view_args=dict(seed_num=0), + sequence=[ + FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), + FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), + FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0), + FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord), + FlowStep(seed_views.SeedExportXpubWarningView), + ] + ) + + def test_export_xpub_custom_derivation_flow(self): """ Export XPUB flow for custom derivation finishes at MainMenuView From 35b3dc65cd3e5088d448e4fb1bc50162ac382f08 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Tue, 4 Jul 2023 16:55:48 -0400 Subject: [PATCH 62/67] code cleanup * funcs internal to test_funcs shouldn't start with "test" * comments in test modules preferred to repeating comments in code * type hinting in code preferred to explaining w/ comments --- src/seedsigner/views/seed_views.py | 4 +--- tests/base.py | 2 +- tests/test_flows_seed.py | 20 ++++++++++---------- 3 files changed, 12 insertions(+), 14 deletions(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index cad143de..c2458ee7 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -707,10 +707,8 @@ class SeedExportXpubDetailsView(View): selected_menu_num = 0 else: - # imported locally instead of at module level to avoid flow-test warnings: "comparing int to MagicMock" - from seedsigner.gui.screens.screen import LoadingScreenThread - # The derivation calc takes a few moments. Run the loading screen while we wait. + from seedsigner.gui.screens.screen import LoadingScreenThread self.loading_screen = LoadingScreenThread(text="Generating xpub...") self.loading_screen.start() diff --git a/tests/base.py b/tests/base.py index fb8f1bca..78d3c61a 100644 --- a/tests/base.py +++ b/tests/base.py @@ -26,7 +26,7 @@ class BaseTest: # Ensure there are no on-disk artifacts after running tests. Settings.SETTINGS_FILENAME = "settings-test.json" - # Mock out the loading screen so it can't spawn. + # Mock out the loading screen so it can't spawn. View classes must import locally! patch('seedsigner.gui.screens.screen.LoadingScreenThread').start() diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 7090e2fd..18628f1e 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -96,7 +96,7 @@ class TestSeedFlows(FlowTest): Selecting "Export XPUB" from the SeedOptionsView should enter the Export XPUB flow and end at the MainMenuView """ - def test_standard_xpubs(sig_tuple, script_tuple, coord_tuple): + def flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple): self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ @@ -116,10 +116,10 @@ class TestSeedFlows(FlowTest): self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() - # these are (constant_value, display_name) tuples - sig_types = SettingsConstants.ALL_SIG_TYPES - script_types = SettingsConstants.ALL_SCRIPT_TYPES - coordinators = SettingsConstants.ALL_COORDINATORS + # these are lists of (constant_value, display_name) tuples + sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES + script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES + coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS # enable non-defaults so they're available in views self.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [x for x,y in sig_types]) @@ -138,7 +138,7 @@ class TestSeedFlows(FlowTest): continue else: print('\n\ntest_standard_xpubs(%s, %s, %s)' % (sig_tuple, script_tuple, coord_tuple)) - test_standard_xpubs(sig_tuple, script_tuple, coord_tuple) + flowtest_standard_xpub(sig_tuple, script_tuple, coord_tuple) def test_export_xpub_disabled_not_available_flow(self): @@ -150,10 +150,10 @@ class TestSeedFlows(FlowTest): self.controller.storage.set_pending_seed(Seed(mnemonic=mnemonic)) self.controller.storage.finalize_pending_seed() - # these are (constant_value, display_name) tuples - sig_types = SettingsConstants.ALL_SIG_TYPES - script_types = SettingsConstants.ALL_SCRIPT_TYPES - coordinators = SettingsConstants.ALL_COORDINATORS + # these are lists of (constant_value, display_name) tuples + sig_types: list[tuple[str, str]] = SettingsConstants.ALL_SIG_TYPES + script_types: list[tuple[str, str]] = SettingsConstants.ALL_SCRIPT_TYPES + coordinators: list[tuple[str, str]] = SettingsConstants.ALL_COORDINATORS # these are the disabled types that we will be testing disabled_sig = SettingsConstants.MULTISIG From 01c4f51f3345212d02b0e6f60c7311c5a0ec31c4 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Fri, 7 Jul 2023 11:21:50 -0400 Subject: [PATCH 63/67] more precise flow-test exceptions --- tests/test_flows_seed.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/tests/test_flows_seed.py b/tests/test_flows_seed.py index 18628f1e..973518d3 100644 --- a/tests/test_flows_seed.py +++ b/tests/test_flows_seed.py @@ -1,5 +1,6 @@ # Must import test base before the Controller -from base import BaseTest, FlowTest, FlowStep, FlowBasedTestUnexpectedViewError +from base import BaseTest, FlowTest, FlowStep +from base import FlowTestUnexpectedViewException, FlowTestRunScreenNotExecutedException, FlowTestInvalidButtonDataSelectionException import pytest from seedsigner.models.settings import SettingsConstants @@ -165,31 +166,29 @@ class TestSeedFlows(FlowTest): self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [x for x,y in script_types if x!=disabled_script]) self.settings.set_value(SettingsConstants.SETTING__COORDINATORS, [x for x,y in coordinators if x!=disabled_coord]) - # test that multisig is not an option via exception raised when NOT redirected to next step w/o a choice - with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + # test that multisig is not an option via exception raised when redirected to next step instead of having a choice + with pytest.raises(FlowTestRunScreenNotExecutedException) as e: self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, button_data_selection=disabled_sig), - FlowStep(seed_views.SeedExportXpubScriptTypeView), ] ) # test that taproot is not an option via exception raised when choice is taproot - with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPORT_XPUB), FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=disabled_script), - FlowStep(seed_views.SeedExportXpubCoordinatorView), ] ) # test that nunchuk is not an option via exception raised when choice is nunchuk - with pytest.raises(FlowBasedTestUnexpectedViewError) as e: + with pytest.raises(FlowTestInvalidButtonDataSelectionException) as e: self.run_sequence( initial_destination_view_args=dict(seed_num=0), sequence=[ @@ -197,7 +196,6 @@ class TestSeedFlows(FlowTest): FlowStep(seed_views.SeedExportXpubSigTypeView, is_redirect=True), FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0), FlowStep(seed_views.SeedExportXpubCoordinatorView, button_data_selection=disabled_coord), - FlowStep(seed_views.SeedExportXpubWarningView), ] ) From b843b6b9fcfc787a937403242b2d98fda1abcb10 Mon Sep 17 00:00:00 2001 From: Jean Do Date: Sat, 8 Jul 2023 13:18:46 -0400 Subject: [PATCH 64/67] pr_339 post-review comments implemented --- src/seedsigner/views/psbt_views.py | 18 ++++++------------ tests/base.py | 2 +- tests/test_flows_psbt.py | 4 ++-- tests/test_flows_settings.py | 3 +-- tests/test_flows_view.py | 11 +++++------ 5 files changed, 15 insertions(+), 23 deletions(-) diff --git a/src/seedsigner/views/psbt_views.py b/src/seedsigner/views/psbt_views.py index 7c7e2bf4..fc5b4763 100644 --- a/src/seedsigner/views/psbt_views.py +++ b/src/seedsigner/views/psbt_views.py @@ -12,7 +12,7 @@ from seedsigner.models.psbt_parser import PSBTParser from seedsigner.models.qr_type import QRType from seedsigner.models.settings import SettingsConstants from seedsigner.gui.screens.psbt_screens import PSBTOverviewScreen, PSBTMathScreen, PSBTAddressDetailsScreen, PSBTChangeDetailsScreen, PSBTFinalizeScreen -from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen, WarningScreen) +from seedsigner.gui.screens.screen import (RET_CODE__BACK_BUTTON, ButtonListScreen, DireWarningScreen, QRDisplayScreen) from .view import BackStackView, MainMenuView, NotYetImplementedView, View, Destination @@ -131,12 +131,6 @@ class PSBTOverviewView(View): else: num_self_transfer_outputs += 1 - spend_amount = psbt_parser.spend_amount - change_amount = psbt_parser.change_amount - fee_amount = psbt_parser.fee_amount - num_inputs = psbt_parser.num_inputs - destination_addresses = psbt_parser.destination_addresses - # Everything is set. Stop the loading screen if self.loading_screen: self.loading_screen.stop() @@ -144,13 +138,13 @@ class PSBTOverviewView(View): # Run the overview screen selected_menu_num = self.run_screen( PSBTOverviewScreen, - spend_amount=spend_amount, - change_amount=change_amount, - fee_amount=fee_amount, - num_inputs=num_inputs, + spend_amount=psbt_parser.spend_amount, + change_amount=psbt_parser.change_amount, + fee_amount=psbt_parser.fee_amount, + num_inputs=psbt_parser.num_inputs, num_self_transfer_outputs=num_self_transfer_outputs, num_change_outputs=num_change_outputs, - destination_addresses=destination_addresses + destination_addresses=psbt_parser.destination_addresses ) if selected_menu_num == RET_CODE__BACK_BUTTON: diff --git a/tests/base.py b/tests/base.py index 78d3c61a..72bd55f3 100644 --- a/tests/base.py +++ b/tests/base.py @@ -50,7 +50,7 @@ class BaseTest: if os.path.exists(Settings.SETTINGS_FILENAME): os.remove(Settings.SETTINGS_FILENAME) except: - print(f"{Settings.SETTINGS_FILENAME} not found to be removed") + print(f"{Settings.SETTINGS_FILENAME} could not be removed") @classmethod diff --git a/tests/test_flows_psbt.py b/tests/test_flows_psbt.py index f8d9c7fd..f50491a1 100644 --- a/tests/test_flows_psbt.py +++ b/tests/test_flows_psbt.py @@ -1,6 +1,6 @@ from base import FlowTest, FlowStep -from seedsigner.controller import Controller, StopFlowBasedTest +from seedsigner.controller import Controller from seedsigner.views.view import MainMenuView from seedsigner.views import scan_views, seed_views, psbt_views @@ -53,4 +53,4 @@ class TestPSBTFlows(FlowTest): FlowStep(psbt_views.PSBTFinalizeView, button_data_selection=psbt_views.PSBTFinalizeView.APPROVE_PSBT), FlowStep(psbt_views.PSBTSignedQRDisplayView), FlowStep(MainMenuView) - ]) \ No newline at end of file + ]) diff --git a/tests/test_flows_settings.py b/tests/test_flows_settings.py index 4bf61212..8c603170 100644 --- a/tests/test_flows_settings.py +++ b/tests/test_flows_settings.py @@ -3,7 +3,6 @@ import os # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopFlowBasedTest from seedsigner.models import SettingsDefinition from seedsigner.models.settings import Settings from seedsigner.models.settings_definition import SettingsConstants @@ -28,7 +27,7 @@ class TestSettingsFlows(FlowTest): FlowStep(settings_views.SettingsMenuView, button_data_selection=settings_entry.display_name), FlowStep(settings_views.SettingsEntryUpdateSelectionView, button_data_selection=settings_entry.get_selection_option_display_name_by_value(SettingsConstants.OPTION__ENABLED)), FlowStep(settings_views.SettingsEntryUpdateSelectionView, screen_return_value=RET_CODE__BACK_BUTTON), - FlowStep(settings_views.SettingsMenuView, screen_return_value=StopFlowBasedTest()), + FlowStep(settings_views.SettingsMenuView), ]) # Settings file should now exist diff --git a/tests/test_flows_view.py b/tests/test_flows_view.py index 43c7b9f0..3f4d537a 100644 --- a/tests/test_flows_view.py +++ b/tests/test_flows_view.py @@ -3,7 +3,6 @@ from mock import patch # Must import test base before the Controller from base import FlowTest, FlowStep -from seedsigner.controller import StopFlowBasedTest from seedsigner.gui.screens.screen import RET_CODE__POWER_BUTTON from seedsigner.models.settings import Settings from seedsigner.views.tools_views import ToolsCalcFinalWordNumWordsView, ToolsMenuView @@ -21,7 +20,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.RESET), - FlowStep(RestartView, screen_return_value=StopFlowBasedTest()), + FlowStep(RestartView), ]) @@ -33,7 +32,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), - FlowStep(PowerOffView, screen_return_value=StopFlowBasedTest()), + FlowStep(PowerOffView), ]) # And again, but this time as if we were in the SeedSigner OS @@ -42,7 +41,7 @@ class TestViewFlows(FlowTest): FlowStep(MainMenuView, screen_return_value=RET_CODE__POWER_BUTTON), FlowStep(PowerOptionsView, button_data_selection=PowerOptionsView.POWER_OFF), FlowStep(PowerOffView), # returns BackStackView - FlowStep(PowerOptionsView, screen_return_value=StopFlowBasedTest()), + FlowStep(PowerOptionsView), ]) @@ -58,7 +57,7 @@ class TestViewFlows(FlowTest): self.run_sequence([ FlowStep(IncompleteView), FlowStep(NotYetImplementedView), - FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), + FlowStep(MainMenuView), ]) @@ -71,5 +70,5 @@ class TestViewFlows(FlowTest): FlowStep(ToolsMenuView, button_data_selection=ToolsMenuView.KEYBOARD), FlowStep(ToolsCalcFinalWordNumWordsView, screen_return_value=Exception("Test exception")), # <-- force an exception FlowStep(UnhandledExceptionView), - FlowStep(MainMenuView, screen_return_value=StopFlowBasedTest()), + FlowStep(MainMenuView), ]) From f6e7136c07f6f68904b420352569a3ebeba784f4 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 16 Jul 2023 08:11:01 -0500 Subject: [PATCH 65/67] Update README.md --- README.md | 26 +++++++++++++------------- 1 file changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 2741969e..68130c57 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ # Project Summary -The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multi-signature wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. +The goal of SeedSigner is to lower the cost and complexity of Bitcoin multi-signature wallet use. To accomplish this goal, SeedSigner offers anyone the opportunity to build a verifiably air-gapped, stateless Bitcoin signing device using inexpensive, publicly available hardware components (usually < $50). SeedSigner helps users save with Bitcoin by assisting with trustless private key generation and multisignature (aka "multisig") wallet setup, and helps users transact with Bitcoin via a secure, air-gapped QR-exchange signing model. Additional information about the project can be found at [SeedSigner.com](https://seedsigner.com). @@ -26,20 +26,22 @@ You can follow [@SeedSigner](https://twitter.com/SeedSigner) on Twitter for the If you have specific questions about the project, our [Telegram Group](https://t.me/joinchat/GHNuc_nhNQjLPWsS) is a great place to ask them. ### Feature Highlights: -* Calculate word 12/24 of a BIP39 seed phrase -* Create a 24-word BIP39 seed phrase with 99 dice rolls -* Create a 24-word BIP39 seed phrase by taking a digital photo -* Temporarily store up to 3 seed phrases while device is powered -* Guided interface to manually create a SeedQR for instant input [(demo video here)](https://youtu.be/c1-PqTNx1vc) -* BIP39 passphrase / word 25 support -* Native Segwit Multisig XPUB generation w/ QR display -* Scan and parse transaction data from animated QR codes +* Calculate the final word (aka checksum) of a 12- or 24-word BIP39 seed phrase +* Create a 24-word BIP39 seed phrase with 99 dice rolls or a 12-word with 50 rolls [(Verifying dice seed generation)](docs/dice_verification.md) +* Create a 12- or 24-word BIP39 seed phrase via image entropy from the onboard camera +* Temporarily stores seeds in memory while the device is powered; all memory is wiped when power is removed +* SD card removable after boot to ensure no secret data can be written to it +* Guided interface to manually transcribe a seed to the SeedQR format for instant seed loading [(demo video here)](https://youtu.be/c1-PqTNx1vc) +* BIP39 passphrase (aka "word 25") support +* Native Segwit Multisig XPUB generation +* PSBT-compliant; scan and parse transaction data from animated QR codes * Sign transactions & transfer XPUB data using animated QR codes [(demo video here)](https://youtu.be/LPqvdQ2gSzs) -* Live preview during photo-to-seed and QR scanning UX +* Live preview during image entropy seed generation and QR scanning UX * Optimized seed word entry interface * Support for Bitcoin Mainnet & Testnet * Support for custom user-defined derivation paths * On-demand receive address verification +* Address Explorer for single sig and multisig wallets * User-configurable QR code display density * Responsive, event-driven user interface @@ -52,10 +54,8 @@ If you have specific questions about the project, our [Telegram Group](https://t * If you think SeedSigner adds value to the Bitcoin ecosystem, please help us spread the word! (tweets, pics, videos, etc.) ### Planned Upcoming Improvements / Functionality: -* Single-sig and multi-sig change address verification -* Re-imagined, graphically-focused user interface * Multi-language support -* Customized Linux live-boot OS to allow MicroSD card removal +* Port to MicroPython to broaden the range of compatible hardware to include low-cost microcontrollers * Other optimizations based on user feedback! --------------- From bba65ea00bd9c3e8bfe8bf48564c300827db7d37 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sun, 16 Jul 2023 08:13:31 -0500 Subject: [PATCH 66/67] Update README.md --- README.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/README.md b/README.md index 68130c57..7ef232ce 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,8 @@ If you have specific questions about the project, our [Telegram Group](https://t ### Planned Upcoming Improvements / Functionality: * Multi-language support +* Significantly faster boot time +* Reproducible builds * Port to MicroPython to broaden the range of compatible hardware to include low-cost microcontrollers * Other optimizations based on user feedback! From 8e757544ba98a2ca46ce26092e3fd39c00baf2f8 Mon Sep 17 00:00:00 2001 From: Nick Klockenga Date: Thu, 20 Jul 2023 14:50:49 -0400 Subject: [PATCH 67/67] Update seed_views.py I accidentally backed out change PR #314 when I merged PR #339. This small text change is to correct my mistake. --- src/seedsigner/views/seed_views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/seedsigner/views/seed_views.py b/src/seedsigner/views/seed_views.py index c8f5cacb..41491d47 100644 --- a/src/seedsigner/views/seed_views.py +++ b/src/seedsigner/views/seed_views.py @@ -824,7 +824,7 @@ class SeedWordsWarningView(View): selected_menu_num = self.run_screen( DireWarningScreen, - text="""You must keep your seed words private & away from all online devices.""", + text="""Never input your seed phrase into a device that connects to the internet.""", ) if selected_menu_num == 0: