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),