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.
This commit is contained in:
343
tests/test_gui_qr_transfer.py
Normal file
343
tests/test_gui_qr_transfer.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user