Files
seedsigner/tests/screenshot_generator/generator.py
T
Nick KlockengaandGitHub 422a7b9f25 Merge pull request #860 from kdmukai/2025-12_mocks_in_screenshot_generator
[Refactor] Screenshot generator: Better state management via mocks
2026-02-16 11:19:23 -05:00

574 lines
38 KiB
Python

import embit
import os
import pathlib
import pytest
import random
import sys
import time
from contextlib import contextmanager
from dataclasses import dataclass
from PIL import ImageFont
from unittest.mock import Mock, patch, MagicMock
from embit import compact
from embit.psbt import PSBT, OutputScope
from embit.script import Script
# Prevent importing modules w/Raspi hardware dependencies.
# These must precede any SeedSigner imports.
sys.modules['seedsigner.hardware.displays.st7789_mpy'] = MagicMock()
sys.modules['seedsigner.hardware.displays.ili9341'] = MagicMock()
sys.modules['seedsigner.views.screensaver.ScreensaverScreen'] = MagicMock()
sys.modules['RPi'] = MagicMock()
sys.modules['RPi.GPIO'] = MagicMock()
sys.modules['seedsigner.hardware.camera.Camera'] = MagicMock()
sys.modules['seedsigner.hardware.microsd'] = MagicMock()
from seedsigner.controller import Controller
from seedsigner.gui.renderer import Renderer
from seedsigner.gui.screens.screen import BaseScreen
from seedsigner.gui.screens.seed_screens import SeedAddPassphraseScreen
from seedsigner.gui.toast import RemoveSDCardToastManagerThread, SDCardStateChangeToastManagerThread
from seedsigner.gui.toast import DefaultToast, InfoToast, SuccessToast, WarningToast, ErrorToast, DireWarningToast
from seedsigner.hardware.microsd import MicroSD
from seedsigner.helpers import embit_utils
from seedsigner.models.decode_qr import DecodeQR
from seedsigner.models.encode_qr import BaseQrEncoder
from seedsigner.models.psbt_parser import OPCODES, PSBTParser
from seedsigner.models.qr_type import QRType
from seedsigner.models.seed import Seed
from seedsigner.models.settings import Settings
from seedsigner.models.settings_definition import SettingsConstants, SettingsDefinition
from seedsigner.views import (MainMenuView, PowerOptionsView, RestartView, RemoveMicroSDWarningView, NotYetImplementedView, UnhandledExceptionView,
psbt_views, seed_views, settings_views, tools_views, scan_views)
from seedsigner.views.screensaver import OpeningSplashView
from seedsigner.views.view import CameraConnectionErrorView, NetworkMismatchErrorView, OptionDisabledView, PowerOffView
from .utils import ScreenshotComplete, ScreenshotConfig, ScreenshotRenderer
import warnings; warnings.warn = lambda *args, **kwargs: None
# Dynamically generate a pytest test run for each locale
@pytest.mark.parametrize("locale", [x for x, y in SettingsConstants.get_detected_languages()])
def test_generate_all(locale, target_locale):
"""
`target_locale` is a fixture created in conftest.py via the `--locale` command line arg.
Optionally skips all other locales.
"""
if target_locale and locale != target_locale:
pytest.skip(f"Skipping {locale}")
if not ImageFont.core.HAVE_RAQM:
# We can't generate pixel-perfect screenshots that match what gets rendered on
# the device if we don't have libraqm.
pytest.fail("libraqm is not installed.")
generate_screenshots(locale)
"""**************************************************************************************
Set up global test data that will be re-used across a variety of screenshots and for
all locales.
**************************************************************************************"""
# Single sig ("abandon" test wallet) tx; 1mil sat input, 1 external output, 1 self-transfer, 1 change output, 400 sat fee
BASE64_SINGLE_SIG_PSBT = """cHNidP8BAJACAAAAAT8SmJzLhTMNgtn9QOmBmet0nnqqIJpsgpgBN5JWNJCxAQAAAAD9////A5CfBwAAAAAAFgAULzSqHPAKU7BVopGgOn1F8KaYi1KQ0AMAAAAAABYAFGQh2ztS8DzX4kGVKUKQhFPrlNNIkNADAAAAAAAWABRvoBZQCjxqc367Jg4t3KeLqSNFWGYAAABPAQQ1h88DDvSxr4AAAAA8jCA37kwWIdoNNI21EWNwmmItDSg43ebYQZxR9jAcYgO4jg++P2RjN+2TvAwPO4Q/z30lieXsiEdU5kAgJ6iQtBBzxdoKVAAAgAEAAIAAAACAAAEAcQIAAAABF84F9MpvLC1H3Cyews1xoNZ4ch3uJMu8jonehCIqmScAAAAAAP3///8CM6/2KQEAAAAWABQQumvlzzcWsGXBNIOliqXTvr9YxEBCDwAAAAAAFgAU0MSj7wnpl7bpnjl+UY/j5BoRjKFNAAAAAQEfQEIPAAAAAAAWABTQxKPvCemXtumeOX5Rj+PkGhGMoQEDBAEAAAAiBgLnqyU3tdSelwMJquBunknzbOHJ/rvUTsjg0cygtPnDGRhzxdoKVAAAgAEAAIAAAACAAAAAAAAAAAAAIgIDXUnszVTQCZ5DZ2J3x6bUYl1hHaiKXfSb+VF6d5Gnd6UYc8XaClQAAIABAACAAAAAgAEAAAAAAAAAAAAiAgPu7SBaaQIv7UpioCRX82mbGcBr90v4AazG2a6EvBap4RhzxdoKVAAAgAEAAIAAAACAAAAAAAEAAAAA"""
BASE64_MULTISIG_PSBT = """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"""
mnemonic_12b = ["abandon"] * 11 + ["about"]
seed_12b = Seed(mnemonic=mnemonic_12b, wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
def add_op_return_to_psbt(psbt: PSBT, raw_payload_data: bytes):
data = (compact.to_bytes(OPCODES.OP_RETURN) +
compact.to_bytes(OPCODES.OP_PUSHDATA1) +
compact.to_bytes(len(raw_payload_data)) +
raw_payload_data)
script = Script(data)
output = OutputScope()
output.script_pubkey = script
output.value = 0
psbt.outputs.append(output)
return psbt.to_string()
# Prep a PSBT with a human-readable OP_RETURN
raw_payload_data = "Chancellor on the brink of third bailout for banks".encode()
psbt = PSBT.from_base64(BASE64_MULTISIG_PSBT)
# Simplify the output side
output = psbt.outputs[-1]
psbt.outputs.clear()
psbt.outputs.append(output)
assert len(psbt.outputs) == 1
BASE64_PSBT_WITH_OP_RETURN_TEXT = add_op_return_to_psbt(psbt, raw_payload_data)
# Prep a PSBT with a (repeatably) random 80-byte OP_RETURN
random.seed(6102)
BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES = add_op_return_to_psbt(PSBT.from_base64(BASE64_MULTISIG_PSBT), random.randbytes(80))
mnemonic_12 = "forum undo fragile fade shy sign arrest garment culture tube off merit".split()
mnemonic_24 = "attack pizza motion avocado network gather crop fresh patrol unusual wild holiday candy pony ranch winter theme error hybrid van cereal salon goddess expire".split()
seed_12 = Seed(mnemonic=mnemonic_12, passphrase="cap*BRACKET3stove", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_24 = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
seed_24_w_passphrase = Seed(mnemonic=mnemonic_24, passphrase="some-PASS*phrase9", wordlist_language_code=SettingsConstants.WORDLIST_LANGUAGE__ENGLISH)
MULTISIG_WALLET_DESCRIPTOR = """wsh(sortedmulti(1,[22bde1a9/48h/1h/0h/2h]tpubDFfsBrmpj226ZYiRszYi2qK6iGvh2vkkghfGB2YiRUVY4rqqedHCFEgw12FwDkm7rUoVtq9wLTKc6BN2sxswvQeQgp7m8st4FP8WtP8go76/{0,1}/*,[73c5da0a/48h/1h/0h/2h]tpubDFH9dgzveyD8zTbPUFuLrGmCydNvxehyNdUXKJAQN8x4aZ4j6UZqGfnqFrD4NqyaTVGKbvEW54tsvPTK2UoSbCC1PJY8iCNiwTL3RWZEheQ/{0,1}/*))#3jhtf6yx"""
# Wrap QRDisplayScreen's `render_brightness_tip` in a simple View + Screen so we
# can call it outside of its child thread and generate a screenshot.
class SeedExportXpubQR_ScreenBrightnessView(seed_views.SeedExportXpubQRDisplayView):
@dataclass
class QRDisplayScreenBrightnessTipScreen(BaseScreen):
qr_encoder: BaseQrEncoder = None
def _render(self):
from seedsigner.gui.screens.screen import QRDisplayScreen
image = self.qr_encoder.part_to_image(self.qr_encoder.cur_part(), 240, 240, border=2, background_color="white")
QRDisplayScreen.QRDisplayThread.render_brightness_tip(None, image)
self.renderer.show_image(image)
def run(self):
self.run_screen(
SeedExportXpubQR_ScreenBrightnessView.QRDisplayScreenBrightnessTipScreen,
qr_encoder=self.qr_encoder, # initialized by SeedExportXpubQRDisplayView
)
def generate_screenshots(locale):
"""
The `Renderer` class is mocked so that calls in the normal code are ignored
(necessary to avoid having it trying to wire up hardware dependencies).
When the `Renderer` instance is needed, we patch in our own test-only
`ScreenshotRenderer`.
"""
# Prep the ScreenshotRenderer that will be patched over the normal Renderer
screenshot_root = os.path.join(os.getcwd(), "seedsigner-screenshots")
ScreenshotRenderer.configure_instance()
screenshot_renderer: ScreenshotRenderer = ScreenshotRenderer.get_instance()
# Replace the core `Singleton` calls so that only our ScreenshotRenderer is used.
Renderer.configure_instance = Mock()
Renderer.get_instance = Mock(return_value=screenshot_renderer)
def setup_screenshots(locale: str) -> dict[str, list[ScreenshotConfig]]:
""" Set up some test data that we'll need in the `Controller` for certain Views """
# Must reset the Controller so each locale gets a fresh start
Controller.reset_instance()
controller = Controller.get_instance()
controller.settings.set_value(SettingsConstants.SETTING__SIG_TYPES, [attr for attr, name in SettingsConstants.ALL_SIG_TYPES])
controller.settings.set_value(SettingsConstants.SETTING__SCRIPT_TYPES, [attr for attr, name in SettingsConstants.ALL_SCRIPT_TYPES])
controller.storage.seeds.append(seed_12)
controller.storage.seeds.append(seed_12b)
controller.storage.seeds.append(seed_24)
controller.storage.set_pending_seed(seed_24_w_passphrase)
# Pending mnemonic for ToolsCalcFinalWordShowFinalWordView
controller.storage.init_pending_mnemonic(num_words=12)
for i, word in enumerate(mnemonic_12[:11]):
controller.storage.update_pending_mnemonic(word=word, index=i)
controller.storage.update_pending_mnemonic(word="satoshi", index=11) # random last word; not supposed to be a valid checksum (yet)
# Load a PSBT into memory
decoder = DecodeQR()
decoder.add_data(BASE64_MULTISIG_PSBT)
controller.psbt = decoder.get_psbt()
controller.psbt_seed = seed_12b
# Message signing data
derivation_path = "m/84h/0h/0h/0/0"
controller.sign_message_data = {
"seed_num": 0,
"derivation_path": derivation_path,
"message": "I attest that I control this bitcoin address blah blah blah",
"addr_format": embit_utils.parse_derivation_path(derivation_path)
}
# so we get a choice for transcribe seed qr format
controller.settings.set_value(
attr_name=SettingsConstants.SETTING__COMPACT_SEEDQR,
value=SettingsConstants.OPTION__ENABLED
)
# Automatically populate all Settings options Views
settings_views_list = []
def add_settings_entries(visibility = SettingsConstants.VISIBILITY__GENERAL):
for settings_entry in SettingsDefinition.settings_entries:
if settings_entry.visibility != visibility:
continue
if settings_entry.attr_name == SettingsConstants.SETTING__LOCALE:
# Locale selection has its own dedicated View
settings_views_list.append(ScreenshotConfig(settings_views.LocaleSelectionView))
else:
# Generic SettingsEntry selection View
settings_views_list.append(ScreenshotConfig(settings_views.SettingsEntryUpdateSelectionView, dict(attr_name=settings_entry.attr_name), screenshot_name=f"SettingsEntryUpdateSelectionView_{settings_entry.attr_name}"))
# Add the top level "General" settings menu and entries
settings_views_list.append(ScreenshotConfig(settings_views.SettingsMenuView))
add_settings_entries(SettingsConstants.VISIBILITY__GENERAL)
# Add the "Advanced" menu...
settings_views_list.append(
ScreenshotConfig(
settings_views.SettingsMenuView,
dict(
visibility=SettingsConstants.VISIBILITY__ADVANCED,
),
screenshot_name="SettingsMenuView__Advanced"
)
)
# ...and Advanced entries
add_settings_entries(SettingsConstants.VISIBILITY__ADVANCED)
# Render the nested "Advanced" -> "Hardware" submenu
settings_views_list.append(
ScreenshotConfig(
settings_views.SettingsMenuView,
dict(visibility=SettingsConstants.VISIBILITY__HARDWARE),
screenshot_name="SettingsMenuView__Hardware"
)
)
add_settings_entries(SettingsConstants.VISIBILITY__HARDWARE)
settingsqr_data_persistent = f"settings::v1 name=English_noob_mode persistent=E xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}"
settingsqr_data_not_persistent = f"settings::v1 name=Mode_Ephemeral persistent=D xpub_qr=urca,sta denom=thr network=M qr_density=M sigs=ss scripts=nat xpub_details=E passphrase=E camera=0 compact_seedqr=E bip85=D priv_warn=E dire_warn=E partners=E locale={locale}"
# Set up mocks to provide whatever temporary data/state a particular screenshot
# might need.
@contextmanager
def mock_load_psbt(base64_psbt: str, seed: Seed = seed_12b):
"""
Reusable utility for other context managers to mock loading a PSBT into the
Controller.
"""
decoder = DecodeQR()
decoder.add_data(base64_psbt)
with patch.object(controller, 'psbt', decoder.get_psbt()):
with patch.object(controller, 'psbt_seed', seed):
with patch.object(controller, 'psbt_parser', PSBTParser(p=controller.psbt, seed=seed)):
yield
@contextmanager
def mock_single_sig_psbt_loaded():
with mock_load_psbt(BASE64_SINGLE_SIG_PSBT):
yield
@contextmanager
def mock_multisig_psbt_loaded():
with mock_load_psbt(BASE64_MULTISIG_PSBT):
yield
@contextmanager
def mock_multisig_wallet_descriptor_loaded():
with patch.object(controller, 'multisig_wallet_descriptor', embit.descriptor.Descriptor.from_string(MULTISIG_WALLET_DESCRIPTOR)):
yield
@contextmanager
def mock_multisig_psbt_and_descriptor_loaded():
with mock_multisig_psbt_loaded():
with mock_multisig_wallet_descriptor_loaded():
yield
@contextmanager
def mock_address_verification_data_loaded():
fake_addr_verification_data = dict(
# These are all totally fake data
address="bc1q6p00wazu4nnqac29fvky6vhjnnhku5u2g9njss62rvy7e0yuperq86f5ek",
network=SettingsConstants.MAINNET,
sig_type=SettingsConstants.SINGLE_SIG,
script_type=SettingsConstants.NATIVE_SEGWIT,
derivation_path = "m/84h/0h/0h",
verified_index=5,
verified_index_is_change=False
)
with patch.object(controller, 'unverified_address', fake_addr_verification_data):
yield
@contextmanager
def mock_controller_psbt_seed_empty():
# Have to ensure this is cleared out in order to get the seed selection screen
with patch.object(controller, 'psbt_seed', None):
yield
@contextmanager
def mock_psbt_with_op_return_loaded():
with mock_load_psbt(BASE64_PSBT_WITH_OP_RETURN_TEXT):
yield
@contextmanager
def mock_psbt_with_op_return_raw_bytes_loaded():
with mock_load_psbt(BASE64_PSBT_WITH_OP_RETURN_RAW_BYTES):
yield
screenshot_sections = {
"Main Menu Views": [
ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=True)),
ScreenshotConfig(OpeningSplashView, dict(force_partner_logos=False), screenshot_name="OpeningSplashView_no_partner_logos"),
ScreenshotConfig(MainMenuView),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_removed', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__REMOVED, activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SDCardStateChangeToast_inserted', toast_thread=SDCardStateChangeToastManagerThread(action=MicroSD.ACTION__INSERTED, activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_RemoveSDCardToast', toast_thread=RemoveSDCardToastManagerThread(activation_delay=0, duration=0)),
ScreenshotConfig(RemoveMicroSDWarningView),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_DefaultToast', toast_thread=DefaultToast("This is a default text toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_InfoToast', toast_thread=InfoToast("This is an info toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_SuccessToast', toast_thread=SuccessToast("This is a success toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_WarningToast', toast_thread=WarningToast("This is a warning toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_DireWarningToast', toast_thread=DireWarningToast("This is a dire warning toast!", activation_delay=0, duration=0)),
ScreenshotConfig(MainMenuView, screenshot_name='MainMenuView_ErrorToast', toast_thread=ErrorToast("This is an error toast!", activation_delay=0, duration=0)),
ScreenshotConfig(PowerOptionsView),
ScreenshotConfig(RestartView),
ScreenshotConfig(PowerOffView),
],
"Seed Views": [
ScreenshotConfig(seed_views.SeedsMenuView),
ScreenshotConfig(seed_views.LoadSeedView),
ScreenshotConfig(seed_views.SeedMnemonicEntryView),
ScreenshotConfig(seed_views.SeedMnemonicInvalidView),
ScreenshotConfig(seed_views.SeedFinalizeView),
ScreenshotConfig(seed_views.SeedAddPassphraseView, screenshot_name="SeedAddPassphraseView_lowercase"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__UPPERCASE_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_uppercase"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__DIGITS_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_digits"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__SYMBOLS_1_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_symbols_1"),
ScreenshotConfig(seed_views.SeedAddPassphraseView, dict(initial_keyboard=SeedAddPassphraseScreen.KEYBOARD__SYMBOLS_2_BUTTON_TEXT), screenshot_name="SeedAddPassphraseView_symbols_2"),
ScreenshotConfig(seed_views.SeedAddPassphraseExitDialogView),
ScreenshotConfig(seed_views.SeedReviewPassphraseView),
ScreenshotConfig(seed_views.SeedOptionsView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedBackupView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedExportXpubSigTypeView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedExportXpubScriptTypeView, dict(seed_num=0, sig_type="msig")),
ScreenshotConfig(seed_views.SeedExportXpubCustomDerivationView, dict(seed_num=0, sig_type="ss", script_type="")),
ScreenshotConfig(seed_views.SeedExportXpubQRFormatView, dict(seed_num=0, sig_type="ss", script_type="nat")),
ScreenshotConfig(seed_views.SeedExportXpubWarningView, dict(seed_num=0, sig_type="msig", script_type="nes", xpub_qr_format="urca", custom_derivation="")),
ScreenshotConfig(seed_views.SeedExportXpubDetailsView, dict(seed_num=0, sig_type="ss", script_type="nat", xpub_qr_format="urca", custom_derivation="")),
ScreenshotConfig(SeedExportXpubQR_ScreenBrightnessView, dict(seed_num=0, xpub_qr_format="urca", derivation_path="m/84'/0'/0'")),
ScreenshotConfig(seed_views.SeedWordsWarningView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsView, dict(seed_num=0, page_index=2), screenshot_name="SeedWordsView_2"),
ScreenshotConfig(seed_views.SeedBIP85SelectNumWordsView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedBIP85SelectChildIndexView, dict(seed_num=0, num_words=24)),
ScreenshotConfig(seed_views.SeedBIP85InvalidChildIndexView, dict(seed_num=0, num_words=12)),
ScreenshotConfig(seed_views.SeedWordsBackupTestPromptView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedWordsBackupTestView, dict(seed_num=0, rand_seed=6102)),
ScreenshotConfig(seed_views.SeedWordsBackupTestMistakeView, dict(seed_num=0, cur_index=7, wrong_word="satoshi")),
ScreenshotConfig(seed_views.SeedWordsBackupTestSuccessView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRFormatView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWarningView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=21), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Compact"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_12_Standard"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__COMPACTSEEDQR, num_modules=25), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Compact"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRWholeQRView, dict(seed_num=2, seedqr_format=QRType.SEED__SEEDQR, num_modules=29), screenshot_name="SeedTranscribeSeedQRWholeQRView_24_Standard"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__COMPACTSEEDQR, initial_zone_x=1, initial_zone_y=1), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Compact"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRZoomedInView, dict(seed_num=0, seedqr_format=QRType.SEED__SEEDQR, initial_zone_x=2, initial_zone_y=2), screenshot_name="SeedTranscribeSeedQRZoomedInView_12_Standard"),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmQRPromptView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmWrongSeedView),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmInvalidQRView),
ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmSuccessView, dict(seed_num=0)),
# Screenshot can't render live preview screens
# ScreenshotConfig(seed_views.SeedTranscribeSeedQRConfirmScanView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__VERIFY_SINGLESIG_ADDR), screenshot_name="SeedSelectSeedView_address_verification"),
ScreenshotConfig(seed_views.AddressVerificationSigTypeView),
ScreenshotConfig(seed_views.SeedAddressVerificationView, dict(seed_num=0), mock_context_manager=mock_address_verification_data_loaded),
ScreenshotConfig(seed_views.SeedAddressVerificationSuccessView, dict(seed_num=0), mock_context_manager=mock_address_verification_data_loaded),
ScreenshotConfig(seed_views.LoadMultisigWalletDescriptorView),
ScreenshotConfig(seed_views.MultisigWalletDescriptorView, mock_context_manager=mock_multisig_wallet_descriptor_loaded),
ScreenshotConfig(seed_views.SeedDiscardView, dict(seed_num=0)),
ScreenshotConfig(seed_views.SeedSelectSeedView, dict(flow=Controller.FLOW__SIGN_MESSAGE), screenshot_name="SeedSelectSeedView_sign_message"),
ScreenshotConfig(seed_views.SeedSignMessageConfirmMessageView),
ScreenshotConfig(seed_views.SeedSignMessageConfirmAddressView),
ScreenshotConfig(seed_views.SeedElectrumMnemonicStartView),
],
"PSBT Views": [
ScreenshotConfig(psbt_views.PSBTSelectSeedView, mock_context_manager=mock_controller_psbt_seed_empty),
ScreenshotConfig(psbt_views.PSBTOverviewView, mock_context_manager=mock_multisig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTUnsupportedScriptTypeWarningView),
ScreenshotConfig(psbt_views.PSBTNoChangeWarningView),
ScreenshotConfig(psbt_views.PSBTMathView, mock_context_manager=mock_multisig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTAddressDetailsView, dict(address_num=0), mock_context_manager=mock_multisig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_single_sig_change_verified", mock_context_manager=mock_single_sig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=1), screenshot_name="PSBTChangeDetailsView_single_sig_self_transfer_verified", mock_context_manager=mock_single_sig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_unverified", mock_context_manager=mock_multisig_psbt_loaded),
ScreenshotConfig(psbt_views.PSBTChangeDetailsView, dict(change_address_num=0), screenshot_name="PSBTChangeDetailsView_multisig_verified", mock_context_manager=mock_multisig_psbt_and_descriptor_loaded),
ScreenshotConfig(psbt_views.PSBTOverviewView, screenshot_name="PSBTOverviewView_op_return", mock_context_manager=mock_psbt_with_op_return_loaded),
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_text", mock_context_manager=mock_psbt_with_op_return_loaded),
ScreenshotConfig(psbt_views.PSBTOpReturnView, screenshot_name="PSBTOpReturnView_raw_hex_data", mock_context_manager=mock_psbt_with_op_return_raw_bytes_loaded),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_change"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=False), screenshot_name="PSBTAddressVerificationFailedView_singlesig_selftransfer"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=True, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_change"),
ScreenshotConfig(psbt_views.PSBTAddressVerificationFailedView, dict(is_change=False, is_multisig=True), screenshot_name="PSBTAddressVerificationFailedView_multisig_selftransfer"),
ScreenshotConfig(psbt_views.PSBTFinalizeView, mock_context_manager=mock_multisig_psbt_loaded),
#ScreenshotConfig(PSBTSignedQRDisplayViewScreenshotConfig),
ScreenshotConfig(psbt_views.PSBTSigningErrorView, mock_context_manager=mock_multisig_psbt_loaded),
],
"Tools Views": [
ScreenshotConfig(tools_views.ToolsMenuView),
#ScreenshotConfig(ToolsImageEntropyLivePreviewView),
#ScreenshotConfig(ToolsImageEntropyFinalImageView),
ScreenshotConfig(tools_views.ToolsImageEntropyMnemonicLengthView),
ScreenshotConfig(tools_views.ToolsDiceEntropyMnemonicLengthView),
ScreenshotConfig(tools_views.ToolsDiceEntropyEntryView, dict(total_rolls=50)),
ScreenshotConfig(tools_views.ToolsCalcFinalWordNumWordsView),
ScreenshotConfig(tools_views.ToolsCalcFinalWordFinalizePromptView),
ScreenshotConfig(tools_views.ToolsCalcFinalWordCoinFlipsView),
ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, screenshot_name="ToolsCalcFinalWordShowFinalWordView_pick_word"),
ScreenshotConfig(tools_views.ToolsCalcFinalWordShowFinalWordView, dict(coin_flips="0010101"), screenshot_name="ToolsCalcFinalWordShowFinalWordView_coin_flips"),
ScreenshotConfig(tools_views.ToolsCalcFinalWordDoneView),
ScreenshotConfig(tools_views.ToolsAddressExplorerSelectSourceView),
ScreenshotConfig(tools_views.ToolsAddressExplorerAddressTypeView, mock_context_manager=mock_multisig_wallet_descriptor_loaded),
ScreenshotConfig(tools_views.ToolsAddressExplorerAddressListView),
# ScreenshotConfig(tools_views.ToolsAddressExplorerAddressView),
],
"Settings Views": settings_views_list + [
ScreenshotConfig(settings_views.IOTestView),
ScreenshotConfig(settings_views.DonateView),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_persistent), screenshot_name="SettingsIngestSettingsQRView_persistent"),
ScreenshotConfig(settings_views.SettingsIngestSettingsQRView, dict(data=settingsqr_data_not_persistent), screenshot_name="SettingsIngestSettingsQRView_not_persistent"),
ScreenshotConfig(settings_views.SettingsSelectionRequiredWarningView, dict(attr_name=SettingsConstants.SETTING__SCRIPT_TYPES)),
],
"Misc Error Views": [
ScreenshotConfig(NotYetImplementedView),
ScreenshotConfig(UnhandledExceptionView, dict(error=["IndexError", "line 1, in some_buggy_code.py", "list index out of range"])),
ScreenshotConfig(CameraConnectionErrorView),
ScreenshotConfig(NetworkMismatchErrorView, dict(derivation_path="m/84'/1'/0'")),
ScreenshotConfig(OptionDisabledView, dict(settings_attr=SettingsConstants.SETTING__MESSAGE_SIGNING)),
ScreenshotConfig(scan_views.ScanInvalidQRTypeView)
]
}
return screenshot_sections
def screencap_view(screenshot_config: ScreenshotConfig):
# Block until we have exclusive access to the screenshot renderer. Without this
# we were occasionally running into confusing race conditions where the next
# screenshot would begin rendering over the previous one. Claiming the lock
# guarantees that the previous screenshot has been fully rendered and saved.
with screenshot_renderer.lock:
screenshot_renderer.set_screenshot_filename(f"{screenshot_config.screenshot_name}.png")
controller = Controller.get_instance()
toast_thread = screenshot_config.toast_thread
try:
print(f"Running {screenshot_config.screenshot_name}")
try:
cur_count = screenshot_renderer.render_count
# Activate the (optional) context manager for this screenshot to activate
# any specialized mocks.
with screenshot_config.mock_context_manager():
# Set up and run the target View
screenshot_config.View_cls(**screenshot_config.view_kwargs).run()
if screenshot_renderer.render_count == cur_count:
# The View didn't actually render anything
raise Exception(f"{screenshot_config.screenshot_name} did not render a screenshot. Verify that its `run_screen()` is reachable by the screenshot generator.")
except ScreenshotComplete:
# The target View has run and its Screen has rendered what it needs to
if toast_thread is not None:
# Now run the Toast so it can render on top of the current image buffer
controller.activate_toast(toast_thread)
while controller.toast_notification_thread.is_alive():
# Give the Toast a moment to complete its work
time.sleep(0.01)
print(f"Completed {screenshot_config.screenshot_name}")
except Exception as e:
# Something else went wrong
from traceback import print_exc
print_exc()
raise e
finally:
if toast_thread and toast_thread.is_alive():
toast_thread.stop()
toast_thread.join()
# Parse the main `l10n/messages.pot` for overall stats
messages_source_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "l10n", "messages.pot")
with open(messages_source_path, 'r') as messages_source_file:
num_source_messages = messages_source_file.read().count("msgid \"") - 1
locale_tuple_list = [locale_tuple for locale_tuple in SettingsConstants.get_detected_languages() if locale_tuple[0] == locale]
if not locale_tuple_list:
raise Exception(f"Invalid locale: {locale}")
locale, display_name = locale_tuple_list[0]
Settings.get_instance().set_value(SettingsConstants.SETTING__LOCALE, value=locale)
locale_readme = f"""# SeedSigner Screenshots: {display_name}\n"""
# Report the translation progress
if locale != SettingsConstants.LOCALE__ENGLISH:
try:
translated_messages_path = os.path.join(pathlib.Path(__file__).parent.resolve().parent.resolve().parent.resolve(), "src", "seedsigner", "resources", "seedsigner-translations", "l10n", locale, "LC_MESSAGES", "messages.po")
with open(translated_messages_path, 'r') as translation_file:
locale_translations = translation_file.read()
num_locale_translations = locale_translations.count("msgid \"") - locale_translations.count("""msgstr ""\n\n""") - 1
if locale != "en":
locale_readme += f"## Translation progress: {num_locale_translations / num_source_messages:.1%}\n\n"
locale_readme += "---\n\n"
except Exception as e:
from traceback import print_exc
print_exc()
for section_name, screenshot_list in setup_screenshots(locale).items():
subdir = section_name.lower().replace(" ", "_")
screenshot_renderer.set_screenshot_path(os.path.join(screenshot_root, locale, subdir))
locale_readme += "\n\n---\n\n"
locale_readme += f"## {section_name}\n\n"
locale_readme += """<table style="border: 0;">"""
locale_readme += f"""<tr><td align="center">"""
for screenshot_config in screenshot_list:
screencap_view(screenshot_config)
locale_readme += """ <table align="left" style="border: 1px solid gray;">"""
locale_readme += f"""<tr><td align="center">{screenshot_config.screenshot_name}<br/><br/><img src="{subdir}/{screenshot_config.screenshot_name}.png"></td></tr>"""
locale_readme += """</table>\n"""
locale_readme += "</td></tr></table>"
with open(os.path.join(screenshot_root, locale, "README.md"), 'w') as readme_file:
readme_file.write(locale_readme)
print(f"Done with locale: {locale}.")
# Write the main README; ensure it writes all locales, not just the one that may
# have been specified for this run.
with open(os.path.join("tests", "screenshot_generator", "template.md"), 'r') as readme_template:
main_readme = readme_template.read()
for locale, display_name in SettingsConstants.get_detected_languages():
main_readme += f"* [{display_name}]({locale}/README.md)\n"
with open(os.path.join(screenshot_root, "README.md"), 'w') as readme_file:
readme_file.write(main_readme)
print(f"Screenshots rendered: {screenshot_renderer.render_count}")