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.
344 lines
11 KiB
Python
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")
|