Files
bal-electrum-plugin/tests/test_gui_qr_transfer.py
svatantrya d288b553ef core+gui: chunked multi-QR will transfer (export/import + review/sign wizard, audio channel)
BALQR-framed chunk scheduler (bal/core/qrtransfer.py) with zlib compression,
four chunk presets and a QR_CHUNK_SIZE setting (row 16).
Export/import dialogs (WillQrExportDialog/WillQrImportDialog), per-tx
review-and-sign wizard, export filters (All/Valid/Valid-NC) and an Auto
slideshow with speed + loop for the QR codes; optional audio_modem channel
with a local receive mirror. _prepare_and_sign_tx refactor (no behaviour
change); WillItem.status defaults to '' instead of None (import crash fix);
invalidate_will guard for a missing date_to_check. Docs: PLAN_QR_TRANSFER,
QML_PLAN, README, HANDOFF, CHANGELOG entry 56, AUDIO_MODEM_DEBIAN.
2026-08-28 16:28:37 -04:00

344 lines
11 KiB
Python

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