457 lines
14 KiB
Python
457 lines
14 KiB
Python
"""
|
|
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")
|