Files
seedsigner/tests/test_flows_tools.py
kdmukai 9377f0d9c9 Collect a minimum pool of preview frames for image entropy
Previously a single button press captured the final image immediately,
so the entropy could come from just one frame. Now the live preview must
first collect a pool of PREVIEW_POOL_SIZE (50) distinct, non-blank frames
before the final image can be taken, and a progress bar/counter shows the
user how many have been gathered.

- Skip duplicate frames the camera re-hands us (sha256 of the frame bytes)
  and reject flat single-color frames (getextrema), so only genuinely new
  frames count toward the pool.
- Gate the final capture on a full pool, and rework the held-button guard
  (is_maybe_still_holding) so a still-held button can't auto-capture the
  instant the pool fills.

Extend tests/test_tools_screens.py and tests/test_flows_tools.py to cover
the pool-fill gating and the held-button behavior at capture time.
2026-08-09 15:44:20 -05:00

343 lines
19 KiB
Python

# Must import test base before the Controller
from base import FlowTest, FlowStep
from seedsigner.controller import Controller
from seedsigner.gui.screens.screen import RET_CODE__BACK_BUTTON, ButtonOption
from seedsigner.models.seed import Seed
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views.view import ErrorView, MainMenuView
from seedsigner.views import scan_views, seed_views, tools_views
class TestToolsFlows(FlowTest):
def test__address_explorer__flow(self):
"""
Test the simplest AddressExplorer flow when a seed is already loaded.
"""
controller = Controller.get_instance()
seed = Seed(mnemonic=["abandon "* 11 + "about"])
controller.storage.set_pending_seed(seed)
controller.storage.finalize_pending_seed()
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # ret 1st onboard seed
FlowStep(seed_views.SeedExportXpubScriptTypeView, button_data_selection=ButtonOption(SettingsDefinition.get_settings_entry(SettingsConstants.SETTING__SCRIPT_TYPES).get_selection_option_display_name_by_value(SettingsConstants.NATIVE_SEGWIT), return_data=SettingsConstants.NATIVE_SEGWIT)),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE),
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list
FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value
FlowStep(tools_views.ToolsAddressExplorerAddressListView),
])
def test__address_explorer__loadseed__sideflow(self):
"""
Finalizing a seed during the Address Explorer flow should return to the next
Address Explorer step upon completion.
"""
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
# Finalize the new seed w/out passphrase
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED),
FlowStep(scan_views.ScanSeedQRView, before_run=load_seed_into_decoder), # simulate read SeedQR
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView),
])
assert self.controller.resume_main_flow == Controller.FLOW__ADDRESS_EXPLORER
# Reset
self.controller.storage.seeds.clear()
self.controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"]))
# Finalize the new seed w/passphrase
self.run_sequence(
sequence=[
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.PASSPHRASE),
FlowStep(seed_views.SeedAddPassphraseView, screen_return_value=dict(passphrase="mypassphrase")),
FlowStep(seed_views.SeedReviewPassphraseView, button_data_selection=seed_views.SeedReviewPassphraseView.DONE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView),
]
)
def test__address_explorer__load_electrum_seed__sideflow(self):
"""
Loading an Electrum seed during the Address Explorer flow should return to
the Address Explorer flow upon completion, skip the script type selection,
and successfully generate receive or change addresses.
"""
self.settings.set_value(SettingsConstants.SETTING__ELECTRUM_SEEDS, SettingsConstants.OPTION__ENABLED)
sequence = [
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.TYPE_ELECTRUM),
FlowStep(seed_views.SeedElectrumMnemonicStartView),
]
# Load an Electrum mnemonic during the flow (same one used in test_seed.py)
for word in "regular reject rare profit once math fringe chase until ketchup century escape".split():
sequence += [
FlowStep(seed_views.SeedMnemonicEntryView, screen_return_value=word),
]
sequence += [
FlowStep(seed_views.SeedFinalizeView, button_data_selection=seed_views.SeedFinalizeView.FINALIZE),
FlowStep(seed_views.SeedOptionsView, is_redirect=True),
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE),
FlowStep(tools_views.ToolsAddressExplorerAddressListView),
]
self.run_sequence(sequence)
def test__address_explorer__scan_wrong_qrtype__flow(self):
"""
Scanning the wrong type of QR code when a SeedQR is expected should route to ErrorView
"""
def load_wrong_data_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("bc1qar0srrr7xfkvy5l643lydnw9re59gtzzwf5mdq")
# Finalize the new seed w/out passphrase
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED),
FlowStep(scan_views.ScanSeedQRView, before_run=load_wrong_data_into_decoder), # simulate scanning the wrong QR type
FlowStep(ErrorView),
])
def test__address_explorer__back_button__flow(self):
"""
Backing out of AddressExplorer behavior depends on current Settings:
* Multiple script types enabled: BACK to SeedExportXpubScriptTypeView
* One script type enabled: BACK to where we started:
* SeedOptions
* ToolsAddressExplorerSelectSourceView if seed was already onboard
* MainMenu if no seed was onboard when we entered via ToolsMenu (loading a
seed during the flow wipes out any history before the load so our only
option is to return to MainMenu).
"""
def load_seed_into_decoder(view: scan_views.ScanView):
view.decoder.add_data("0000" * 11 + "0003")
controller = Controller.get_instance()
seed = Seed(mnemonic=["abandon "* 11 + "about"])
controller.storage.set_pending_seed(seed)
controller.storage.finalize_pending_seed()
# Scenario 1: Seed already onboard, multiple script types enabled, BACK can still
# change script type selection.
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [SettingsConstants.NATIVE_SEGWIT, SettingsConstants.TAPROOT])
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, screen_return_value=0), # select the first onboard seed
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPLORER),
FlowStep(seed_views.SeedExportXpubScriptTypeView, screen_return_value=0),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(seed_views.SeedExportXpubScriptTypeView),
])
# Scenario 2: Seed already onboard, one script type enabled, started from
# SeedOptionsView, BACK to SeedOptionsView.
self.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [SettingsConstants.NATIVE_SEGWIT])
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.SEEDS),
FlowStep(seed_views.SeedsMenuView, screen_return_value=0), # select the first onboard seed
FlowStep(seed_views.SeedOptionsView, button_data_selection=seed_views.SeedOptionsView.EXPLORER),
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(seed_views.SeedOptionsView),
])
# Scenario 3: Seed already onboard, one script type enabled, started from
# ToolsMenu, BACK to ToolsAddressExplorerSelectSourceView.
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, screen_return_value=0), # select the first onboard seed
FlowStep(seed_views.SeedExportXpubScriptTypeView, is_redirect=True),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView),
])
# Scenario 4: No seed onboard, one script type enabled, started from Tools, BACK
# can only go to MainMenu because of mid-flow seed load.
controller.discard_seed(seed)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_SEED),
FlowStep(scan_views.ScanSeedQRView, 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, is_redirect=True),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, screen_return_value=RET_CODE__BACK_BUTTON),
FlowStep(MainMenuView),
])
def test__address_explorer__legacy_multisig_p2sh__flow(self):
"""
Address Explorer should be able to parse a legacy multisig p2sh (m/45')
descriptor and generate addresses.
"""
def load_descriptor_into_decoder(view: scan_views.ScanView):
# descriptor from test_psbt_parser.py
p2sh_descriptor = "sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}")
view.decoder.add_data(p2sh_descriptor)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerSelectSourceView, button_data_selection=tools_views.ToolsAddressExplorerSelectSourceView.SCAN_DESCRIPTOR),
FlowStep(scan_views.ScanWalletDescriptorView, before_run=load_descriptor_into_decoder), # simulate read descriptor QR
FlowStep(seed_views.MultisigWalletDescriptorView, button_data_selection=seed_views.MultisigWalletDescriptorView.ADDRESS_EXPLORER),
FlowStep(tools_views.ToolsAddressExplorerAddressTypeView, button_data_selection=tools_views.ToolsAddressExplorerAddressTypeView.RECEIVE),
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=10), # ret NEXT page of addrs
FlowStep(tools_views.ToolsAddressExplorerAddressListView, screen_return_value=4), # ret a specific addr from the list
FlowStep(tools_views.ToolsAddressExplorerAddressView), # runs until dismissed; no ret value
FlowStep(tools_views.ToolsAddressExplorerAddressListView),
])
def test__verify_address__legacy_multisig_p2sh__flow(self):
"""
Address Explorer should be able to scan a legacy multisig p2sh address and
verify it against its descriptor.
"""
def load_address_into_decoder(view: scan_views.ScanView):
# Receive addr @ index 5 from test_psbt_parser.py
view.decoder.add_data("2N5eN5vUpgsLHAGzKm2VfmYyvNwXmCug5dH")
def load_descriptor_into_decoder(view: scan_views.ScanView):
# descriptor from test_psbt_parser.py
p2sh_descriptor = "sh(sortedmulti(2,[0f889044/45h]tpubD8NkS3Gngj7L4FJRYrwojKhsx2seBhrNrXVdvqaUyvtVe1YDCVcziZVa9g3KouXz7FN5CkGBkoC16nmNu2HcG9ubTdtCbSW8DEXSMHmmu62/<0;1>/*,[03cd0a2b/45h]tpubD8HkLLgkdJkVitn1i9CN4HpFKJdom48iKm9PyiXYz5hivn1cGz6H3VeS6ncmCEgamvzQA2Qofu2YSTwWzvuaYWbJDEnvTUtj5R96vACdV6L/<0;1>/*,[769f695c/45h]tpubD98hRDKvtATTM8hy5Vvt5ZrvDXwJvrUZm1p1mTKDmd7FqUHY9Wj2k4X1CvxjjtTf3JoChWqYbnWjfkRJ65GQnpVJKbbMfjnGzCwoBUXafyM/<0;1>/*))#uardwtq4".replace("<0;1>", "{0,1}")
view.decoder.add_data(p2sh_descriptor)
settings = Controller.get_instance().settings
settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.VERIFY_ADDRESS),
FlowStep(scan_views.ScanAddressView, before_run=load_address_into_decoder), # simulate read address QR
FlowStep(seed_views.AddressVerificationStartView, is_redirect=True),
FlowStep(seed_views.AddressVerificationSigTypeView, button_data_selection=seed_views.AddressVerificationSigTypeView.MULTISIG),
FlowStep(seed_views.LoadMultisigWalletDescriptorView, button_data_selection=seed_views.LoadMultisigWalletDescriptorView.SCAN),
FlowStep(scan_views.ScanWalletDescriptorView, before_run=load_descriptor_into_decoder), # simulate read descriptor QR
FlowStep(seed_views.MultisigWalletDescriptorView, screen_return_value=0),
FlowStep(seed_views.SeedAddressVerificationView),
FlowStep(seed_views.SeedAddressVerificationSuccessView),
])
def test__verify_address__singlesig__flow(self):
"""
Address Explorer should be able to scan a singlesig address and
verify it against a loaded key.
"""
controller = Controller.get_instance()
controller.storage.set_pending_seed(Seed(mnemonic=["abandon "* 11 + "about"]))
controller.storage.finalize_pending_seed()
settings = controller.settings
settings.set_value(SettingsConstants.SETTING__NETWORK, SettingsConstants.REGTEST)
addrs = [
# Native segwit regtest receive addr @ index 6
"bcrt1q4e9q5taxnsvc6m0uxv6h75mkzvnkxeqk6l90u2",
# Taproot regtest change addr @ index 48
"bcrt1pj5v8ean2hc5lh2djsgfx4j9uc0n67942ngv6q9r49qv88ex5mrwsn3u4f7",
]
for test_addr in addrs:
def load_address_into_decoder(view: scan_views.ScanView):
# Native segwit regtest receive addr @ index 6
view.decoder.add_data(test_addr)
self.run_sequence([
FlowStep(MainMenuView, button_data_selection=MainMenuView.TOOLS),
FlowStep(tools_views.ToolsMenuView, button_data_selection=tools_views.ToolsMenuView.VERIFY_ADDRESS),
FlowStep(scan_views.ScanAddressView, before_run=load_address_into_decoder), # simulate read address QR
FlowStep(seed_views.AddressVerificationStartView, is_redirect=True),
FlowStep(seed_views.SeedSelectSeedView, screen_return_value=0),
FlowStep(seed_views.SeedAddressVerificationView),
FlowStep(seed_views.SeedAddressVerificationSuccessView),
])
class TestToolsImageEntropyFlows(FlowTest):
def test__image_entropy__incorrect_preview_frame_count_aborts(self):
"""
If the live preview screen returns anything other than the required number of
entropy frames, the View must raise rather than continue on to seed creation.
"""
from unittest.mock import Mock
from seedsigner.views.view import UnhandledExceptionView
from seedsigner.gui.screens.tools_screens import ToolsImageEntropyLivePreviewScreen
# Empty list (no frames)
self.run_sequence([
FlowStep(tools_views.ToolsImageEntropyLivePreviewView, screen_return_value=[]),
FlowStep(UnhandledExceptionView),
])
# Too few
self.run_sequence([
FlowStep(tools_views.ToolsImageEntropyLivePreviewView, screen_return_value=[Mock()] * 10),
FlowStep(UnhandledExceptionView),
])
# Too many
self.run_sequence([
FlowStep(tools_views.ToolsImageEntropyLivePreviewView, screen_return_value=[Mock()] * (ToolsImageEntropyLivePreviewScreen.PREVIEW_POOL_SIZE + 5)),
FlowStep(UnhandledExceptionView),
])
# Degenerate None
self.run_sequence([
FlowStep(tools_views.ToolsImageEntropyLivePreviewView, screen_return_value=None),
FlowStep(UnhandledExceptionView),
])
def test__image_entropy__screen_exception_does_not_advance(self):
"""
There is no explicit handling for the live preview screen raising, but the flow
must never continue on to seed creation when it does.
"""
from seedsigner.views.view import UnhandledExceptionView
self.run_sequence([
FlowStep(tools_views.ToolsImageEntropyLivePreviewView, screen_return_value=Exception("Test exception")),
FlowStep(UnhandledExceptionView),
])
def test__image_entropy__full_preview_frames_advances(self):
""" A full set of preview frames advances to the final image capture. """
from unittest.mock import Mock
from seedsigner.gui.screens.tools_screens import ToolsImageEntropyLivePreviewScreen
self.run_sequence([
FlowStep(tools_views.ToolsImageEntropyLivePreviewView, screen_return_value=[Mock()] * ToolsImageEntropyLivePreviewScreen.PREVIEW_POOL_SIZE),
FlowStep(tools_views.ToolsImageEntropyFinalImageView),
])