From 058305acca1ec630b3d2909252e2b804d9c904b6 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Fri, 9 Feb 2024 12:42:49 +0100 Subject: [PATCH 1/6] add a simple test framework for testing QObjects and their signal/slot mechanism --- electrum/tests/test_qml_types.py | 85 +++++++++++++++++++++++++++++ electrum/tests/test_qt_base.py | 92 ++++++++++++++++++++++++++++++++ 2 files changed, 177 insertions(+) create mode 100644 electrum/tests/test_qml_types.py create mode 100644 electrum/tests/test_qt_base.py diff --git a/electrum/tests/test_qml_types.py b/electrum/tests/test_qml_types.py new file mode 100644 index 000000000..d7d543af6 --- /dev/null +++ b/electrum/tests/test_qml_types.py @@ -0,0 +1,85 @@ +import shutil +import tempfile + +from electrum import SimpleConfig +from electrum.gui.qml.qetypes import QEAmount +from electrum.tests.test_qt_base import QETestCase, QEventReceiver, qt_test + + +class WalletMock: + def __init__(self, electrum_path): + self.config = SimpleConfig({ + 'electrum_path': electrum_path, + 'decimal_point': 5 + }) + self.contacts = None + + +class QETestTypes(QETestCase): + + def setUp(self): + super().setUp() + self.electrum_path = tempfile.mkdtemp() + self.wallet = WalletMock(self.electrum_path) + + def tearDown(self): + super().tearDown() + shutil.rmtree(self.electrum_path) + + @qt_test + def test_qeamount(self): + a = QEAmount() + self.assertTrue(a.isEmpty) + a_er = QEventReceiver(a.valueChanged) + a.satsInt = 1 + self.assertTrue(bool(a_er.received)) + self.assertFalse(a.isEmpty) + + a_er.clear() + a.clear() + self.assertTrue(a.isEmpty) + self.assertTrue(bool(a_er.received)) + + a_er.clear() + a.isMax = True + self.assertTrue(a.isMax) + self.assertFalse(a.isEmpty) + self.assertTrue(bool(a_er.received)) + + @qt_test + def test_qeamount_copy(self): + a = QEAmount() + b = QEAmount() + b.satsInt = 1 + c = QEAmount() + c.msatsInt = 1 + d = QEAmount() + d.isMax = True + + t = QEAmount() + t_er = QEventReceiver(t.valueChanged) + + t.copyFrom(a) + self.assertTrue(t.isEmpty) + self.assertEqual(0, len(t_er.received)) + + t.clear() + t_er.clear() + t.copyFrom(b) + self.assertFalse(t.isEmpty) + self.assertEqual(t.satsInt, 1) + self.assertEqual(1, len(t_er.received)) + + t.clear() + t_er.clear() + t.copyFrom(c) + self.assertFalse(t.isEmpty) + self.assertEqual(t.msatsInt, 1) + self.assertEqual(1, len(t_er.received)) + + t.clear() + t_er.clear() + t.copyFrom(d) + self.assertFalse(t.isEmpty) + self.assertTrue(t.isMax) + self.assertEqual(1, len(t_er.received)) diff --git a/electrum/tests/test_qt_base.py b/electrum/tests/test_qt_base.py new file mode 100644 index 000000000..3ea6a8a24 --- /dev/null +++ b/electrum/tests/test_qt_base.py @@ -0,0 +1,92 @@ +import threading +import unittest +from functools import wraps, partial +from unittest import SkipTest + +from PyQt6.QtCore import QCoreApplication, QTimer, QMetaObject, Qt, pyqtSlot, QObject + + +class TestQCoreApplication(QCoreApplication): + @pyqtSlot() + def doInvoke(self): + getattr(self._instance, self._method)() + + +class QEventReceiver(QObject): + def __init__(self, *signals): + super().__init__() + self.received = [] + self.signals = [] + for signal in signals: + self.signals.append(signal) + signal.connect(partial(self.doReceive, signal)) + + # intentionally no pyqtSlot decorator, to catch all + def doReceive(self, signal, *args): + self.received.append((signal, args)) + + def receivedForSignal(self, signal): + return list(filter(lambda x: x[0] == signal, self.received)) + + def clear(self): + self.received.clear() + + +class QETestCase(unittest.IsolatedAsyncioTestCase): + + def setUp(self): + super().setUp() + self.app = None + self._e = None + self._event = threading.Event() + + def start_qt_task(): + self.app = TestQCoreApplication([]) + + self.timer = QTimer(self.app) + self.timer.setSingleShot(False) + self.timer.setInterval(500) # msec + self.timer.timeout.connect(lambda: None) # periodically enter python scope + + self.app.exec() + self.app = None + + self.qt_thread = threading.Thread(target=start_qt_task) + self.qt_thread.start() + + def tearDown(self): + self.app.exit() + if self.qt_thread.is_alive(): + self.qt_thread.join() + + +def qt_test(func): + @wraps(func) + def decorator(self, *args): + if threading.current_thread().name == 'MainThread': + self.app._instance = self + self.app._method = func.__name__ + QMetaObject.invokeMethod(self.app, 'doInvoke', Qt.ConnectionType.QueuedConnection) + self._event.wait(15) + if self._e: + print(f'raising ex: {self._e!r}') + # deallocate stored exception from qt thread otherwise we SEGV garbage collector + # instead, re-create using the exception message, special casing AssertionError and SkipTest + e = None + if isinstance(self._e, AssertionError): + e = AssertionError(str(self._e)) + elif isinstance(self._e, SkipTest): + e = SkipTest(str(self._e)) + else: + e = Exception(str(self._e)) + self._e = None + raise e + return + try: + func(self, *args) + except Exception as e: + self._e = e + finally: + self._event.set() + self._event.clear() + return decorator From 08a06ae4aa5261485ca84ffb998a8e18bc685095 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 12 Feb 2024 10:10:36 +0100 Subject: [PATCH 2/6] don't prefix utility classes and functions file with test_ --- electrum/tests/{test_qt_base.py => qt_util.py} | 0 electrum/tests/test_qml_types.py | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename electrum/tests/{test_qt_base.py => qt_util.py} (100%) diff --git a/electrum/tests/test_qt_base.py b/electrum/tests/qt_util.py similarity index 100% rename from electrum/tests/test_qt_base.py rename to electrum/tests/qt_util.py diff --git a/electrum/tests/test_qml_types.py b/electrum/tests/test_qml_types.py index d7d543af6..d9f0d6207 100644 --- a/electrum/tests/test_qml_types.py +++ b/electrum/tests/test_qml_types.py @@ -3,7 +3,7 @@ import tempfile from electrum import SimpleConfig from electrum.gui.qml.qetypes import QEAmount -from electrum.tests.test_qt_base import QETestCase, QEventReceiver, qt_test +from electrum.tests.qt_util import QETestCase, QEventReceiver, qt_test class WalletMock: From 46e0c6e8aee9e276ebd037824cf6c52d9d39993b Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 12 Feb 2024 10:38:40 +0100 Subject: [PATCH 3/6] tests: print traceback of original exception when testcase fails --- electrum/tests/qt_util.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/electrum/tests/qt_util.py b/electrum/tests/qt_util.py index 3ea6a8a24..2ca802fe2 100644 --- a/electrum/tests/qt_util.py +++ b/electrum/tests/qt_util.py @@ -1,4 +1,5 @@ import threading +import traceback import unittest from functools import wraps, partial from unittest import SkipTest @@ -69,7 +70,7 @@ def qt_test(func): QMetaObject.invokeMethod(self.app, 'doInvoke', Qt.ConnectionType.QueuedConnection) self._event.wait(15) if self._e: - print(f'raising ex: {self._e!r}') + print("".join(traceback.format_exception(self._e))) # deallocate stored exception from qt thread otherwise we SEGV garbage collector # instead, re-create using the exception message, special casing AssertionError and SkipTest e = None From 71dbf76cd0e713d284fb7969b967987e97937456 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 12 Feb 2024 11:29:05 +0100 Subject: [PATCH 4/6] add new 'qml_gui' extra to setup.py and include it for tox --- .cirrus.yml | 2 ++ setup.py | 1 + tox.ini | 1 + 3 files changed, 4 insertions(+) diff --git a/.cirrus.yml b/.cirrus.yml index 08b837fb7..fa54df974 100644 --- a/.cirrus.yml +++ b/.cirrus.yml @@ -34,6 +34,8 @@ task: install_script: - apt-get update - apt-get -y install libsecp256k1-dev + # qml test reqs: + - apt-get -y install libgl1 libegl1 libxkbcommon0 libdbus-1-3 - pip install -r $ELECTRUM_REQUIREMENTS_CI tox_script: - export PYTHONASYNCIODEBUG diff --git a/setup.py b/setup.py index f0f82711f..223177287 100755 --- a/setup.py +++ b/setup.py @@ -45,6 +45,7 @@ extras_require = { 'gui': ['pyqt5'], 'crypto': ['cryptography>=2.6'], 'tests': ['pycryptodomex>=3.7', 'cryptography>=2.6', 'pyaes>=0.1a1'], + 'qml_gui': ['pyqt6', 'Pillow==8.4.0'] } # 'full' extra that tries to grab everything an enduser would need (except for libsecp256k1...) extras_require['full'] = [pkg for sublist in diff --git a/tox.ini b/tox.ini index 58e09e963..3e1aaafb6 100644 --- a/tox.ini +++ b/tox.ini @@ -14,3 +14,4 @@ commands= coverage report extras= tests + qml_gui From 3582c79160bd31356187d12d9ccb42a6ab4363e4 Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 12 Feb 2024 11:32:17 +0100 Subject: [PATCH 5/6] stub QVideoSink import as it requires many dependencies but isn't used on android currently --- electrum/gui/qml/qeqr.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/electrum/gui/qml/qeqr.py b/electrum/gui/qml/qeqr.py index d268fa9cd..d1ec0c9cb 100644 --- a/electrum/gui/qml/qeqr.py +++ b/electrum/gui/qml/qeqr.py @@ -10,7 +10,13 @@ from PIL import ImageQt from PyQt6.QtCore import pyqtProperty, pyqtSignal, pyqtSlot, QObject, QRect from PyQt6.QtGui import QImage, QColor from PyQt6.QtQuick import QQuickImageProvider -from PyQt6.QtMultimedia import QVideoSink +try: + from PyQt6.QtMultimedia import QVideoSink +except ImportError: + # stub QVideoSink when not found, as it's not essential on android + # and requires many dependencies when unit testing. + # Note: missing QtMultimedia will lead to errors when using QR scanner on desktop + from PyQt6.QtCore import QObject as QVideoSink from electrum.logging import get_logger from electrum.qrreader import get_qr_reader From a626d1bf42ba15c594465cb76352225d96efcd0f Mon Sep 17 00:00:00 2001 From: Sander van Grieken Date: Mon, 12 Feb 2024 15:30:46 +0100 Subject: [PATCH 6/6] tests: add test for QEAmount(from_invoice=..) and (m)satsStr properties --- electrum/tests/test_qml_types.py | 57 +++++++++++++++++++++++++++++++- 1 file changed, 56 insertions(+), 1 deletion(-) diff --git a/electrum/tests/test_qml_types.py b/electrum/tests/test_qml_types.py index d9f0d6207..6200fd1e2 100644 --- a/electrum/tests/test_qml_types.py +++ b/electrum/tests/test_qml_types.py @@ -3,7 +3,9 @@ import tempfile from electrum import SimpleConfig from electrum.gui.qml.qetypes import QEAmount +from electrum.invoices import Invoice, LN_EXPIRY_NEVER from electrum.tests.qt_util import QETestCase, QEventReceiver, qt_test +from electrum.transaction import PartialTxOutput class WalletMock: @@ -15,7 +17,7 @@ class WalletMock: self.contacts = None -class QETestTypes(QETestCase): +class TestTypes(QETestCase): def setUp(self): super().setUp() @@ -34,17 +36,28 @@ class QETestTypes(QETestCase): a.satsInt = 1 self.assertTrue(bool(a_er.received)) self.assertFalse(a.isEmpty) + self.assertEqual('1', a.satsStr) a_er.clear() a.clear() self.assertTrue(a.isEmpty) self.assertTrue(bool(a_er.received)) + self.assertEqual('0', a.satsStr) + a.clear() a_er.clear() a.isMax = True self.assertTrue(a.isMax) self.assertFalse(a.isEmpty) self.assertTrue(bool(a_er.received)) + self.assertEqual('0', a.satsStr) + + a.clear() + a_er.clear() + a.msatsInt = 1 + self.assertTrue(bool(a_er.received)) + self.assertFalse(a.isEmpty) + self.assertEqual('1', a.msatsStr) @qt_test def test_qeamount_copy(self): @@ -83,3 +96,45 @@ class QETestTypes(QETestCase): self.assertFalse(t.isEmpty) self.assertTrue(t.isMax) self.assertEqual(1, len(t_er.received)) + + @qt_test + def test_qeamount_frominvoice(self): + amount_sat = 10_000 + outputs = [PartialTxOutput.from_address_and_value('bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293', amount_sat)] + invoice = Invoice( + amount_msat=amount_sat * 1000, + message="mymsg", + time=1692716965, + exp=LN_EXPIRY_NEVER, + outputs=outputs, + bip70=None, + height=0, + lightning_invoice=None, + ) + a = QEAmount(from_invoice=invoice) + self.assertEqual(10_000, a.satsInt) + self.assertEqual(10_000_000, a.msatsInt) + self.assertFalse(a.isMax) + + outputs = [PartialTxOutput.from_address_and_value('bc1qj3zx2zc4rpv3npzmznxhdxzn0wm7pzqp8p2293', '!')] + invoice = Invoice( + amount_msat='!', + message="mymsg", + time=1692716965, + exp=LN_EXPIRY_NEVER, + outputs=outputs, + bip70=None, + height=0, + lightning_invoice=None, + ) + a = QEAmount(from_invoice=invoice) + self.assertTrue(a.isMax) + self.assertEqual(0, a.satsInt) + self.assertEqual(0, a.msatsInt) + + bolt11 = 'lnbc20m1pvjluezpp5qqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqqqsyqcyq5rqwzqfqypqhp58yjmdan79s6qqdhdzgynm4zwqd5d7xmw5fk98klysy043l2ahrqsfpp3qjmp7lwpagxun9pygexvgpjdc4jdj85fr9yq20q82gphp2nflc7jtzrcazrra7wwgzxqc8u7754cdlpfrmccae92qgzqvzq2ps8pqqqqqqpqqqqq9qqqvpeuqafqxu92d8lr6fvg0r5gv0heeeqgcrqlnm6jhphu9y00rrhy4grqszsvpcgpy9qqqqqqgqqqqq7qqzqj9n4evl6mr5aj9f58zp6fyjzup6ywn3x6sk8akg5v4tgn2q8g4fhx05wf6juaxu9760yp46454gpg5mtzgerlzezqcqvjnhjh8z3g2qqdhhwkj' + invoice = Invoice.from_bech32(bolt11) + a = QEAmount(from_invoice=invoice) + self.assertEqual(2_000_000, a.satsInt) + self.assertEqual(2_000_000_000, a.msatsInt) + self.assertFalse(a.isMax)