* fix(gui): voci di menu BAL duplicate/condensate dopo riavvio o cambio wallet
Sintomo (Windows 11): dopo aver riavviato Electrum o cambiato wallet, le
schede Will/Heirs sparivano dalla tab bar e dal menu, e compariva una voce
di menu condensata/illeggibile (icona + testo sovrapposti) sotto il logo di
Electrum, accanto a 'Portafogli'.
Causa: init_menubar_tools veniva eseguito DUE volte sulla stessa finestra.
Con il plugin gia abilitato, al riavvio Electrum invoca sia l'hook
init_menubar sia il percorso di init a caldo (init_qt -> _setup_window),
entrambi chiamano init_menubar_tools -> addTab/addAction duplicati.
Nell'originale init_qt faceva return (chiedendo il riavvio) e quindi i menu
venivano creati una sola volta; rimuovendo quel return (fix B3) e' emersa la
doppia inizializzazione.
Fix:
- BalWindow._menubar_initialized: guardia di idempotenza.
- init_menubar_tools: se gia inizializzato, esce subito (niente duplicati).
- on_close: resetta il flag dopo aver rimosso tab/azioni, cosi la stessa
finestra puo essere riusata per un altro wallet.
- tests/gui_fixes_test.py: regressione che verifica la guardia in __init__,
init_menubar_tools e on_close.
Logica di business invariata (nessuna modifica a bal/core/*).
* fix(gui): ripristina create_status_bar come no-op (come originale)
L'elemento di menu condensato/illeggibile sotto il logo di Electrum era
causato dal StatusBarButton aggiunto da create_status_bar.
Nell'originale Gitea questo hook aveva un 'return' subito dopo il log, PRIMA
di costruire il bottone: era quindi disabilitato di proposito. Durante la
pulizia del 'dead code' nel refactoring quel return era stato rimosso,
riattivando la creazione del bottone -> elemento icona+testo renderizzato
nel punto sbagliato dopo riavvio/cambio wallet.
Fix: create_status_bar torna a essere un no-op (return), fedele all'originale.
Le impostazioni restano raggiungibili da Strumenti -> Plugin.
Regressione: gui_fixes_test verifica che create_status_bar non chiami
addPermanentWidget.
* fix(core): OverflowError su Windows (anno 2038) che rompeva tab/menu BAL
CAUSA VERA (dal log Electrum dell'utente, Windows 11):
OverflowError: Python int too large to convert to C int
window.py __init__ -> create_heirs_tab -> WillSettingsWidget
-> on_locktime_change -> BalTimestamp.to_date
-> datetime.fromtimestamp(NLOCKTIME_MAX)
Su Windows time_t e' a 32 bit, quindi datetime.fromtimestamp() solleva
OverflowError per qualsiasi timestamp oltre il 2038 (es. NLOCKTIME_MAX =
2**32-1 = 4294967295, usato come locktime di default/sentinella). Su Linux
64-bit la stessa chiamata funziona: per questo il bug si vedeva solo su
Windows e i test su Linux non lo intercettavano.
L'eccezione interrompeva BalWindow.__init__ durante init_menubar/load_wallet,
lasciando le schede Will/Heirs e la voce di menu a meta' costruzione ->
l'elemento grafico condensato/illeggibile sotto il logo di Electrum.
FIX (comportamento invariato per tutti i valori normali):
- BalTimestamp._safe_fromtimestamp(): datetime.fromtimestamp con clamp a
INT32_MAX in caso di OverflowError/OSError/ValueError, esattamente come la
funzione get_max_allowed_timestamp() dell'originale (Electrum issue #6170).
- Usato in to_date / to_timestamp / __str__ / __repr__ di BalTimestamp.
- widgets.py set_value: usa il converter sicuro.
- util.py timestamp_minus: stessa protezione inline con clamp a INT32_MAX.
I valori entro il 2038 (date assolute normali, durate relative come 90d/5y)
producono lo stesso identico risultato di prima.
TEST: tests/windows_overflow_test.py riproduce il limite 32-bit di Windows
(monkeypatch di datetime.fromtimestamp) e dimostra che senza il fix si ottiene
lo stesso OverflowError del log, mentre col fix passa. Verificato anche che il
test FALLISCE senza il fix.
* docs(it): documenta il fix OverflowError Windows (anno 2038) nel changelog
Aggiunge la sezione §13 al CHANGELOG_REFACTOR.md che descrive:
- sintomo (schede/menu rotti su Windows dopo riavvio/cambio wallet)
- causa vera dal log (datetime.fromtimestamp(NLOCKTIME_MAX) -> OverflowError
su time_t 32-bit di Windows)
- fix con _safe_fromtimestamp (clamp a INT32_MAX, come Electrum #6170)
- test di regressione windows_overflow_test.py
Aggiornata anche la cronologia (§12) con PR #3.
---------
Co-authored-by: GenSpark AI Developer <ai@genspark.dev>
144 lines
6.3 KiB
Python
144 lines
6.3 KiB
Python
"""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 stay a no-op, like the original plugin
|
|
# (whose body had an early ``return`` before building the StatusBarButton).
|
|
# Re-adding the status-bar button made a stray condensed icon+text element
|
|
# appear in the wrong place after restart / wallet switch.
|
|
csb_src = inspect.getsource(plugin_mod.Plugin.create_status_bar)
|
|
csb_active = _active_source_without_strings(plugin_mod) # whole module sans strings
|
|
csb_body = inspect.getsource(plugin_mod.Plugin.create_status_bar)
|
|
# The executable body must not add a permanent widget / build the button.
|
|
# Strip comments to avoid matching the explanatory note.
|
|
csb_code = "\n".join(
|
|
line for line in csb_body.splitlines()
|
|
if not line.lstrip().startswith("#")
|
|
)
|
|
assert "addPermanentWidget" not in csb_code, (
|
|
"create_status_bar must not add a status-bar widget (original is a no-op)")
|
|
print("[OK] create_status_bar is a no-op (matches original)")
|
|
|
|
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]))
|