From c61de8522fac04f5886fb016f25bb9e3288f1585 Mon Sep 17 00:00:00 2001 From: kdmukai Date: Sat, 4 Mar 2023 14:21:15 -0600 Subject: [PATCH] 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()