add tests

This commit is contained in:
bot
2026-06-20 09:49:39 -04:00
parent 86ed0297a7
commit 525dde2b3c
34 changed files with 7427 additions and 0 deletions

152
tests/gui_fixes_test.py Normal file
View File

@@ -0,0 +1,152 @@
"""Regression tests for the GUI window/lifecycle fixes (B1-B10).
These tests need a QApplication but run head-less under
``QT_QPA_PLATFORM=offscreen``. They check the *behaviour* of the centralized
window helpers and assert that the known bug patterns are gone, without trying
to drive a full Electrum session.
Usage:
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/gui_fixes_test.py <PKG>
where <PKG> is e.g. electrum.plugins.bal
"""
import ast
import importlib
import inspect
import sys
def _active_source_without_strings(module) -> str:
"""Return module source with docstrings/strings removed.
Lets us assert a token is absent from *executable* code even if it still
appears inside an explanatory docstring/comment.
"""
src = inspect.getsource(module)
tree = ast.parse(src)
# collect string-constant spans to drop
class _S(ast.NodeVisitor):
def __init__(self):
self.spans = []
def visit_Constant(self, node):
if isinstance(node.value, str) and hasattr(node, "end_lineno"):
self.spans.append((node.lineno, node.end_lineno))
self.generic_visit(node)
s = _S(); s.visit(tree)
drop = set()
for a, b in s.spans:
drop.update(range(a, b + 1))
lines = src.splitlines()
kept = [ln for i, ln in enumerate(lines, start=1)
if i not in drop and not ln.lstrip().startswith("#")]
return "\n".join(kept)
def main(pkg: str) -> int:
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
app = QApplication.instance() or QApplication(sys.argv)
wu = importlib.import_module(pkg + ".gui.qt.window_utils")
# top_level_of: returns the top-level container of a child widget
w = QWidget(); child = QWidget(w)
assert wu.top_level_of(child) is w
assert wu.top_level_of(None) is None
print("[OK] top_level_of")
# bring_to_front / stop_thread must never raise on edge inputs
wu.bring_to_front(QDialog())
wu.stop_thread(None)
print("[OK] bring_to_front / stop_thread(None)")
# _window_key: stable and unique per window
plugin_mod = importlib.import_module(pkg + ".gui.qt.plugin")
a, b = QWidget(), QWidget()
assert plugin_mod._window_key(a) == plugin_mod._window_key(a)
assert plugin_mod._window_key(a) != plugin_mod._window_key(b)
print("[OK] _window_key stable & unique")
# B3/B4: no winId bound-method key, no 'restart Electrum' surrender in
# *executable* code (docstrings explaining the old behaviour are allowed).
active = _active_source_without_strings(plugin_mod)
assert "winId" not in active, "winId still used in executable code"
print("[OK] no winId in executable code")
win_mod = importlib.import_module(pkg + ".gui.qt.window")
active_win = _active_source_without_strings(win_mod)
assert "restart Electrum" not in active_win
print("[OK] no 'restart Electrum' surrender in window.py code")
# B1: BalDialog must not shadow QWidget.parent() with an attribute
dialogs_mod = importlib.import_module(pkg + ".gui.qt.dialogs")
dsrc = inspect.getsource(dialogs_mod)
assert "self.parent =" not in dsrc, "self.parent assignment still present"
print("[OK] no self.parent shadowing in dialogs.py")
# REGRESSION: BalDialog.closeEvent / hideEvent must NOT stop the task
# thread. Electrum's TaskThread.on_done calls cb_done (often self.accept,
# which closes the dialog) BEFORE cb_result (on_success, e.g. updating the
# will-executor list). If the base closeEvent stopped/joined the thread,
# the auto-close from accept() would tear the thread down before
# on_success ran and the downloaded list would be silently dropped.
close_src = inspect.getsource(dialogs_mod.BalDialog.closeEvent)
hide_src = inspect.getsource(dialogs_mod.BalDialog.hideEvent)
assert "stop_thread" not in close_src, (
"BalDialog.closeEvent must not stop the thread (drops download result)")
assert "stop_thread" not in hide_src, (
"BalDialog.hideEvent must not stop the thread (drops download result)")
print("[OK] BalDialog.closeEvent/hideEvent do not kill the task thread")
# REGRESSION: init_menubar_tools must be idempotent. Electrum can invoke
# both the init_menubar hook and the hot-init path (init_qt -> _setup_window)
# for the same window (e.g. on restart with the plugin already enabled);
# wiring the tabs/menu actions twice produces a garbled, condensed menu
# entry under the Electrum logo. Verify the guard flag is in place.
bal_window_cls = win_mod.BalWindow
menubar_src = inspect.getsource(bal_window_cls.init_menubar_tools)
assert "_menubar_initialized" in menubar_src, (
"init_menubar_tools must guard against double initialisation")
init_src = inspect.getsource(bal_window_cls.__init__)
assert "_menubar_initialized" in init_src, (
"_menubar_initialized must be initialised in BalWindow.__init__")
onclose_src = inspect.getsource(bal_window_cls.on_close)
assert "_menubar_initialized" in onclose_src, (
"on_close must reset _menubar_initialized so the window can be reused")
print("[OK] init_menubar_tools is idempotent (no duplicate tabs/menu)")
# REGRESSION: create_status_bar MUST add the BAL status-bar icon (bottom
# right of the Electrum window). It signals that the plugin is installed
# and, when clicked, opens the plugin settings. An earlier change wrongly
# turned this into a no-op while chasing the "condensed menu" bug (whose
# real cause was a Windows OverflowError, fixed elsewhere), which made the
# icon disappear. The icon must stay, and must not be duplicated on
# restart / wallet switch (hence the _statusbar_buttons book-keeping).
csb_body = inspect.getsource(plugin_mod.Plugin.create_status_bar)
csb_code = "\n".join(
line for line in csb_body.splitlines()
if not line.lstrip().startswith("#")
)
assert "StatusBarButton" in csb_code, (
"create_status_bar must build a StatusBarButton (the BAL icon)")
assert "addPermanentWidget" in csb_code, (
"create_status_bar must add the BAL icon to the status bar")
assert "settings_dialog" in csb_code, (
"clicking the BAL icon must open settings_dialog")
assert "_statusbar_buttons" in csb_code, (
"create_status_bar must track buttons to avoid duplicate icons")
# __init__ must initialise the tracking dict.
init_code = inspect.getsource(plugin_mod.Plugin.__init__)
assert "_statusbar_buttons" in init_code, (
"Plugin.__init__ must initialise self._statusbar_buttons")
print("[OK] create_status_bar adds the BAL icon + opens settings on click")
print(f"\n[OK] all GUI-fix checks passed for package {pkg!r}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(2)
sys.exit(main(sys.argv[1]))