""" Tests for the QR / audio will-transfer dialogs (``bal.gui.qt.dialogs``). Covers WillQrExportDialog (build, frame navigation, chunk-size change) and WillQrImportDialog (frame capture, slot grid, complete-review enabling, total mismatch reset, frame assembly -> decode). The wizard and the camera/audio paths need a live wallet/hardware and are exercised only through the shared frame-assembly path here. Run: QT_QPA_PLATFORM=offscreen python3 tests/test_gui_qr_transfer.py """ import base64 import sys from unittest.mock import patch sys.path.insert(0, __file__.rsplit("/", 2)[0]) from electrum.transaction import Transaction from PyQt6.QtWidgets import QApplication, QMainWindow import bal.gui.qt.dialogs as dialogs from bal.core.qrtransfer import encode_transfer, split_frames from bal.core.will import WillItem _app = QApplication.instance() or QApplication(sys.argv) # A valid 1x1 transparent PNG, good enough for BalDialog's window icon. _PNG_BYTES = base64.b64decode( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==" ) # A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output), # reused for the WillItem status regression test. _VALID_TX_HEX = ( "01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b" "f38633b424eb4031000000006c493046022100a82bbc57a0136751e543" "3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d" "e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501" "2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3" "5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a" "42146f11ef8414ae929feaafc388ac00000000" ) class FakePlugin: def read_file(self, path): return _PNG_BYTES class FakeWindow(QMainWindow): config = {} def format_amount(self, amount): return "{:.8f}".format(amount) def format_amount_and_units(self, amount): return "{:.8f} sat".format(amount) class FakeBalWindow: """Duck-typed stand-in for BalWindow (dialog layer only).""" def __init__(self): self.window = FakeWindow() self.bal_plugin = FakePlugin() self.willitems = {} def get_audio_modem_plugin(self): return None class StubTx: def __init__(self, payload): self.payload = payload def txid(self): return "{:064x}".format(hash(self.payload) & 0xFFFFFFFFFFFFFFFF) def __str__(self): return self.payload class StubWillItem: def __init__(self, payload, statuses=None): self.tx = StubTx(payload) self.statuses = statuses or {} def get_status(self, name): return self.statuses.get(name, False) def _make_willitems(n=3, payload_len=120): return { "item{}".format(i): StubWillItem("T{}".format(i) * payload_len) for i in range(n) } # ------------------------------------------------------------------ # # WillQrExportDialog # ------------------------------------------------------------------ # def test_export_dialog_builds(): bw = FakeBalWindow() bw.willitems = _make_willitems() d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) assert d.tx_strings assert d.frames assert len(d.frames) >= 1 # Frame 1 is shown. assert d.qr_view.text == d.frames[0] assert "1" in d.progress_label.text() d.close() def test_export_dialog_empty_close(): # An empty will shows a modal message; stub it out for the test. orig = dialogs.MessageBoxMixin.show_message dialogs.MessageBoxMixin.show_message = lambda self, msg, icon=None: None try: bw = FakeBalWindow() d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) assert not d.isVisible() d.close() finally: dialogs.MessageBoxMixin.show_message = orig def test_imported_item_status_not_none(): # Regression: a WillItem built from a bare {"tx": hex} had a None status, # so set_status (e.g. IMPORTED / INVALIDATED from the import validity # pass) crashed with "unsupported operand type(s) for +=: 'NoneType' and # 'str'". with patch.object(Transaction, "add_info_from_wallet"): wi = WillItem({"tx": _VALID_TX_HEX}, wallet=None) assert wi.status == "" assert wi.set_status("IMPORTED", True) is True assert wi.set_status("INVALIDATED", True) is True assert "Imported" in wi.status and "Invalidated" in wi.status def test_export_auto_scroll(): bw = FakeBalWindow() bw.willitems = _make_willitems(n=6, payload_len=400) d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) assert d.fps_spin is not None assert not d.auto_timer.isActive() d.fps_spin.setValue(2) d._toggle_auto() assert d.auto_timer.isActive() assert d.auto_btn.text() == dialogs._("Stop") d._auto_step() assert d.index == 1 d._toggle_auto() assert not d.auto_timer.isActive() assert d.auto_btn.text() == dialogs._("Auto") # Advancing past the last frame stops the slideshow automatically. d._toggle_auto() d.index = len(d.frames) - 1 d._auto_step() assert not d.auto_timer.isActive() d.close() def test_export_auto_scroll_loop(): bw = FakeBalWindow() bw.willitems = _make_willitems(n=6, payload_len=400) d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) assert d.loop_check is not None # Loop on: reaching the last code wraps back to the first and keeps going. d.loop_check.setChecked(True) d._toggle_auto() assert d.auto_timer.isActive() d.index = len(d.frames) - 1 d._auto_step() assert d.index == 0 assert d.auto_timer.isActive() d._toggle_auto() # Loop off: reaching the last code stops the slideshow. d.loop_check.setChecked(False) d._toggle_auto() d.index = len(d.frames) - 1 d._auto_step() assert not d.auto_timer.isActive() d.close() def test_export_filter_valid_and_valid_nc(): bw = FakeBalWindow() a = StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) b = StubWillItem("B" * 120, statuses={"VALID": True}) c = StubWillItem("C" * 120) bw.willitems = {"a": a, "b": b, "c": c} d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) assert len(d.tx_strings) == 3 assert d.filter_combo.count() == 3 # "Valid" filter -> only the valid items (a, b). d._on_filter_change(1) assert sorted(d.tx_strings) == ["A" * 120, "B" * 120] # "Valid NC" filter -> only the valid, not-complete item (b). d._on_filter_change(2) assert sorted(d.tx_strings) == ["B" * 120] assert d.qr_view.text == d.frames[0] d.close() def test_export_filter_empty_reverts(): bw = FakeBalWindow() # Only a COMPLETE valid item: "Valid NC" selects nothing -> revert. bw.willitems = { "a": StubWillItem("A" * 120, statuses={"VALID": True, "COMPLETE": True}) } d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) messages = [] d.show_message = lambda msg: messages.append(msg) d._on_filter_change(2) # "Valid NC" -> empty subset assert messages assert d._filter_index == 0 # reverted to "All" assert d.filter_combo.currentIndex() == 0 assert len(d.tx_strings) == 1 d.close() def test_export_navigation_and_chunk_change(): bw = FakeBalWindow() bw.willitems = _make_willitems(n=6, payload_len=400) d = dialogs.WillQrExportDialog(bw, bal_plugin=bw.bal_plugin) first_count = len(d.frames) assert first_count > 1 # long transfer, small default chunk assert not d.prev_btn.isEnabled() d._next() assert d.index == 1 assert d.qr_view.text == d.frames[1] assert d.prev_btn.isEnabled() d._prev() assert d.index == 0 assert d.qr_view.text == d.frames[0] # Switch to the largest preset: fewer, bigger frames. d._on_chunk_change(len(dialogs.CHUNK_PRESETS) - 1) assert len(d.frames) < first_count assert d.index == 0 d.close() # ------------------------------------------------------------------ # # WillQrImportDialog # ------------------------------------------------------------------ # def test_import_frame_flow(): bw = FakeBalWindow() d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) assert not d.review_btn.isEnabled() assert not d.slot_area.isVisible() transfer = encode_transfer(["A" * 120, "B" * 120, "C" * 120]) frames = split_frames(transfer, 150) assert len(frames) > 1 for frame in frames: d._add_frame(frame) assert d.total == len(frames) assert d.review_btn.isEnabled() # isVisible() needs a shown parent; assert the widget is not hidden instead. assert not d.slot_area.isHidden() assert len(d.slot_widgets) == d.total assert "All" in d.status_label.text() # Duplicate capture is harmless. d._add_frame(frames[0]) assert len(d.frames) == d.total d.close() def test_import_assembles_and_decodes(): bw = FakeBalWindow() d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) captured = {} def fake_finish(tx_strings): captured["tx_strings"] = tx_strings d._finish_import = fake_finish transfer = encode_transfer(["P" * 130, "Q" * 130]) for frame in split_frames(transfer, 150): d._add_frame(frame) d._review_and_sign() assert captured["tx_strings"] == ["P" * 130, "Q" * 130] d.close() def test_import_total_mismatch_resets(): bw = FakeBalWindow() d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) warnings = [] d.show_warning = lambda msg: warnings.append(msg) frames_a = split_frames(encode_transfer(["A" * 120, "B" * 120]), 150) frames_b = split_frames(encode_transfer(["A" * 120, "B" * 120, "C" * 120]), 150) for frame in frames_a: d._add_frame(frame) assert d.total == len(frames_a) # A frame with a different total wipes the import; the first frame of # the new transfer must be scanned afresh. d._add_frame(frames_b[0]) assert warnings assert d.total == 0 assert not d.frames assert not d.review_btn.isEnabled() d.close() def test_import_manual_entry(): bw = FakeBalWindow() d = dialogs.WillQrImportDialog(bw, bal_plugin=bw.bal_plugin) frames = split_frames(encode_transfer(["M" * 120]), 150) d.manual_edit.setText(frames[0]) d._add_from_manual() assert d.manual_edit.text() == "" assert d.total == 1 assert d.review_btn.isEnabled() d.close() # ------------------------------------------------------------------ # # Main # ------------------------------------------------------------------ # if __name__ == "__main__": for name in sorted(dir()): if name.startswith("test_"): globals()[name]() print(f" [OK] {name}") print("[OK] All QR transfer GUI tests passed")