feat: animated-QR transfer, Android reader, relative-locktime preservation, karen7 hermetic tests
This commit is contained in:
456
tests/test_gui_export_dialogs.py
Normal file
456
tests/test_gui_export_dialogs.py
Normal file
@@ -0,0 +1,456 @@
|
||||
"""
|
||||
Tests for the filter-based unified export/import dialogs and the BalWindow
|
||||
transport helpers (``bal.gui.qt.dialogs``, ``bal.gui.qt.window``).
|
||||
|
||||
Covers the shared export filters (All / Valid / Valid NC), the unified
|
||||
``WillExportDialog`` file page (whole item vs tx-only content, empty-filter
|
||||
abort), the audio export/import pages (KB/sec wiring, missing plugin guard,
|
||||
receive flow) and the comma-separated tx-only file writer. The audio pages
|
||||
run against a stub plugin so no sound hardware is exercised.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_export_dialogs.py
|
||||
"""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import sys
|
||||
import zlib
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QMainWindow
|
||||
|
||||
import bal.gui.qt.dialogs as dialogs
|
||||
from bal.core.qrtransfer import CHUNK_PRESETS
|
||||
|
||||
_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,
|
||||
# version 2); used for real-WillItem serialization tests.
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
|
||||
class _Cfg:
|
||||
def __init__(self, value):
|
||||
self._value = value
|
||||
|
||||
def get(self):
|
||||
return self._value
|
||||
|
||||
|
||||
class FakePlugin:
|
||||
QR_CHUNK_SIZE = _Cfg(CHUNK_PRESETS[0][1])
|
||||
|
||||
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 FakeAudioPlugin:
|
||||
"""Duck-typed ``audio_modem`` plugin for the audio pages."""
|
||||
|
||||
def __init__(self):
|
||||
self.modem_config = None
|
||||
|
||||
def is_available(self):
|
||||
return True
|
||||
|
||||
|
||||
class _FakeModemConfig:
|
||||
def __init__(self, kbps):
|
||||
self.modem_bps = kbps * 1000
|
||||
|
||||
|
||||
class FakeBalWindow:
|
||||
"""Duck-typed stand-in for BalWindow (dialog layer only)."""
|
||||
|
||||
def __init__(self, audio_plugin=None):
|
||||
self.window = FakeWindow()
|
||||
self.bal_plugin = FakePlugin()
|
||||
self.willitems = {}
|
||||
self.audio_plugin = audio_plugin
|
||||
self.bitrate_set = None
|
||||
self.audio_payloads = []
|
||||
|
||||
def get_audio_modem_plugin(self):
|
||||
return self.audio_plugin
|
||||
|
||||
def set_audio_modem_bitrate(self, kbps):
|
||||
self.bitrate_set = kbps
|
||||
if self.audio_plugin is not None:
|
||||
self.audio_plugin.modem_config = _FakeModemConfig(kbps)
|
||||
|
||||
def _audio_send_payload(self, payload):
|
||||
self.audio_payloads.append(payload)
|
||||
|
||||
def export_json_file(self, path, will=None):
|
||||
items = will if will is not None else self.willitems
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
json.dump({wid: wi.to_dict() for wid, wi in items.items()}, f)
|
||||
|
||||
def export_tx_file(self, path, will=None):
|
||||
items = will if will is not None else self.willitems
|
||||
with open(path, "w", encoding="utf-8") as f:
|
||||
f.write(",".join(str(wi.tx) for _, wi in items.items()))
|
||||
|
||||
|
||||
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 to_dict(self):
|
||||
return {"tx": str(self.tx)}
|
||||
|
||||
|
||||
def _make_willitems(n=3, payload_len=60, statuses=None):
|
||||
return {
|
||||
"item{}".format(i): StubWillItem(
|
||||
"T{}".format(i) * payload_len, statuses=statuses
|
||||
)
|
||||
for i in range(n)
|
||||
}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Shared export filters
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_export_filter_options():
|
||||
opts = dialogs.export_filter_options()
|
||||
assert [label for label, _fn in opts] == ["All", "Valid", "Valid NC"]
|
||||
complete = StubWillItem("C*", statuses={"VALID": True, "COMPLETE": True})
|
||||
valid = StubWillItem("V*", statuses={"VALID": True})
|
||||
plain = StubWillItem("P*")
|
||||
assert opts[0][1](complete) and opts[0][1](plain)
|
||||
assert opts[1][1](complete) and opts[1][1](valid) and not opts[1][1](plain)
|
||||
assert not opts[2][1](complete)
|
||||
assert opts[2][1](valid) and not opts[2][1](plain)
|
||||
|
||||
|
||||
def test_filter_willitems_by_index():
|
||||
items = {
|
||||
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True}),
|
||||
"b": StubWillItem("B*", statuses={"VALID": True}),
|
||||
"c": StubWillItem("C*"),
|
||||
}
|
||||
opts = dialogs.export_filter_options()
|
||||
assert set(dialogs.filter_willitems(items, opts, 0)) == {"a", "b", "c"}
|
||||
assert set(dialogs.filter_willitems(items, opts, 1)) == {"a", "b"}
|
||||
assert dialogs.filter_willitems(items, opts, 2) == {"b": items["b"]}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog file page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_file_export_selects_by_filter():
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(3)
|
||||
items["item0"].statuses = {"VALID": True, "COMPLETE": True}
|
||||
items["item1"].statuses = {"VALID": True}
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
assert set(d._selected_items()) == set(items)
|
||||
|
||||
d._on_filter_change(1)
|
||||
assert set(d._selected_items()) == {"item0", "item1"}
|
||||
d._on_filter_change(2)
|
||||
assert list(d._selected_items()) == ["item1"]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_run_tx_only(tmpdir):
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(3)
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
d.content_check.setChecked(False)
|
||||
captured = {}
|
||||
|
||||
def fake_gui(window, title, exporter):
|
||||
captured["title"] = title
|
||||
captured["exporter"] = exporter
|
||||
|
||||
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||
d._export_file()
|
||||
assert captured["title"] == "will_tx"
|
||||
out = tmpdir.join("will_tx.txt").strpath
|
||||
captured["exporter"](out)
|
||||
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||
assert open(out, encoding="utf-8").read() == expected
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_run_willitem(tmpdir):
|
||||
bw = FakeBalWindow()
|
||||
items = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
assert d.content_check.isChecked()
|
||||
captured = {}
|
||||
|
||||
def fake_gui(window, title, exporter):
|
||||
captured["title"] = title
|
||||
captured["exporter"] = exporter
|
||||
|
||||
with patch.object(dialogs, "export_meta_gui", side_effect=fake_gui):
|
||||
d._export_file()
|
||||
assert captured["title"] == "will"
|
||||
out = tmpdir.join("will.json").strpath
|
||||
captured["exporter"](out)
|
||||
data = json.load(open(out, encoding="utf-8"))
|
||||
assert set(data) == set(items)
|
||||
assert data["item0"]["tx"] == str(items["item0"].tx)
|
||||
d.close()
|
||||
|
||||
|
||||
def test_file_export_empty_under_filter_aborts():
|
||||
bw = FakeBalWindow()
|
||||
items = {
|
||||
"a": StubWillItem("A*", statuses={"VALID": True, "COMPLETE": True})
|
||||
}
|
||||
d = dialogs.WillExportDialog(bw, will=items, bal_plugin=bw.bal_plugin)
|
||||
messages = []
|
||||
d.show_message = lambda msg: messages.append(msg) # type: ignore[assignment]
|
||||
d._filter_index = 2 # force an empty "Valid NC" selection
|
||||
with patch.object(dialogs, "export_meta_gui") as gui:
|
||||
d._export_file()
|
||||
assert not gui.called
|
||||
assert messages
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalWindow transport helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bal_window_export_tx_file(tmpdir):
|
||||
import bal.gui.qt.window as window
|
||||
|
||||
items = _make_willitems(3)
|
||||
bw = object.__new__(window.BalWindow)
|
||||
bw.willitems = items
|
||||
out = tmpdir.join("will_tx.txt").strpath
|
||||
bw.export_tx_file(out)
|
||||
expected = ",".join(str(wi.tx) for _, wi in items.items())
|
||||
assert open(out, encoding="utf-8").read() == expected
|
||||
|
||||
|
||||
def test_bal_window_set_audio_modem_bitrate():
|
||||
try:
|
||||
import amodem.config
|
||||
except ImportError:
|
||||
return
|
||||
import bal.gui.qt.window as window
|
||||
|
||||
class P:
|
||||
def __init__(self):
|
||||
self.modem_config = None
|
||||
|
||||
probe = P()
|
||||
bw = object.__new__(window.BalWindow)
|
||||
bw.get_audio_modem_plugin = lambda: probe
|
||||
bw.set_audio_modem_bitrate(1)
|
||||
assert probe.modem_config is amodem.config.bitrates[1]
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillExportDialog audio page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_audio_export_page_send():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
items = _make_willitems(3, payload_len=30)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.audio_send_btn.text() == dialogs._("Send")
|
||||
assert d.audio_send_btn.isEnabled()
|
||||
assert d.kbps_combo.count() > 0
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
d._send_audio()
|
||||
assert bw.bitrate_set == kbps
|
||||
# Whole-will default: the audio payload is a single JSON document.
|
||||
assert len(bw.audio_payloads) == 1
|
||||
data = json.loads(bw.audio_payloads[0])
|
||||
assert set(data) == set(items)
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_export_page_send_tx_only():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
items = _make_willitems(3, payload_len=30)
|
||||
bw.willitems = items
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
d.content_check.setChecked(False)
|
||||
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
d._send_audio()
|
||||
assert bw.bitrate_set == kbps
|
||||
expected = "\n".join(dialogs.serialize_tx_list(items))
|
||||
assert bw.audio_payloads == [expected]
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_export_page_plugin_missing():
|
||||
# The window stays usable: only the audio option is disabled.
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
bw.willitems = _make_willitems(2)
|
||||
d = dialogs.WillExportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.audio_send_btn.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert len(d.qr_page.frames) >= 1 # QR still usable
|
||||
assert d.file_export_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillImportDialog audio page
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_audio_import_page_build():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert d.kbps_combo.count() > 0
|
||||
assert d.receive_btn.text() == dialogs._("Receive by audio…")
|
||||
assert d.receive_btn.isEnabled()
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_import_page_plugin_missing():
|
||||
bw = FakeBalWindow(audio_plugin=None)
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
assert not d.receive_btn.isEnabled()
|
||||
assert "not available" in d.audio_warn_label.text()
|
||||
assert d.qr_page is not None # QR import still usable
|
||||
d.close()
|
||||
|
||||
|
||||
def test_audio_import_receive_wiring():
|
||||
bw = FakeBalWindow(audio_plugin=FakeAudioPlugin())
|
||||
d = dialogs.WillImportDialog(bw, bal_plugin=bw.bal_plugin)
|
||||
captured = {}
|
||||
|
||||
class FakeWaitingDialog:
|
||||
def __init__(self, parent, msg, task, on_success=None, on_error=None):
|
||||
captured["msg"] = msg
|
||||
captured["task"] = task
|
||||
captured["success"] = on_success
|
||||
captured["error"] = on_error
|
||||
|
||||
imported = []
|
||||
|
||||
def fake_complete(bal_window, bal_plugin, payload, **kwargs):
|
||||
imported.append(payload)
|
||||
|
||||
blob = zlib.compress(b"A" * 40 + b"\n" + b"B" * 40)
|
||||
with patch.object(dialogs, "WaitingDialog", FakeWaitingDialog), patch.object(
|
||||
dialogs, "_complete_import", side_effect=fake_complete
|
||||
):
|
||||
d._audio_receive()
|
||||
kbps = int(d.kbps_combo.currentText())
|
||||
assert bw.bitrate_set == kbps
|
||||
assert captured["task"] is not None
|
||||
captured["success"](blob)
|
||||
# Payload is the raw decompressed text; autodetect handles the splitting.
|
||||
assert imported == ["A" * 40 + "\n" + "B" * 40]
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Whole-will JSON payload serializes a real Transaction (MyEncoder)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_qr_whole_will_json_serializes_transaction():
|
||||
"""Regression: _whole_will_json must not raise
|
||||
"Object of type Transaction is not JSON serializable".
|
||||
|
||||
Real WillItems keep a ``Transaction`` object in ``tx``; the whole-will
|
||||
QR payload (default content scope) must serialize it via MyEncoder the
|
||||
same way write_json_file does.
|
||||
"""
|
||||
from bal.core.will import WillItem
|
||||
|
||||
item = WillItem({
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": {},
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
})
|
||||
bw = FakeBalWindow()
|
||||
d = dialogs.WillExportDialog(
|
||||
bw, will={"imp0": item}, bal_plugin=bw.bal_plugin, initial_mode="qr"
|
||||
)
|
||||
j = d._whole_will_json()
|
||||
data = json.loads(j)
|
||||
assert data["imp0"]["tx"] == _VALID_TX_HEX
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
import inspect
|
||||
import os
|
||||
import tempfile
|
||||
|
||||
class _Path:
|
||||
def __init__(self, d, name):
|
||||
self.strpath = os.path.join(d, name)
|
||||
|
||||
class _Tmp:
|
||||
def __init__(self):
|
||||
self._d = tempfile.mkdtemp()
|
||||
|
||||
def join(self, name):
|
||||
return _Path(self._d, name)
|
||||
|
||||
tmp = _Tmp()
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
fn = globals()[name]
|
||||
fn(tmp) if inspect.signature(fn).parameters else fn()
|
||||
print(" [OK] {}".format(name))
|
||||
print("[OK] All export dialog GUI tests passed")
|
||||
Reference in New Issue
Block a user