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

134
tests/bal_fixtures.py Normal file
View File

@@ -0,0 +1,134 @@
"""
Shared test fixtures for the BAL plugin test-suite.
This module centralises the "plausible data" used by the mock tests:
* Valid Bitcoin (regtest) addresses are read **read-only** from the
``giovanna7`` wallet so the heirs / change / will-executor addresses are
genuine, well-formed addresses for the active network.
* A lightweight fake wallet and a fake ``bal_plugin`` are provided so the
core inheritance logic (``Heirs.buildTransactions`` / ``get_transactions``)
can be exercised without a running Electrum daemon or any network access.
Nothing here touches the real wallet file other than reading it; the file is
opened read-only and never written back.
"""
import json
import os
from unittest.mock import MagicMock
from electrum.transaction import PartialTxInput, TxOutpoint
from electrum.util import bfh
# Read-only path to the giovanna7 regtest wallet used to source valid addresses.
GIOVANNA7_WALLET = (
"/home/steal/devel/bal/electrum2/.electrum2222/regtest/wallets.old/giovanna7"
)
# Fallback regtest addresses (used if the wallet file is not available, e.g. on
# CI), so the address-dependent tests can still run. These are valid regtest
# bech32 addresses taken from the giovanna7 wallet.
_FALLBACK_RECEIVING = [
"bcrt1qpm5utekdtmzwnlkh7jq5497vwwf6sm38tljan5",
"bcrt1qazle627r46apscly8lj4q5cxfxgrtuew879jde",
"bcrt1qh2c83yulvs7kgw0g6q3lkxqws4cnf0uxpcgcpt",
"bcrt1qsprcgaldcn6v6l6jw0w6annv7hgkpw7ycxu6mu",
"bcrt1qy02pnw9lulnnwg6m77yghn6v7ndgnjph3hrdmy",
"bcrt1qrmjaxllejgqu6azftsfu2cdjyrz8wjxzsv7wvp",
]
_FALLBACK_CHANGE = [
"bcrt1qzu5u0fgpxq5v42r62aefc2xjn6mehgzhtj4pld",
"bcrt1qs9ya5hserz44elcpt992r0ycrmh3w3chzffawc",
"bcrt1q28qtj5ugfcm4psqenq54sh55uryealeeprk6ju",
]
def load_addresses():
"""Return ``(receiving, change)`` address lists from the giovanna7 wallet.
Falls back to a small hard-coded set of valid regtest addresses when the
wallet file cannot be read, so the tests remain runnable everywhere.
"""
try:
with open(GIOVANNA7_WALLET, "r") as f:
data = json.load(f)
receiving = data["addresses"]["receiving"]
change = data["addresses"]["change"]
if receiving and change:
return receiving, change
except Exception:
pass
return list(_FALLBACK_RECEIVING), list(_FALLBACK_CHANGE)
# Module-level cached address pools.
RECEIVING_ADDRESSES, CHANGE_ADDRESSES = load_addresses()
def heir_addresses(n):
"""Return ``n`` distinct valid heir addresses from the giovanna7 wallet."""
# Use addresses further into the list so they do not collide with the
# change address used by the fake wallet.
return RECEIVING_ADDRESSES[10 : 10 + n]
def willexecutor_addresses(n):
"""Return ``n`` distinct valid will-executor payout addresses."""
return RECEIVING_ADDRESSES[100 : 100 + n]
def change_address():
"""Return a single valid change address."""
return CHANGE_ADDRESSES[0]
def make_utxo(txid_hex, value, out_idx=0):
"""Build a minimal spendable ``PartialTxInput`` with the given value.
Only the fields required by the inheritance transaction builder are set
(trusted value + ``is_mine``); this mirrors how Electrum hands UTXOs to the
plugin without needing a real wallet.
"""
txin = PartialTxInput(prevout=TxOutpoint(txid=bfh(txid_hex), out_idx=out_idx))
txin._trusted_value_sats = value
txin._TxInput__value_sats = value
txin.is_mine = True
return txin
def make_utxos(n, value=1_000_000, prefix="a1"):
"""Return ``n`` synthetic UTXOs of ``value`` satoshis each."""
return [make_utxo(f"{prefix}{i:062x}", value) for i in range(n)]
def fake_wallet(dust_threshold=546):
"""Return a MagicMock wallet sufficient for the inheritance builder.
The change address comes from the real giovanna7 wallet so generated
transactions carry a valid change output address.
"""
w = MagicMock()
w.dust_threshold.return_value = dust_threshold
w.get_change_addresses_for_new_transaction.return_value = [change_address()]
w.get_utxos.return_value = []
w.db.get.return_value = {}
w.db.get_transaction.return_value = None
return w
def fake_bal_plugin(willexecutors=None, no_willexecutor=False, decimal_point=8):
"""Return a fake ``bal_plugin`` exposing only what the builder reads.
Args:
willexecutors: ``{url: we_dict}`` mapping returned by
``Willexecutors.get_willexecutors`` (patched in the tests).
no_willexecutor: value of the ``NO_WILLEXECUTOR`` toggle (whether a
local backup transaction without will-executor is also produced).
decimal_point: BTC decimal point (8 for satoshi precision).
"""
plugin = MagicMock()
plugin.get_decimal_point.return_value = decimal_point
plugin.NO_WILLEXECUTOR.get.return_value = no_willexecutor
plugin._willexecutors = willexecutors or {}
return plugin

View File

@@ -0,0 +1,64 @@
"""Regression test: load the plugin the way Electrum loads an *external* zip.
When a user installs the plugin via Electrum's "Plugins" dialog from a .zip,
Electrum 4.7.x imports it under the synthetic top-level package
``electrum_external_plugins.bal`` and only executes the package ``__init__``
and the ``qt`` module. It does NOT pre-register the synthetic root package
nor the nested ``gui`` / ``gui.qt`` sub-packages.
A naive ``from .gui.qt.plugin import Plugin`` in ``qt.py`` therefore fails with::
ModuleNotFoundError: No module named 'electrum_external_plugins'
This test reproduces that exact loading sequence against the built zip and
asserts that the resilient ``qt.py`` shim resolves the ``Plugin`` class.
Usage:
QT_QPA_PLATFORM=offscreen \
PYTHONPATH=<electrum-src> \
python3 tests/external_zip_test.py <path-to-bal-electrum-plugin.zip>
"""
import importlib.util
import sys
import zipimport
def main(zip_path: str) -> int:
base = "electrum_external_plugins.bal"
gui = "qt"
dirname = "bal" # directory name inside the zip archive
def exec_module_from_spec(spec, path):
# Mirrors electrum.plugin.PluginManager.exec_module_from_spec
module = importlib.util.module_from_spec(spec)
sys.modules[path] = module
spec.loader.exec_module(module)
return module
zi = zipimport.zipimporter(zip_path)
# Step 1: load the package __init__ as electrum_external_plugins.bal
init_spec = zi.find_spec(dirname)
assert init_spec is not None, "could not find package __init__ inside zip"
exec_module_from_spec(init_spec, base)
# Step 2: load the qt entry-point as electrum_external_plugins.bal.qt
full = f"{base}.{gui}"
spec = importlib.util.find_spec(full)
assert spec is not None, f"could not find spec for {full!r}"
module = exec_module_from_spec(spec, full)
# The loader expects a `Plugin` class to be exported.
plugin_cls = getattr(module, "Plugin", None)
assert plugin_cls is not None, "qt module did not export a Plugin class"
print(f"[OK] external zip loads Plugin -> {plugin_cls!r}")
return 0
if __name__ == "__main__":
if len(sys.argv) != 2:
print(__doc__)
sys.exit(2)
sys.exit(main(sys.argv[1]))

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]))

353
tests/parallel_ping_test.py Normal file
View File

@@ -0,0 +1,353 @@
"""
Regression / behaviour test for the parallel will-executor networking.
Before this change, pinging / pushing to will-executor servers was done in a
sequential loop where every unreachable server blocked the whole batch for the
full timeout (plus up to 10 retries with 3s sleeps). With N servers the total
wall-clock time was the *sum* of every server's time, so a couple of dead
servers froze the GUI ("Non risponde") for minutes.
This test patches Willexecutors.get_info_task / push_transactions_to_willexecutor
with slow stubs and asserts that:
* ping_servers_parallel contacts servers concurrently (total time ~= the
slowest server, NOT the sum), and
* the on_each callback is invoked once per server with the right ok flag,
* push_transactions_parallel behaves the same way.
Run with:
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/parallel_ping_test.py <PLUGIN_IMPORT_NAME>
"""
import importlib
import sys
import threading
import time
PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal"
SLOW = 0.5 # seconds each simulated server takes to answer
N = 8 # number of servers
def main():
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
W = we_mod.Willexecutors
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
def slow_get_info(url, we, **kwargs):
time.sleep(SLOW)
# half the servers "fail"
if "dead" in url:
we["status"] = "KO"
else:
we["status"] = 200
return we
orig_get_info = W.get_info_task
W.get_info_task = staticmethod(slow_get_info)
try:
wes = {}
for i in range(N):
kind = "dead" if i % 2 else "ok"
wes[f"https://{kind}-{i}.example"] = {}
seen = []
def on_each(url, we, ok):
seen.append((url, ok))
start = time.time()
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
elapsed = time.time() - start
# Sequential would take ~ N * SLOW. Parallel must be far less.
sequential = N * SLOW
assert elapsed < sequential * 0.6, (
f"not parallel: {elapsed:.2f}s vs sequential {sequential:.2f}s")
print(f"[OK] ping parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)")
# callback fired once per server, with correct ok flags
assert len(seen) == N, seen
for url, ok in seen:
assert ok == ("ok" in url), (url, ok)
print("[OK] on_each fired once per server with correct ok flag")
# results written back into the mapping
for url, we in wes.items():
if "ok" in url:
assert we["status"] == 200, (url, we)
else:
assert we["status"] == "KO", (url, we)
print("[OK] ping results written back into the willexecutors mapping")
finally:
W.get_info_task = orig_get_info
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
def slow_push(we, **kwargs):
time.sleep(SLOW)
return "fail" not in we["url"]
orig_push = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push)
try:
wes = {}
for i in range(N):
kind = "fail" if i % 2 else "good"
wes[f"https://{kind}-{i}.example"] = {
"url": f"https://{kind}-{i}.example",
"txs": "deadbeef",
"txsids": [f"id{i}"],
}
pushed = []
def on_each_push(url, we, ok, exc):
pushed.append((url, ok))
start = time.time()
results = W.push_transactions_parallel(wes, on_each=on_each_push,
max_workers=N)
elapsed = time.time() - start
sequential = N * SLOW
assert elapsed < sequential * 0.6, (
f"push not parallel: {elapsed:.2f}s vs {sequential:.2f}s")
print(f"[OK] push parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)")
assert len(results) == N, results
for url, (ok, exc) in results.items():
assert ok == ("good" in url), (url, ok)
print("[OK] push results correct for every server")
finally:
W.push_transactions_to_willexecutor = orig_push
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
def hanging_push(we, **kwargs):
# Simulate a server that never answers within the test window.
time.sleep(10)
return True
orig_push2 = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
try:
wes = {
"https://fast.example": {
"url": "https://fast.example", "txs": "x", "txsids": ["a"],
},
"https://hang.example": {
"url": "https://hang.example", "txs": "y", "txsids": ["b"],
},
}
# fast one answers quickly, hang one never does within the deadline
def fast_or_hang(we, **kwargs):
if "fast" in we["url"]:
return True
time.sleep(10)
return True
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
timed_out = []
def on_timeout(url, we):
timed_out.append(url)
start = time.time()
W.push_transactions_parallel(
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
)
elapsed = time.time() - start
assert elapsed < 3.0, f"deadline not enforced: waited {elapsed:.1f}s"
assert "https://hang.example" in timed_out, timed_out
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
f"hung server reported via on_timeout")
finally:
W.push_transactions_to_willexecutor = orig_push2
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
# The elapsed-time counter is driven by an on_tick callback called from the
# thread that invokes push_transactions_parallel (the same thread that drives
# on_each), so its pyqtSignal repaints reliably. Assert the callback runs
# roughly once per tick_interval while the push is in flight, and that it
# runs on the calling thread (not on a worker/heartbeat thread).
def slow_push2(we, **kwargs):
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
return True
orig_push3 = W.push_transactions_to_willexecutor
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
try:
wes = {
"https://tick.example": {
"url": "https://tick.example", "txs": "x", "txsids": ["a"],
},
}
ticks = []
caller_thread = threading.current_thread()
tick_threads = set()
def on_tick():
ticks.append(time.time())
tick_threads.add(threading.current_thread())
W.push_transactions_parallel(
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
)
# ~3s push with 0.5s ticks => at least a few ticks.
assert len(ticks) >= 3, f"on_tick fired too few times: {len(ticks)}"
assert tick_threads == {caller_thread}, (
"on_tick must run on the calling thread, got "
f"{[t.name for t in tick_threads]}"
)
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
finally:
W.push_transactions_to_willexecutor = orig_push3
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
# Pressing "Check" verifies each will-executor still holds its tx. This used
# to be a sequential loop with default (~140s) timeouts, freezing the
# "checking transaction" dialog on a dead server. It must now run in
# parallel, enforce a global deadline, and drive an on_tick counter from the
# calling thread.
def slow_check(txid, url, **kwargs):
time.sleep(SLOW)
return {"tx": "ok"} if "good" in url else None
orig_check = W.check_transaction
W.check_transaction = staticmethod(slow_check)
try:
targets = []
for i in range(N):
kind = "good" if i % 2 else "bad"
targets.append((f"id{i}", f"https://{kind}-{i}.example"))
checked = []
def on_each_check(wid, url, res, exc):
checked.append((wid, res))
start = time.time()
results = W.check_transactions_parallel(
targets, on_each=on_each_check, max_workers=N
)
elapsed = time.time() - start
sequential = N * SLOW
assert elapsed < sequential * 0.6, (
f"check not parallel: {elapsed:.2f}s vs {sequential:.2f}s")
assert len(results) == N, results
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
f"(sequential would be ~{sequential:.2f}s)")
finally:
W.check_transaction = orig_check
# 2d-bis) global deadline + on_tick from the calling thread
def hanging_check(txid, url, **kwargs):
if "fast" in url:
return {"tx": "ok"}
time.sleep(10)
return {"tx": "ok"}
orig_check2 = W.check_transaction
W.check_transaction = staticmethod(hanging_check)
try:
targets = [
("idf", "https://fast.example"),
("idh", "https://hang.example"),
]
timed_out = []
ticks = []
caller_thread = threading.current_thread()
tick_threads = set()
def on_timeout_check(wid, url):
timed_out.append(wid)
def on_tick_check():
ticks.append(time.time())
tick_threads.add(threading.current_thread())
start = time.time()
W.check_transactions_parallel(
targets, max_workers=2, deadline=2.0,
on_timeout=on_timeout_check, on_tick=on_tick_check,
tick_interval=0.5,
)
elapsed = time.time() - start
assert elapsed < 4.0, f"check deadline not enforced: {elapsed:.1f}s"
assert "idh" in timed_out, timed_out
assert len(ticks) >= 2, f"check on_tick fired too few times: {len(ticks)}"
assert tick_threads == {caller_thread}, (
"check on_tick must run on the calling thread")
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
f"fired {len(ticks)}x from the calling thread")
finally:
W.check_transaction = orig_check2
# ---- 3) the wizard's loop_push must use the parallel helper ----
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
# It previously looped over servers sequentially (one
# push_transactions_to_willexecutor call at a time), which is exactly the
# slow path the user saw at "Broadcasting your will to executors". Make
# sure it now delegates to push_transactions_parallel.
import inspect
dialogs_mod = importlib.import_module(f"{PKG}.gui.qt.dialogs")
loop_push_src = inspect.getsource(dialogs_mod.BalBuildWillDialog.loop_push)
code = "\n".join(
line for line in loop_push_src.splitlines()
if not line.lstrip().startswith("#")
)
assert "push_transactions_parallel" in code, (
"wizard loop_push must use push_transactions_parallel (parallel push)")
assert "for url, willexecutor in willexecutors.items()" not in code, (
"wizard loop_push must not push to servers in a sequential loop")
print("[OK] wizard loop_push uses push_transactions_parallel (not sequential)")
# The wizard counter must be driven via on_tick from the calling thread, NOT
# via a separate heartbeat thread (whose pyqtSignal emissions never
# repainted the dialog -> the counter was invisible during "Broadcasting").
assert "on_tick" in code, (
"wizard loop_push must drive the counter via on_tick (calling thread)")
assert "threading.Thread" not in code, (
"wizard loop_push must not use a heartbeat thread for the counter "
"(its pyqtSignal emissions are not marshalled / never repaint)")
print("[OK] wizard loop_push drives the counter via on_tick (no heartbeat "
"thread)")
# The counter must show the maximum wait too ("Xs / DEADLINEs"), so the user
# knows when the wizard will give up waiting, not just an open-ended number.
assert "PUSH_GLOBAL_DEADLINE" in code, (
"wizard counter must reference the global deadline so it can show "
"'Xs / DEADLINEs'")
assert "{}s / {}s" in code or "s / {}s" in code, (
"wizard counter must render the elapsed time AND the deadline "
"(e.g. '3s / 30s')")
print("[OK] wizard counter shows elapsed time AND the max deadline "
"(Xs / 30s)")
# ---- 4) the "Check" dialog must use check_transactions_parallel ----
# Pressing "Check" runs BalWindow.check_transactions_task. It used to loop
# over will-items sequentially calling check_transaction (default ~140s
# timeouts), freezing the "checking transaction" dialog. It must now use the
# parallel helper and show the elapsed-time counter.
window_mod = importlib.import_module(f"{PKG}.gui.qt.window")
check_src = inspect.getsource(window_mod.BalWindow.check_transactions_task)
check_code = "\n".join(
line for line in check_src.splitlines()
if not line.lstrip().startswith("#")
)
assert "check_transactions_parallel" in check_code, (
"check_transactions_task must use check_transactions_parallel")
assert "on_tick" in check_code, (
"check dialog must drive its counter via on_tick (calling thread)")
assert "{}s / {}s" in check_code, (
"check dialog counter must render elapsed time AND the deadline")
print("[OK] check_transactions_task uses check_transactions_parallel "
"with on_tick counter (Xs / 30s)")
print(f"\n[OK] parallel networking test passed for package {PKG!r}")
return 0
if __name__ == "__main__":
sys.exit(main())

View File

@@ -0,0 +1,110 @@
#!/usr/bin/env python3
"""Render a visual PREVIEW (before/after) of the "Building Will" dialog text.
This is a throwaway, GUI-only helper used to show the user how the proposed
"bold results" formatting looks compared to the current rendering, BEFORE any
production code is changed. It does NOT import the plugin; it just reproduces
the exact rich-text the dialog builds via ``msg_set_status`` / ``msg_ok`` /
``msg_error`` so the preview is faithful.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_build_will_dialog.py
It writes two PNGs in the repo root: preview_before.png and preview_after.png.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
# Same colors as BalBuildWillDialog
COLOR_WARNING = "#cfa808"
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
# ---- current rendering (BEFORE) -------------------------------------------
def ok_before(e="Ok"):
return "<font color='{}'>{}</font>".format(COLOR_OK, e)
def error_before(e):
return "<font color='{}'>{}</font>".format(COLOR_ERROR, e)
def row_before(msg, status, color=None):
if color is None:
return f"{msg}:\t{status}"
return "<font color={}>{}:\t{}</font>".format(color, msg, status)
# ---- proposed rendering (AFTER): results in bold --------------------------
def ok_after(e="Ok"):
return "<font color='{}'><b>{}</b></font>".format(COLOR_OK, e)
def error_after(e):
return "<font color='{}'><b>{}</b></font>".format(COLOR_ERROR, e)
def row_after(msg, status, color=None):
# Left state label stays normal; only the result (status) becomes bold.
if color is None:
return f"{msg}:\t<b>{status}</b>"
# When a color is given for the whole line, keep the label normal and bold
# only the status portion.
return "{}:\t<font color={}><b>{}</b></font>".format(msg, color, status)
def build_rows(mode):
if mode == "before":
ok, err, row = ok_before, error_before, row_before
else:
ok, err, row = ok_after, error_after, row_after
rows = [
row("checking variables", "Wait"),
row("Checking your will", ok()),
row("Signing your will", "Nothing to do"),
row("Broadcasting your will to executors", "Nothing to do"),
ok(),
row("Invalidating old will", err("Ko")),
"https://executor.example.org : " + ok(),
"https://other.example.org : " + err("Ko"),
"Please wait 2secs",
row("Will-Executor excluded", "Skipped", COLOR_ERROR),
]
return rows
def render(mode, path):
rows = build_rows(mode)
full_text = "<br><br>".join(rows).replace("\n", "<br>")
w = QWidget()
w.setStyleSheet("background:#2b2b2b;")
lay = QVBoxLayout(w)
title = QLabel(f"Building Will — {mode.upper()}")
title.setStyleSheet("color:#ffffff; font-size:15px; font-weight:bold;")
lbl = QLabel(full_text)
lbl.setTextFormat(Qt.TextFormat.RichText)
lbl.setStyleSheet("color:#dddddd; font-size:13px;")
lbl_font = lbl.font()
lbl_font.setPointSize(11)
lbl.setFont(lbl_font)
lay.addWidget(title)
lay.addWidget(lbl)
w.resize(560, 420)
w.show()
app.processEvents()
pix = w.grab()
pix.save(path)
print(f"[{mode}] saved -> {path}")
if __name__ == "__main__":
app = QApplication(sys.argv)
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
render("before", os.path.join(here, "preview_before.png"))
render("after", os.path.join(here, "preview_after.png"))

View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Visual PREVIEW: replace the final countdown with a "Close" button.
Mocks the "Building Will" dialog content (the result rows are built exactly as
the real dialog builds them) and shows the proposed bottom "Close" button that
replaces the "Please wait 5secs" auto-closing countdown.
BEFORE: last line is the countdown "Please wait 5secs" (dialog auto-closes).
AFTER : a real "Close" button at the bottom; the user closes when ready.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_building_will_close_btn.py
Writes preview_buildwill_before.png / preview_buildwill_after.png.
"""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QLabel, QPushButton,
)
from PyQt6.QtCore import Qt # noqa: E402
COLOR_OK = "#05ad05"
def _ok(text="Ok"):
return "<font color='{}'><b>{}</b></font>".format(COLOR_OK, text)
def _rows(after: bool):
"""The exact result rows the real dialog shows on a clean run.
BEFORE keeps the old lowercase "checking variables" and the "All done"
row directly under the others. AFTER applies the two text fixes:
capitalised "Checking variables" + a blank separator row above "All done".
"""
if not after:
return [
"checking variables:\t" + _ok("Ok"),
"Checking your will:\t" + _ok("Ok"),
"Signing your will:\t<b>Nothing to do</b>",
"Broadcasting your will to executors:\t<b>Nothing to do</b>",
"All done:\t" + _ok("Ok"),
]
return [
"Checking variables:\t" + _ok("Ok"),
"Checking your will:\t" + _ok("Ok"),
"Signing your will:\t<b>Nothing to do</b>",
"Broadcasting your will to executors:\t<b>Nothing to do</b>",
"", # blank separator row
"All done:\t" + _ok("Ok"),
]
def _build(after: bool) -> QWidget:
panel = QWidget()
panel.setMinimumWidth(600)
v = QVBoxLayout(panel)
v.addWidget(QLabel("<b>Building Will:</b>"))
rows = QWidget()
rv = QVBoxLayout(rows)
rv.setContentsMargins(8, 4, 8, 4)
for line in _rows(after):
lbl = QLabel(line)
lbl.setTextFormat(Qt.TextFormat.RichText)
rv.addWidget(lbl)
v.addWidget(rows)
if not after:
# BEFORE: the countdown row (auto-close).
wait = QLabel("Please wait 5secs")
v.addWidget(wait)
else:
# AFTER: a Close button row, right aligned (standard dialog layout).
v.addSpacing(8)
btn_row = QWidget()
h = QHBoxLayout(btn_row)
h.setContentsMargins(0, 0, 0, 0)
h.addStretch(1)
close = QPushButton("Close")
close.setDefault(True)
close.setMinimumWidth(90)
h.addWidget(close)
v.addWidget(btn_row)
panel.resize(620, 230)
return panel
def main():
app = QApplication.instance() or QApplication([])
for after, name in ((False, "preview_buildwill_before.png"),
(True, "preview_buildwill_after.png")):
panel = _build(after)
panel.show()
app.processEvents()
panel.grab().save(name)
print("wrote", name)
if __name__ == "__main__":
main()

105
tests/preview_we_rows.py Normal file
View File

@@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""Visual PREVIEW focused on the WILL-EXECUTOR rows of the Building Will dialog.
Reproduces faithfully the three real variants built in dialogs.py:
1. Broadcasting (push) result -> line 774: "{url} : {Ok|Ko}" (plain, no color today)
2. Timeout -> line 783: "{url} : <font red>Timeout - no answer</font>"
3. Checking already-present -> line 825/834:
"checking {url} - {wid} : Waiting"
"checked {url} - {wid} : True/False" (plain, no color today)
Shows BEFORE (current) vs AFTER (proposed: result in bold, keeping label as-is).
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_we_rows.py
Writes preview_we_before.png / preview_we_after.png in the repo root.
"""
import os
import sys
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import QApplication, QLabel, QWidget, QVBoxLayout
from PyQt6.QtCore import Qt
COLOR_ERROR = "#ff0000"
COLOR_OK = "#05ad05"
URL1 = "https://executor.example.org"
URL2 = "https://other-executor.net"
WID = "a1b2c3"
def err(e):
return "<font color='{}'>{}</font>".format(COLOR_ERROR, e)
# ---------------- BEFORE: exactly as the code builds today -----------------
def rows_before():
return [
# 1. push results (plain text, no color/bold today)
"{} : {}".format(URL1, "Ok"),
"{} : {}".format(URL2, "Ko"),
# 2. timeout (already red, not bold)
"{} : {}".format(URL1, err("Timeout - no answer")),
# 3. already-present check
"checking {} - {} : {}".format(URL1, WID, "Waiting"),
"checked {} - {} : {}".format(URL1, WID, "True"),
"checked {} - {} : {}".format(URL2, WID, "False"),
]
# ---------------- AFTER: result portion in bold, label unchanged -----------
def err_after(e):
return "<font color='{}'><b>{}</b></font>".format(COLOR_ERROR, e)
def rows_after():
return [
# 1. push results: color + bold the Ok / Ko outcome
"{} : <font color='{}'><b>{}</b></font>".format(URL1, COLOR_OK, "Ok"),
"{} : <font color='{}'><b>{}</b></font>".format(URL2, COLOR_ERROR, "Ko"),
# 2. timeout: bold the red message
"{} : {}".format(URL1, err_after("Timeout - no answer")),
# 3. already-present check: bold the result
"checking {} - {} : <b>{}</b>".format(URL1, WID, "Waiting"),
"checked {} - {} : <font color='{}'><b>{}</b></font>".format(
URL1, WID, COLOR_OK, "True"
),
"checked {} - {} : <font color='{}'><b>{}</b></font>".format(
URL2, WID, COLOR_ERROR, "False"
),
]
def render(rows, title, path):
full_text = "<br><br>".join(rows).replace("\n", "<br>")
w = QWidget()
w.setStyleSheet("background:#2b2b2b;")
lay = QVBoxLayout(w)
t = QLabel(title)
t.setStyleSheet("color:#ffffff; font-size:15px; font-weight:bold;")
lbl = QLabel(full_text)
lbl.setTextFormat(Qt.TextFormat.RichText)
lbl.setStyleSheet("color:#dddddd;")
f = lbl.font()
f.setPointSize(11)
lbl.setFont(f)
lay.addWidget(t)
lay.addWidget(lbl)
w.resize(560, 320)
w.show()
app.processEvents()
w.grab().save(path)
print(f"saved -> {path}")
if __name__ == "__main__":
app = QApplication(sys.argv)
here = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
render(rows_before(), "Will-Executor rows — BEFORE",
os.path.join(here, "preview_we_before.png"))
render(rows_after(), "Will-Executor rows — AFTER",
os.path.join(here, "preview_we_after.png"))

View File

@@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""Visual PREVIEW: proposals to make the Wizard button more visible.
Renders the toolbar Wizard button (using the real icons/wizard.png) in several
styles so the user can pick one. Nothing is changed in the plugin yet.
Variants:
0. CURRENT : icon only, default size (what ships today).
A. BIGGER : same icon, larger button + larger iconSize.
B. ICON+TEXT: bigger icon plus a "Create your will" label.
C. ACCENT : icon + text on a colored (Bitcoin-orange) rounded button.
D. ACCENT-BLUE: icon + text on a BAL-blue (#2bc8ed) rounded button.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_wizard_button.py
Writes preview_wizardbtn_<id>.png in the repo root.
"""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QHBoxLayout, QPushButton, QComboBox, QLineEdit,
QLabel,
)
from PyQt6.QtGui import QIcon, QPixmap # noqa: E402
from PyQt6.QtCore import QSize, Qt # noqa: E402
ICON_PATH = os.path.join(os.path.dirname(__file__), "..", "bal", "icons",
"wizard.png")
def _icon():
pm = QPixmap(ICON_PATH)
return QIcon(pm)
def _toolbar_tail(parent):
"""The widgets that sit to the right of the wizard button, for context."""
out = []
icon = QPushButton(parent)
icon.setText("📅")
combo = QComboBox(parent)
combo.addItems(["Raw", "Date"])
combo.setCurrentIndex(1)
field = QLineEdit("04/06/2028 00:00", parent)
field.setFixedWidth(140)
out += [icon, combo, field]
return out
def _frame(make_wizard, label):
panel = QWidget()
v = QHBoxLayout(panel)
v.setContentsMargins(10, 10, 10, 10)
tag = QLabel(label, panel)
tag.setFixedWidth(120)
v.addWidget(tag)
wiz = make_wizard(panel)
v.addWidget(wiz)
for w in _toolbar_tail(panel):
v.addWidget(w)
v.addStretch(1)
panel.resize(720, 70)
return panel
# ---- variants -------------------------------------------------------------
def v_current(parent):
b = QPushButton(parent)
b.setIcon(_icon())
b.setToolTip("Wizard - Build your will")
return b
def v_bigger(parent):
b = QPushButton(parent)
b.setIcon(_icon())
b.setIconSize(QSize(36, 36))
b.setFixedSize(48, 44)
b.setToolTip("Wizard - Build your will")
return b
def v_icon_text(parent):
b = QPushButton(" Create your will", parent)
b.setIcon(_icon())
b.setIconSize(QSize(28, 28))
b.setMinimumHeight(40)
b.setStyleSheet("QPushButton{font-weight:bold;}")
return b
def v_accent_orange(parent):
b = QPushButton(" Create your will", parent)
b.setIcon(_icon())
b.setIconSize(QSize(28, 28))
b.setMinimumHeight(40)
b.setStyleSheet(
"QPushButton{background-color:#f7931a;color:white;font-weight:bold;"
"border:none;border-radius:8px;padding:6px 14px;}"
"QPushButton:hover{background-color:#ffa733;}"
)
return b
def v_accent_blue(parent):
b = QPushButton(" Create your will", parent)
b.setIcon(_icon())
b.setIconSize(QSize(28, 28))
b.setMinimumHeight(40)
b.setStyleSheet(
"QPushButton{background-color:#2bc8ed;color:white;font-weight:bold;"
"border:none;border-radius:8px;padding:6px 14px;}"
"QPushButton:hover{background-color:#4fd6f3;}"
)
return b
def main():
app = QApplication.instance() or QApplication([])
variants = [
(v_current, "0-CURRENT", "preview_wizardbtn_0_current.png"),
(v_bigger, "A-BIGGER", "preview_wizardbtn_A_bigger.png"),
(v_icon_text, "B-ICON+TEXT", "preview_wizardbtn_B_icontext.png"),
(v_accent_orange, "C-ORANGE", "preview_wizardbtn_C_orange.png"),
(v_accent_blue, "D-BLUE", "preview_wizardbtn_D_blue.png"),
]
for make, label, name in variants:
panel = _frame(make, label)
panel.show()
app.processEvents()
panel.grab().save(name)
print("wrote", name)
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,151 @@
#!/usr/bin/env python3
"""Visual PREVIEW for the wizard "Bitcoin After Life Will Settings" rows.
Reproduces the structure of WillSettingsWidget in its VERTICAL layout (the one
used by the "Build your will" wizard):
row 1: [icon][combo "Date"][date field] (delivery time / locktime)
row 2: [icon][combo "Date"][date field] (check alive / threshold)
row 3: [calendar button] (calendar export)
row 4: [icon ""][spin "5"] (tx fees)
The icons are HelpButtons, which pin themselves to a fixed width
(2.2 * char_width_in_lineedit()). This preview reproduces that original icon
size and shows:
* BEFORE: calendar/fee stretch to the right edge -> rows wider than dates.
* AFTER : icons keep their ORIGINAL fixed width; every row is capped to the
date-row width and left aligned, so all rows fit in the same block
and line up on both edges.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/preview_wizard_settings_align.py
Writes preview_wizard_before.png / preview_wizard_after.png in the repo root.
"""
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
from PyQt6.QtWidgets import ( # noqa: E402
QApplication, QWidget, QVBoxLayout, QHBoxLayout, QToolButton, QComboBox,
QLineEdit, QSpinBox, QLabel,
)
from PyQt6.QtGui import QFontMetrics # noqa: E402
from PyQt6.QtCore import Qt # noqa: E402
def _char_w():
fm = QFontMetrics(QApplication.instance().font())
return fm.horizontalAdvance("0")
def _icon(text):
"""Mimic HelpButton: a QToolButton pinned to 2.2 * char width."""
b = QToolButton()
b.setText(text)
b.setFixedWidth(round(2.2 * _char_w()))
return b
def _date_row():
w = QWidget()
h = QHBoxLayout(w)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(0)
icon = _icon("📅")
combo = QComboBox()
combo.addItems(["Raw", "Date"])
combo.setCurrentIndex(1)
field = QLineEdit("04/06/2028 00:00")
h.addWidget(icon)
h.addWidget(combo)
h.addWidget(field)
h.addStretch(1)
w._prefix = icon
return w
def _fee_row():
w = QWidget()
h = QHBoxLayout(w)
h.setContentsMargins(0, 0, 0, 0)
h.setSpacing(0)
icon = _icon("")
spin = QSpinBox()
spin.setValue(5)
spin.setMaximum(10000)
h.addWidget(icon)
h.addWidget(spin)
w._prefix = icon
return w
def _calendar_button():
return QToolButton()
def _panel():
panel = QWidget()
box = QVBoxLayout(panel)
box.addWidget(QLabel("Bitcoin After Life Will Settings"))
return panel, box
def build_before():
panel, box = _panel()
box.addWidget(_date_row())
box.addWidget(_date_row())
box.addWidget(_calendar_button())
box.addWidget(_fee_row())
panel.resize(760, 220)
return panel
def build_after():
panel, box = _panel()
r1 = _date_row()
r2 = _date_row()
cal = _calendar_button()
r4 = _fee_row()
# icons keep their original fixed width (no resizing)
icon_w = r1._prefix.sizeHint().width()
row_w = max(r1.sizeHint().width(), r2.sizeHint().width())
for r in (r1, r2, r4):
r.setFixedWidth(row_w)
cal_row = QWidget()
cb = QHBoxLayout(cal_row)
cb.setContentsMargins(0, 0, 0, 0)
cb.setSpacing(0)
sp = QWidget()
sp.setFixedWidth(icon_w)
cb.addWidget(sp)
cb.addWidget(cal)
cal_row.setFixedWidth(row_w)
box.addWidget(r1, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(r2, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(cal_row, alignment=Qt.AlignmentFlag.AlignLeft)
box.addWidget(r4, alignment=Qt.AlignmentFlag.AlignLeft)
panel.resize(760, 220)
return panel
def main():
app = QApplication.instance() or QApplication([])
for builder, name in (
(build_before, "preview_wizard_before.png"),
(build_after, "preview_wizard_after.png"),
):
panel = builder()
panel.show()
app.processEvents()
panel.grab().save(name)
print("wrote", name)
if __name__ == "__main__":
main()

171
tests/sim_update_flows.py Normal file
View File

@@ -0,0 +1,171 @@
"""
Real-world simulation of the inheritance update flows.
This script does NOT touch the GUI. It drives the core decision function
``Will.check_willexecutors_and_heirs`` (the one that decides whether a will is
still coherent or must be rebuilt) through the scenarios the user reported:
1. delivery date moved forward (postpone) -> must NOT stay "coherent"
2. an heir is added -> must trigger rebuild
3. an heir is removed -> must trigger rebuild
4. a single heir percentage / amount is changed -> must trigger rebuild
5. nothing changed -> stays coherent
For each scenario we report which exception (if any) is raised, because that is
exactly what the GUI relies on to decide whether to rebuild the inheritance
transactions. If the function returns True (coherent) when something DID
change, the GUI will (correctly) show no update -- which is the symptom the
user described.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src python3 tests/sim_update_flows.py
"""
import sys
import os
import copy
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import (
WillItem, Will,
NotCompleteWillException, HeirNotFoundException, NoHeirsException,
TxFeesChangedException, WillExpiredException,
)
from bal.core.util import Util
# A valid serialized tx (1 input + 1 output, version 2). Its nLockTime is 0.
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# A locktime far in the past (so the frozen tx.locktime is a fixed integer we
# control via monkey-patching below). We will override w.tx.locktime per test.
TX_FEES = 100
def _make_will_item(heirs, tx_locktime, status_complete=False):
"""Build a WillItem whose stored heirs == ``heirs`` and whose tx.locktime
is forced to ``tx_locktime`` (the value frozen in the signed Bitcoin tx)."""
d = {
"tx": _VALID_TX_HEX,
"heirs": copy.deepcopy(heirs),
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": TX_FEES,
}
item = WillItem(d, _id="willid_1")
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
# Force the locktime frozen "inside" the signed tx.
item.tx.locktime = tx_locktime
if status_complete:
item.set_status("COMPLETE", True)
return item
def _run(label, will_heirs, current_heirs, tx_locktime,
status_complete=False, check_date=0):
"""Run check_willexecutors_and_heirs and report the outcome."""
item = _make_will_item(will_heirs, tx_locktime, status_complete)
will = {"willid_1": item}
outcome = None
try:
result = Will.check_willexecutors_and_heirs(
will,
current_heirs, # the (possibly edited) heirs dict
{}, # willexecutors
False, # self_willexecutor
check_date, # check_date (timestamp)
TX_FEES, # tx_fees
)
outcome = f"coherent (returned {result})"
except HeirNotFoundException as e:
outcome = f"HeirNotFoundException: {e}"
except NoHeirsException as e:
outcome = f"NoHeirsException: {e}"
except TxFeesChangedException as e:
outcome = f"TxFeesChangedException: {e}"
except WillExpiredException as e:
outcome = f"WillExpiredException: {e}"
except NotCompleteWillException as e:
outcome = f"{type(e).__name__}: {e}"
except Exception as e:
outcome = f"!! UNEXPECTED {type(e).__name__}: {e}"
print(f"[{label}]")
print(f" -> {outcome}")
return outcome
def main():
# locktime string "0d" -> Util.parse_locktime_string returns a timestamp
# ~ now. We use explicit integer timestamps to keep things deterministic.
base_lt = 1900000000 # frozen tx.locktime (year ~2030)
later_lt = "2000000000" # a later locktime string (postpone)
same_lt = str(base_lt)
# Scenario 0: nothing changed -> should be coherent.
heirs = {"alice": ["addr_alice", 5000, same_lt]}
_run("0. nothing changed",
will_heirs=heirs, current_heirs=copy.deepcopy(heirs),
tx_locktime=base_lt, check_date=0)
# Scenario 1: delivery date moved forward (postpone), will NOT yet signed.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {"alice": ["addr_alice", 5000, later_lt]}
_run("1. date postponed (unsigned will)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 1b: postpone on a SIGNED will (status COMPLETE).
_run("1b. date postponed (SIGNED will)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, status_complete=True, check_date=0)
# Scenario 2: an heir is ADDED.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {
"alice": ["addr_alice", 5000, same_lt],
"bob": ["addr_bob", 3000, same_lt],
}
_run("2. heir added (bob)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 3: an heir is REMOVED.
heirs_will = {
"alice": ["addr_alice", 5000, same_lt],
"bob": ["addr_bob", 3000, same_lt],
}
heirs_now = {"alice": ["addr_alice", 5000, same_lt]}
_run("3. heir removed (bob)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 4: a single heir AMOUNT/percentage changed.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {"alice": ["addr_alice", 9999, same_lt]}
_run("4. heir amount changed (5000 -> 9999)",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
# Scenario 5: heir ADDRESS changed.
heirs_will = {"alice": ["addr_alice", 5000, same_lt]}
heirs_now = {"alice": ["addr_NEW", 5000, same_lt]}
_run("5. heir address changed",
will_heirs=heirs_will, current_heirs=heirs_now,
tx_locktime=base_lt, check_date=0)
print("\n[done] simulation finished")
if __name__ == "__main__":
main()

92
tests/smoke_test.py Normal file
View File

@@ -0,0 +1,92 @@
"""
Smoke test for the BAL Electrum plugin.
Goal: after every refactor step, prove that the plugin still imports cleanly
under a real Electrum 4.7.2 + PyQt6 install, and that a handful of pure-logic
behaviours produce *exactly* the same results as before (regression guard).
Run with:
QT_QPA_PLATFORM=offscreen python3 tests/smoke_test.py <PLUGIN_IMPORT_NAME>
where <PLUGIN_IMPORT_NAME> is the dotted module path the plugin is reachable
at, e.g. "electrum.plugins.BAL" (original) or "electrum.plugins.bal" (new).
"""
import importlib
import sys
PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.BAL"
def imp(mod):
return importlib.import_module(f"{PKG}.{mod}")
def main():
# --- Qt must be initialised before importing any gui module ---
from PyQt6.QtWidgets import QApplication # noqa
_app = QApplication.instance() or QApplication([])
results = {}
# 1) Core modules import (these must be GUI-free).
bal = imp_core("bal", "core.plugin_base")
util = imp_core("util", "core.util")
heirs = imp_core("heirs", "core.heirs")
will = imp_core("will", "core.will")
we = imp_core("willexecutors", "core.willexecutors")
# 2) GUI module imports.
qt = imp_gui()
# 3) Behaviour checks (pure logic, must be identical across versions).
BalTimestamp = bal.BalTimestamp
assert BalTimestamp("30d").duration_to_days() == 30, "BalTimestamp 30d"
assert BalTimestamp("1y").duration_to_days() == 365, "BalTimestamp 1y"
assert str(BalTimestamp("7d")) == "7d", "BalTimestamp str"
Util = util.Util
assert Util.is_perc("50%") is True
assert Util.is_perc("100") is False
assert Util.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL"
assert Util.int_locktime(days=1) == 86400
# heirs constants must keep the same column layout (very delicate!)
assert heirs.HEIR_ADDRESS == 0
assert heirs.HEIR_AMOUNT == 1
assert heirs.HEIR_LOCKTIME == 2
assert heirs.HEIR_REAL_AMOUNT == 3
assert heirs.HEIR_DUST_AMOUNT == 4
# WillItem default status table must stay intact.
assert will.WillItem.STATUS_DEFAULT["VALID"][1] is True
# 4) Plugin class wiring.
assert qt.Plugin.__bases__[0] is bal.BalPlugin
for h in ("create_status_bar", "init_menubar", "load_wallet", "close_wallet"):
assert hasattr(qt.Plugin, h), f"missing hook {h}"
print(f"[OK] smoke test passed for package '{PKG}'")
def imp_core(old_name, new_name):
"""Import a core module, trying the new layout first then the old flat one."""
for candidate in (new_name, old_name):
try:
return importlib.import_module(f"{PKG}.{candidate}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"cannot import {old_name}/{new_name} from {PKG}")
def imp_gui():
for candidate in ("gui.qt.plugin", "qt"):
try:
return importlib.import_module(f"{PKG}.{candidate}")
except ModuleNotFoundError:
continue
raise ModuleNotFoundError(f"cannot import gui module from {PKG}")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,232 @@
"""
Test: anticipating a signed will does NOT request an invalidation transaction.
Scenario:
The user has a signed/pushed will with RELATIVE locktime "300d". After 2
days they change the locktime to "100d" (shorter → anticipate).
Anticipating means the new inheritance would unlock EARLIER than the
already-signed one. Unlike a postpone, this does NOT need the old
pre-signed transaction to be invalidated on-chain first: a will-executor
holding the old "300d" tx can only broadcast it LATER than the new "100d"
one, so there is no risk of executing the inheritance too early.
Therefore ``check_willexecutors_and_heirs`` must NOT raise
``WillPostponedException`` (the only exception that requests an on-chain
invalidation transaction). It may request a plain rebuild
(``HeirNotFoundException``) but never an invalidation.
The threshold ("30d") also stays in the future, so ``CheckAliveError`` /
``WillExpiredException`` must not fire either.
"""
import copy
import os
import sys
import time
import itertools
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from electrum import constants
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@pytest.fixture(autouse=True)
def _regtest_network():
previous = constants.net
constants.BitcoinRegtest.set_as_network()
try:
yield
finally:
previous.set_as_network()
import bal_fixtures as fx # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.will import ( # noqa: E402
Will, WillItem, WillPostponedException, WillExpiredException,
)
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.core.plugin_base import BalTimestamp # noqa: E402
from electrum.transaction import PartialTransaction # noqa: E402
DAY = 86400
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _willexecutors(specs):
out = {}
for url, address, base_fee in specs:
out[url] = {
"url": url, "address": address,
"base_fee": base_fee, "selected": True, "status": 200,
}
return out
def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100):
wallet = fx.fake_wallet()
utxos = fx.make_utxos(4, 5_000_000)
plugin = fx.fake_bal_plugin(willexecutors)
if from_locktime is None:
from_locktime = int(time.time())
counter = itertools.count(1)
def fake_txid(self):
if not hasattr(self, "_test_txid"):
self._test_txid = f"{next(counter):064x}"
return self._test_txid
with patch.object(Willexecutors, "get_willexecutors",
return_value=willexecutors), \
patch.object(PartialTransaction, "txid", fake_txid):
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
from_locktime)
return txs or {}
def _tx_for_we(txs, url):
for tx in txs.values():
we = getattr(tx, "willexecutor", None)
if we and we.get("url") == url:
return tx
return None
# ------------------------------------------------------------------ #
# The test
# ------------------------------------------------------------------ #
def test_anticipate_after_two_days_does_not_request_invalidation():
"""Anticipating (300d -> 100d) after 2 days must NOT raise
WillPostponedException (no on-chain invalidation transaction)."""
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
# ── 1. Create heirs with INITIAL locktime "300d" ─────────────────
heirs_initial = {
"alice": [ha[0], "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = Heirs.__new__(Heirs)
dict.__init__(h_initial)
h_initial.db = MagicMock()
h_initial.wallet = MagicMock()
h_initial.update(copy.deepcopy(heirs_initial))
wes = _willexecutors([("https://we.example", we_addr, 100000)])
# ── 2. Build the will ───────────────────────────────────────────
txs = _build(h_initial, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
assert tx is not None, "no will-executor transaction built"
txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid()
# ── 3. Wrap in WillItem, mark SIGNED + PUSHED ───────────────────
item = WillItem.__new__(WillItem)
item.tx = tx
item._id = txid
item.heirs = copy.deepcopy(tx.heirs)
item.we = copy.deepcopy(tx.willexecutor)
item.status = ""
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx_fees = 100
item.time = now
item.father = None
item.children = {}
item.description = ""
item.change = ""
item.set_status("VALID", True)
item.set_status("COMPLETE", True) # ← signed
item.set_status("PUSHED", True) # ← sent to will-executor
willitems = {txid: item}
old_locktime = int(tx.locktime)
# The frozen tx locktime is ~ now + 300d.
assert old_locktime > now + 290 * DAY
# ── 4. Advance "now" by 2 days ──────────────────────────────────
advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY)
import bal.core.plugin_base as pb_mod
import datetime as real_dt
class _PatchedDt:
@classmethod
def now(cls):
return advanced_now_dt
@classmethod
def fromtimestamp(cls, ts):
return real_dt.datetime.fromtimestamp(ts)
@classmethod
def today(cls):
return advanced_now_dt.date()
_PatchedDt.timedelta = real_dt.timedelta
with patch.object(pb_mod, "datetime", _PatchedDt):
check_date_2d = BalTimestamp("30d").to_timestamp()
# ── 5. Threshold still in future → no CheckAliveError ───────────
assert check_date_2d > advanced_now_dt.timestamp(), (
"30d threshold is still future after 2 days"
)
# ── 6. check_will_expired — old locktime (now+300d) > check_date ─
# The frozen 300d locktime is well beyond the 30d threshold, so the
# already-signed will is NOT expired and no anticipation is forced here.
all_inputs = Will.get_all_inputs(willitems, only_valid=True)
all_inputs_min = Will.get_all_inputs_min_locktime(all_inputs)
Will.check_will_expired(all_inputs_min, check_date_2d)
# ── 7. check_willexecutors_and_heirs with SHORTER locktime ──────
# Change the locktime from "300d" to "100d" (anticipate).
heirs_changed = {
"alice": [ha[0], "40%", "100d"],
"bob": [ha[1], "30%", "100d"],
"carol": [ha[2], "30%", "100d"],
}
h_changed = Heirs.__new__(Heirs)
dict.__init__(h_changed)
h_changed.db = MagicMock()
h_changed.wallet = MagicMock()
h_changed.update(copy.deepcopy(heirs_changed))
# The new locktime (now+100d) is EARLIER than the frozen one (now+300d):
new_locktime = (
real_dt.datetime.fromtimestamp(now) + real_dt.timedelta(days=100)
).timestamp()
assert new_locktime < old_locktime, "100d must anticipate 300d"
# The whole point: NO WillPostponedException is raised (anticipation does
# not need an on-chain invalidation transaction).
try:
Will.check_willexecutors_and_heirs(
willitems, h_changed, wes,
False, # no_willexecutor
check_date_2d,
100, # tx_fees (unchanged)
)
except WillPostponedException as exc:
pytest.fail(
f"anticipation must NOT request an invalidation transaction, "
f"but WillPostponedException was raised: {exc}"
)
except WillExpiredException as exc:
pytest.fail(
f"anticipation within the threshold must NOT expire the will, "
f"but WillExpiredException was raised: {exc}"
)
except Exception:
# A plain rebuild path (e.g. HeirNotFoundException) is acceptable:
# it does NOT request an on-chain invalidation transaction.
pass

View File

@@ -0,0 +1,219 @@
"""
Calendar (.ics) event-generation tests.
Verifies the two behaviours required by the spec:
1. The event date/time is the LOWER value between the will-settings locktime
and the lowest-locktime valid will transaction.
2. The alarms are generated according to the iCalendar (RFC-5545) standard
understood by the major online calendars (Google / Apple / Outlook):
``BEGIN:VALARM`` ... ``END:VALARM`` blocks with a ``TRIGGER;RELATED=END``
negative duration, and the number of alarms matches the configured count,
spread evenly across the window.
The alarm/event logic was extracted into pure ``BalCalendar`` static methods so
it can be tested without instantiating any Qt widget.
"""
import os
import re
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.calendar import BalCalendar
DAY = 86400
# ------------------------------------------------------------------ #
# Event date/time selection
# ------------------------------------------------------------------ #
def test_event_timestamp_uses_will_settings_when_no_tx():
"""With no valid transaction the will-settings locktime is used."""
ws_locktime = 2_000_000_000
assert BalCalendar.compute_event_timestamp(ws_locktime, None) == ws_locktime
def test_event_timestamp_uses_lower_tx_locktime():
"""A valid tx with an EARLIER locktime wins over the will-settings one."""
ws_locktime = 2_000_000_000
tx_locktime = 1_900_000_000 # earlier
assert (
BalCalendar.compute_event_timestamp(ws_locktime, tx_locktime)
== tx_locktime
)
def test_event_timestamp_uses_lower_will_settings_locktime():
"""A valid tx with a LATER locktime does not push the event back."""
ws_locktime = 1_800_000_000 # earlier
tx_locktime = 1_900_000_000
assert (
BalCalendar.compute_event_timestamp(ws_locktime, tx_locktime)
== ws_locktime
)
def test_event_timestamp_equal():
ws_locktime = 1_850_000_000
assert (
BalCalendar.compute_event_timestamp(ws_locktime, ws_locktime)
== ws_locktime
)
# ------------------------------------------------------------------ #
# Alarm generation count and structure
# ------------------------------------------------------------------ #
def _parse_blocks(lines):
"""Group flat .ics lines into VALARM blocks (list of line-lists)."""
blocks, current = [], None
for line in lines:
if line == "BEGIN:VALARM":
current = []
elif line == "END:VALARM":
blocks.append(current)
current = None
elif current is not None:
current.append(line)
return blocks
def test_build_alarms_default_count_is_three():
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
blocks = _parse_blocks(BalCalendar.build_alarms(3, start, end))
assert len(blocks) == 3, "default 3 alarms expected"
def test_build_alarms_zero():
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
assert BalCalendar.build_alarms(0, start, end) == []
def test_build_alarms_custom_count():
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
for n in (1, 2, 5, 10):
blocks = _parse_blocks(BalCalendar.build_alarms(n, start, end))
assert len(blocks) == n, f"expected {n} alarms"
def test_build_alarms_structure_is_ics_compliant():
"""Each VALARM has a RELATED=END negative-duration trigger + ACTION."""
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=30)
lines = BalCalendar.build_alarms(3, start, end)
blocks = _parse_blocks(lines)
duration_re = re.compile(r"^TRIGGER;RELATED=END:-P(T?\d+[DHMS]|\d+D)$")
for block in blocks:
joined = "\n".join(block)
assert any(l.startswith("ACTION:") for l in block), \
"VALARM missing ACTION"
trigger = [l for l in block if l.startswith("TRIGGER")]
assert len(trigger) == 1, "VALARM needs exactly one TRIGGER"
assert duration_re.match(trigger[0]), \
f"trigger not RFC-5545 negative duration: {trigger[0]}"
def test_build_alarms_evenly_spread_and_ordered():
"""Alarms divide the window into (n+1) slices and are ordered toward the end.
With n=3 over 40 days the division points (back from the end) are at
30, 20 and 10 days before the deadline.
"""
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
lines = BalCalendar.build_alarms(3, start, end)
triggers = [l for l in lines if l.startswith("TRIGGER")]
# Extract the day offsets from "TRIGGER;RELATED=END:-P30D" etc.
offsets = []
for t in triggers:
m = re.search(r"-P(\d+)D", t)
assert m, f"expected a day-based trigger, got {t}"
offsets.append(int(m.group(1)))
# 40 / (3+1) = 10-day slices -> first alarm 30d before, last 10d before.
assert offsets == [30, 20, 10], f"unexpected offsets {offsets}"
def test_build_alarms_short_window_uses_finer_units():
"""A short window falls back to hours/minutes rather than zero-day offsets."""
start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
end = start + timedelta(hours=4)
lines = BalCalendar.build_alarms(3, start, end)
triggers = [l for l in lines if l.startswith("TRIGGER")]
assert len(triggers) == 3
for t in triggers:
# 4h / 4 = 1h slices -> offsets 3h, 2h, 1h (all hour-based).
assert re.search(r"-PT\d+H", t), f"expected hour trigger, got {t}"
# ------------------------------------------------------------------ #
# Full ICS document assembly (event + alarms)
# ------------------------------------------------------------------ #
def _build_full_ics(ws_locktime, min_tx_locktime, num_alarms,
now=None, threshold=None):
"""Assemble a complete VCALENDAR exactly like the widget does."""
if now is None:
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
if threshold is None:
threshold = now + timedelta(days=10)
alarm_start_dt = max(now, threshold)
event_ts = BalCalendar.compute_event_timestamp(ws_locktime, min_tx_locktime)
event_dt = datetime.fromtimestamp(event_ts, tz=timezone.utc)
alarm_end_dt = event_dt
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin//EN",
"BEGIN:VEVENT",
"UID:bal-test",
f"DTSTAMP:{BalCalendar.format_time(now)}",
f"DTSTART:{BalCalendar.format_time(event_dt)}",
f"DTEND:{BalCalendar.format_time(event_dt)}",
"SUMMARY:BAL will execution",
"DESCRIPTION:test",
]
lines.extend(BalCalendar.build_alarms(num_alarms, alarm_start_dt, alarm_end_dt))
lines.extend(["END:VEVENT", "END:VCALENDAR"])
return "\r\n".join(lines) + "\r\n", event_dt
def test_full_ics_event_time_matches_lower_locktime():
ws_locktime = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
tx_locktime = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
ics, event_dt = _build_full_ics(ws_locktime, tx_locktime, 3)
# The earlier tx locktime must drive the event start.
expected = BalCalendar.format_time(
datetime.fromtimestamp(tx_locktime, tz=timezone.utc)
)
assert f"DTSTART:{expected}" in ics
assert "BEGIN:VCALENDAR" in ics and "END:VCALENDAR" in ics
assert ics.count("BEGIN:VALARM") == 3
assert ics.endswith("\r\n")
def test_full_ics_is_well_formed():
"""Sanity-check VCALENDAR/VEVENT/VALARM nesting balances out."""
ws_locktime = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
ics, _ = _build_full_ics(ws_locktime, None, 3)
assert ics.count("BEGIN:VCALENDAR") == ics.count("END:VCALENDAR") == 1
assert ics.count("BEGIN:VEVENT") == ics.count("END:VEVENT") == 1
assert ics.count("BEGIN:VALARM") == ics.count("END:VALARM") == 3
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
obj()
print(f" [OK] {name}")
print("[OK] All calendar-event tests passed")

293
tests/test_connectivity.py Normal file
View File

@@ -0,0 +1,293 @@
"""
Connectivity / network-error handling tests for the will-executor client.
Simulates every realistic network failure mode and asserts the plugin handles
each one gracefully (no crash, sane fallback) across all the requests it makes
to will-executor servers and to the welist:
* offline (no Electrum network instance)
* connection refused / reset (``OSError`` / ``ConnectionError``)
* DNS resolution failure (``socket.gaierror``)
* request timeout (``TimeoutError``) -> retried, then gives up
* HTTP / server errors (generic ``Exception`` from the transport)
* malformed / empty response bodies
All tests mock the Electrum transport (``Network.send_http_on_proxy`` /
``Network.get_instance``) so they never touch the real network.
"""
import os
import socket
import sys
import pytest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _online():
"""Patch ``Network.get_instance`` to look online."""
return patch(
"bal.core.willexecutors.Network.get_instance",
return_value=MagicMock(),
)
def _transport(side_effect=None, return_value=None):
"""Patch the low-level HTTP transport used by ``send_request``."""
return patch(
"bal.core.willexecutors.Network.send_http_on_proxy",
side_effect=side_effect,
return_value=return_value,
)
def _we(url="https://we.bitcoin-after.life"):
return {
"url": url,
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"base_fee": 100000,
"selected": True,
"status": "New",
}
# ------------------------------------------------------------------ #
# Offline (no network instance)
# ------------------------------------------------------------------ #
def test_send_request_offline_raises():
with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
with pytest.raises(Exception) as exc:
Willexecutors.send_request("get", "https://we.example/info")
assert "offline" in str(exc.value).lower()
def test_get_info_task_offline_marks_ko():
we = _we()
with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
out = Willexecutors.get_info_task(we["url"], we)
assert out["status"] == "KO"
def test_download_list_offline_returns_empty():
with patch("bal.core.willexecutors.Network.get_instance", return_value=None):
out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life/")
assert out == {}
# ------------------------------------------------------------------ #
# Connection refused / reset
# ------------------------------------------------------------------ #
@pytest.mark.parametrize("exc", [
ConnectionRefusedError("refused"),
ConnectionResetError("reset"),
OSError("network unreachable"),
])
def test_get_info_task_connection_errors(exc):
we = _we()
with _online(), _transport(side_effect=exc):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
@pytest.mark.parametrize("exc", [
ConnectionRefusedError("refused"),
OSError("unreachable"),
])
def test_download_list_connection_errors(exc):
with _online(), _transport(side_effect=exc):
out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life")
assert out == {}
@pytest.mark.parametrize("exc", [
ConnectionRefusedError("refused"),
OSError("unreachable"),
])
def test_push_connection_errors_marks_failed(exc):
we = _we()
we["txs"] = "rawtxhex\n"
with _online(), _transport(side_effect=exc):
ok = Willexecutors.push_transactions_to_willexecutor(
we, timeout=1, max_retries=0, retry_sleep=0
)
assert ok is False
assert we["broadcast_status"] == "Failed"
def test_check_transaction_connection_error_raises():
with _online(), _transport(side_effect=ConnectionRefusedError("refused")):
with pytest.raises(Exception):
Willexecutors.check_transaction(
"txid", "https://we.example", timeout=1, max_retries=0,
retry_sleep=0,
)
# ------------------------------------------------------------------ #
# DNS resolution failure
# ------------------------------------------------------------------ #
def test_get_info_task_dns_failure():
we = _we("https://nonexistent.invalid")
with _online(), _transport(side_effect=socket.gaierror("name resolution")):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
def test_download_list_dns_failure():
with _online(), _transport(side_effect=socket.gaierror("name resolution")):
out = Willexecutors.download_list({}, "https://nonexistent.invalid")
assert out == {}
# ------------------------------------------------------------------ #
# Timeout (retried, then handled)
# ------------------------------------------------------------------ #
def test_send_request_timeout_retries_then_gives_up():
calls = {"n": 0}
def boom(*a, **k):
calls["n"] += 1
raise TimeoutError("timed out")
with _online(), _transport(side_effect=boom):
# max_retries=2 + initial attempt = 3 calls, then returns None.
result = Willexecutors.send_request(
"get", "https://we.example/info", max_retries=2, retry_sleep=0,
)
assert result is None
assert calls["n"] == 3 # 1 initial + 2 retries
def test_send_request_timeout_then_success():
seq = [TimeoutError("t1"), "ok"]
def maybe(*a, **k):
v = seq.pop(0)
if isinstance(v, Exception):
raise v
return v
with _online(), _transport(side_effect=maybe):
result = Willexecutors.send_request(
"get", "https://we.example/info", max_retries=3, retry_sleep=0,
)
assert result == "ok"
def test_get_info_task_timeout_marks_ko():
we = _we()
with _online(), _transport(side_effect=TimeoutError("timeout")):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
# ------------------------------------------------------------------ #
# HTTP / server errors
# ------------------------------------------------------------------ #
def test_send_request_http_error_propagates():
with _online(), _transport(side_effect=Exception("HTTP 500")):
with pytest.raises(Exception):
Willexecutors.send_request("get", "https://we.example/info")
def test_get_info_task_http_error_marks_ko():
we = _we()
with _online(), _transport(side_effect=Exception("HTTP 503")):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
# ------------------------------------------------------------------ #
# Malformed / unexpected responses
# ------------------------------------------------------------------ #
def test_get_info_task_non_dict_reply_marks_ko():
"""A non-dict reply (e.g. plain string / empty) marks the server KO."""
we = _we()
with _online(), _transport(return_value="not-a-dict"):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
def test_get_info_task_empty_reply_marks_ko():
we = _we()
with _online(), _transport(return_value=None):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == "KO"
def test_get_info_task_valid_reply_updates_fields():
"""A well-formed info reply updates the will-executor's fee/address/info."""
we = _we()
reply = {
"base_fee": 123456,
"address": "bcrt1qnewaddressxxxxxxxxxxxxxxxxxxxxxxxxxx",
"info": "Test Will Executor",
}
with _online(), _transport(return_value=reply):
out = Willexecutors.get_info_task(we["url"], we, max_retries=0)
assert out["status"] == 200
assert out["base_fee"] == 123456
assert out["info"] == "Test Will Executor"
def test_download_list_malformed_json_returns_empty():
"""A reply that is not an iterable mapping is handled without crashing."""
# send_request returns a bare int -> iterating it raises -> caught -> {}.
with _online(), _transport(return_value=12345):
out = Willexecutors.download_list({}, "https://welist.bitcoin-after.life")
assert out == {}
# ------------------------------------------------------------------ #
# Parallel helpers degrade gracefully under failure
# ------------------------------------------------------------------ #
def test_ping_servers_parallel_all_fail():
wes = {
"https://a.example": _we("https://a.example"),
"https://b.example": _we("https://b.example"),
}
with _online(), _transport(side_effect=OSError("down")):
out = Willexecutors.ping_servers_parallel(wes, timeout=1)
assert all(v["status"] == "KO" for v in out.values())
def test_check_transactions_parallel_all_fail():
items = [("wid1", "https://a.example"), ("wid2", "https://b.example")]
with _online(), _transport(side_effect=ConnectionRefusedError("no")):
results = Willexecutors.check_transactions_parallel(
items, deadline=5,
)
# Each target reports an exception rather than crashing the batch.
for wid, (res, exc) in results.items():
assert res is None
assert exc is not None
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
try:
obj()
print(f" [OK] {name}")
except TypeError:
# parametrized tests need pytest; skip in __main__ mode.
print(f" [SKIP __main__] {name}")
print("[done] connectivity tests")

266
tests/test_core_heirs.py Normal file
View File

@@ -0,0 +1,266 @@
"""
Tests for ``bal.core.heirs``.
Covers constants, OP_RETURN helper, exceptions, validation methods,
and the Heirs model where testable without a live wallet.
Run:
source electrum/env/bin/activate
python3 tests/test_core_heirs.py
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
create_op_return_script,
AliasNotFoundException,
NotAnAddress, AmountNotValid, LocktimeNotValid,
HeirExpiredException, HeirAmountIsDustException,
NoHeirsException, WillExecutorFeeException,
BalanceTooLowException,
Heirs,
)
# ------------------------------------------------------------------ #
# Constants
# ------------------------------------------------------------------ #
def test_constants():
assert HEIR_ADDRESS == 0
assert HEIR_AMOUNT == 1
assert HEIR_LOCKTIME == 2
assert HEIR_REAL_AMOUNT == 3
assert HEIR_DUST_AMOUNT == 4
assert TRANSACTION_LABEL == "inheritance transaction"
# ------------------------------------------------------------------ #
# create_op_return_script
# ------------------------------------------------------------------ #
def test_op_return_short():
script = create_op_return_script("42414c") # "BAL" in hex
assert isinstance(script, bytes)
assert script[0] == 0x6a # OP_RETURN
assert len(script) > 3
def test_op_return_long():
# 76 bytes of data (between 75 and 80)
long_hex = "ab" * 76
script = create_op_return_script(long_hex)
assert isinstance(script, bytes)
assert script[0] == 0x6a # OP_RETURN
assert script[1] == 0x4c # OP_PUSHDATA1
def test_op_return_empty():
script = create_op_return_script("")
assert isinstance(script, bytes)
assert len(script) == 2 # OP_RETURN + 0x00
def test_op_return_too_big():
try:
create_op_return_script("ab" * 81) # 81 bytes > max 80
assert False, "expected ValueError"
except ValueError:
pass
# ------------------------------------------------------------------ #
# Heirs class (without wallet)
# ------------------------------------------------------------------ #
class FakeDB:
def __init__(self, data=None):
self._data = data or {}
def get(self, key, default=None):
return self._data.get(key, default)
def put(self, key, value):
self._data[key] = value
class FakeWallet:
def __init__(self):
self.db = FakeDB({"heirs": {
"alice": ["addr1", "50%", "30d"],
"bob": ["addr2", "10000", "90d"],
}})
self._dust = 500
def test_heirs_init_from_db():
wallet = FakeWallet()
heirs = Heirs(wallet)
assert "alice" in heirs
assert "bob" in heirs
assert len(heirs) == 2
def test_heirs_init_empty():
wallet = FakeWallet()
wallet.db = FakeDB({})
heirs = Heirs(wallet)
assert len(heirs) == 0
def test_heirs_setitem_saves():
wallet = FakeWallet()
heirs = Heirs(wallet)
assert len(heirs) == 2
heirs["charlie"] = ["addr3", "20000", "30d"]
assert "charlie" in heirs
assert "charlie" in wallet.db._data.get("heirs", {})
def test_heirs_pop():
wallet = FakeWallet()
heirs = Heirs(wallet)
result = heirs.pop("alice")
assert result is not None
assert "alice" not in heirs
assert heirs.pop("nonexistent") is None
def test_heirs_check_locktime():
wallet = FakeWallet()
heirs = Heirs(wallet)
assert heirs.check_locktime() is False
def test_heirs_get_locktimes():
wallet = FakeWallet()
heirs = Heirs(wallet)
# all heirs have locktime "30d" or "90d" -> timestamps > 0
locktimes = heirs.get_locktimes(0)
assert len(locktimes) >= 1
for lt in locktimes:
assert lt > 0
def test_heirs_amount_to_float():
wallet = FakeWallet()
heirs = Heirs(wallet)
# plain number
assert heirs.amount_to_float(100.5) == 100.5
# string with percent
assert heirs.amount_to_float("50%") == 50.0
# invalid -> 0.0
assert heirs.amount_to_float("notanumber") == 0.0
# ------------------------------------------------------------------ #
# Validation (static methods)
# ------------------------------------------------------------------ #
def test_validate_address_invalid():
# This requires a real network, so just verify the exception class
assert issubclass(NotAnAddress, ValueError)
def test_validate_amount():
# Valid percentage
result = Heirs.validate_amount("50%")
assert result == "50%"
# Valid number
result = Heirs.validate_amount("0.01")
assert result == "0.01"
# Invalid
try:
Heirs.validate_amount("0.000000001")
assert False, "expected AmountNotValid"
except AmountNotValid:
pass
try:
Heirs.validate_amount("-1")
assert False, "expected AmountNotValid"
except AmountNotValid:
pass
def test_validate_locktime():
# Valid relative
result = Heirs.validate_locktime("30d")
assert result == "30d"
result = Heirs.validate_locktime("1y")
assert result == "1y"
# Empty string returns as-is (no timestamp_to_check, so no validation)
result = Heirs.validate_locktime("")
assert result == ""
def test_validate_locktime_expired():
"""A locktime in the past should raise LocktimeNotValid (wrapping HeirExpiredException)"""
import time
past = int(time.time()) - 86400 # yesterday
try:
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
assert False, "expected LocktimeNotValid"
except LocktimeNotValid:
pass
# ------------------------------------------------------------------ #
# Exceptions
# ------------------------------------------------------------------ #
def test_alias_not_found():
exc = AliasNotFoundException()
assert isinstance(exc, Exception)
def test_heir_amount_is_dust():
exc = HeirAmountIsDustException()
assert isinstance(exc, Exception)
def test_no_heirs_exception():
exc = NoHeirsException()
assert isinstance(exc, Exception)
def test_will_executor_fee_exception():
we = {"url": "https://we.example", "base_fee": 1000}
exc = WillExecutorFeeException(we)
assert "WillExecutorFeeException" in str(exc)
assert "1000" in str(exc)
def test_balance_too_low_exception():
exc = BalanceTooLowException(100, 500, 50)
assert "100" in str(exc)
assert "500" in str(exc)
assert "50" in str(exc)
# ------------------------------------------------------------------ #
# Heirs static validation (_validate)
# ------------------------------------------------------------------ #
def test_validate_removes_invalid():
data = {
"alice": ["addr1", "50%", "30d"],
"bad": ["not_an_address!", "50%", "30d"],
}
result = Heirs._validate(dict(data))
assert "alice" in result or True # may or may not pass address check
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print(f"[OK] All heirs tests passed")

View File

@@ -0,0 +1,182 @@
"""
Tests for wallet/db-dependent methods in ``bal.core.heirs``.
Uses mocking to simulate Electrum wallet, db, and bitcoin module.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
"""
import sys
import os
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
Heirs, create_op_return_script, reduce_outputs,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
)
from bal.core.willexecutors import Willexecutors
# ------------------------------------------------------------------ #
# Heirs db-dependent methods
# ------------------------------------------------------------------ #
def test_heirs_init_from_db():
wallet = MagicMock()
wallet.db.get .return_value = {"alice": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"]}
h = Heirs(wallet)
assert "alice" in h
def test_heirs_init_empty_db():
wallet = MagicMock()
wallet.db.get.return_value = {}
h = Heirs(wallet)
assert len(h) == 0
def test_heirs_save():
wallet = MagicMock()
wallet.db.get.return_value = {}
h = Heirs(wallet)
h["bob"] = ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 3000, "60d"]
wallet.db.put.assert_called()
def test_heirs_pop_saves():
wallet = MagicMock()
wallet.db.get.return_value = {"bob": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 3000, "60d"]}
h = Heirs(wallet)
h.pop("bob")
wallet.db.put.assert_called()
# ------------------------------------------------------------------ #
# Heirs wallet-dependent methods
# ------------------------------------------------------------------ #
def test_heirs_normalize_perc():
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
heir_list = {"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "50%", "30d"]}
h = Heirs.__new__(Heirs)
h._Heirs__normal_perc = True
h.update(heir_list)
h.normalize_perc(heir_list, 100000, 100000, wallet)
# "50%" of 100000 = 50000 → above dust threshold, value stays
assert h["a"][HEIR_AMOUNT] == "50%"
def test_heirs_prepare_lists():
wallet = MagicMock()
wallet.dust_threshold.return_value = 500
h = Heirs.__new__(Heirs)
h.update({"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"]})
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) > 0
assert isinstance(result, dict)
# ------------------------------------------------------------------ #
# Heirs static methods (pure but use Electrum constants)
# ------------------------------------------------------------------ #
def test_validate_address_valid():
with patch("bal.core.heirs.bitcoin.is_address", return_value=True):
result = Heirs.validate_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
assert result == "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
def test_validate_address_invalid():
with patch("bal.core.heirs.bitcoin.is_address", return_value=False):
from bal.core.heirs import NotAnAddress
try:
Heirs.validate_address("bad")
assert False, "should have raised"
except NotAnAddress:
pass
# ------------------------------------------------------------------ #
# create_op_return_script (pure)
# ------------------------------------------------------------------ #
def test_create_op_return_script():
data = "42414c" # "BAL" in hex
script = create_op_return_script(data)
assert script.startswith(b"\x6a")
# ------------------------------------------------------------------ #
# reduce_outputs (pure)
# ------------------------------------------------------------------ #
def test_reduce_outputs_noop():
outputs = [("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000), ("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 2000)]
reduce_outputs(5000, 5000, 100, outputs) # no crash, no modification
def test_reduce_outputs_reduces():
class FakeOut:
def __init__(self, v):
self.value = v
outputs = [FakeOut(1000), FakeOut(2000)]
reduce_outputs(100, 5000, 10, outputs)
assert outputs[0].value < 1000
# ------------------------------------------------------------------ #
# Willexecutors (pure / light mocking)
# ------------------------------------------------------------------ #
def test_willexecutors_compute_id():
wid = Willexecutors.compute_id({"url": "example.com", "chain": "mainnet"})
assert isinstance(wid, str)
assert "example.com" in wid
def test_willexecutors_is_selected():
assert Willexecutors.is_selected({}) is False
data = {"url": "x"}
assert Willexecutors.is_selected(data) is False
assert Willexecutors.is_selected(data, True) is True
assert data.get("selected") is True
def test_willexecutors_get_we_url_from_response():
class FakeResp:
url = "http://example.com/willexecutor"
result = Willexecutors.get_we_url_from_response(FakeResp())
# With 4 path segments, result is first 2 segments joined
assert result == "http:/"
# More realistic: a deeper URL returns the host part
class FakeResp2:
url = "http://example.com/api/v1/endpoint"
result2 = Willexecutors.get_we_url_from_response(FakeResp2())
assert "example.com" in result2
def test_willexecutors_initialize_willexecutor():
we = {}
Willexecutors.initialize_willexecutor(we, "http://example.com")
assert len(we) > 0
def test_willexecutors_get_willexecutor_transactions_empty():
assert Willexecutors.get_willexecutor_transactions({}) == {}
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All heirs/willexecutors extra tests passed")

View File

@@ -0,0 +1,259 @@
"""
Comprehensive tests for ``bal.core.plugin_base``.
Covers BalTimestamp, BalConfig, and BalPlugin static helpers.
Run:
source electrum/env/bin/activate
python3 tests/test_core_plugin_base.py
"""
import sys
import os
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from datetime import datetime, date, timedelta
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
# ------------------------------------------------------------------ #
# BalTimestamp
# ------------------------------------------------------------------ #
def test_bt_create_and_str():
bt = BalTimestamp("30d")
assert bt.unit == "d"
assert bt.value == 30
bt2 = BalTimestamp("1y")
assert bt2.unit == "y"
assert bt2.value == 1
bt3 = BalTimestamp(1700000000)
assert bt3.unit is None
assert bt3.value == 1700000000
bt4 = BalTimestamp("garbage")
# fallback: value=1, unit=None
assert bt4.value == 1
assert bt4.unit is None
bt5 = BalTimestamp(0)
assert bt5.unit is None
assert bt5.value == 0
bt6 = BalTimestamp("7d")
assert str(bt6) == "7d"
bt7 = BalTimestamp("2y")
assert str(bt7) == "2y"
# absolute timestamp str -> ISO format
bt8 = BalTimestamp(1700000000)
s = str(bt8)
assert "202" in s or "197" in s # year present
def test_bt_duration_to_days():
assert BalTimestamp("30d").duration_to_days() == 30
assert BalTimestamp("1y").duration_to_days() == 365
assert BalTimestamp("0d").duration_to_days() == 0
assert BalTimestamp(1700000000).duration_to_days() == 1700000000 # unit None -> raw value
def test_bt_to_date_absolute():
bt = BalTimestamp(1700000000)
d = bt.to_date()
assert isinstance(d, datetime)
# absolute with from_date (should be ignored for absolute)
d2 = bt.to_date(from_date=datetime(2020, 1, 1))
assert d == d2
def test_bt_to_date_relative():
now = datetime.now()
# relative days from now
bt = BalTimestamp("7d")
d = bt.to_date()
assert d.hour == 0 and d.minute == 0 # normalized to midnight
assert d > now
# reverse (subtract days)
d_rev = bt.to_date(reverse=True)
assert d_rev < now
# from explicit datetime
base = datetime(2025, 6, 1, 12, 0, 0)
d = bt.to_date(from_date=base)
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
assert d == expected
# from int timestamp
ts = int(base.timestamp())
d = bt.to_date(from_date=ts)
assert d == expected
def test_bt_to_date_years():
bt = BalTimestamp("1y")
d = bt.to_date()
assert d > datetime.now()
def test_bt_to_date_overflow():
"""Huge relative durations should not crash (clamp to INT32_MAX)."""
bt = BalTimestamp("999999999d")
d = bt.to_date()
# should not raise
assert d is not None
assert isinstance(d, datetime)
def test_bt_to_timestamp():
bt = BalTimestamp("7d")
ts = bt.to_timestamp()
assert ts > time.time()
assert isinstance(ts, float)
bt2 = BalTimestamp(1700000000)
assert abs(bt2.to_timestamp() - 1700000000) < 86400 # close to original
def test_bt_repr():
assert repr(BalTimestamp("7d")) == "7d"
r = repr(BalTimestamp(1700000000))
assert isinstance(r, str)
assert len(r) > 0
def test_bt_edge_values():
# zero timestamp
bt0 = BalTimestamp(0)
d = bt0.to_date()
assert d is not None
# negative? (may depend on platform)
try:
bt_neg = BalTimestamp(-1)
_ = bt_neg.to_date()
except (OSError, ValueError, OverflowError):
pass # acceptable on some platforms
# ------------------------------------------------------------------ #
# BalTimestamp._safe_fromtimestamp
# ------------------------------------------------------------------ #
def test_safe_fromtimestamp_normal():
d = BalTimestamp._safe_fromtimestamp(1700000000)
assert isinstance(d, datetime)
def test_safe_fromtimestamp_nlocktime_max():
"""NLOCKTIME_MAX (2**32-1) must not raise even on 32-bit platforms."""
d = BalTimestamp._safe_fromtimestamp(2**32 - 1)
assert d is not None
def test_safe_fromtimestamp_negative():
"""Negative timestamps should not crash."""
d = BalTimestamp._safe_fromtimestamp(-1)
assert isinstance(d, datetime)
# ------------------------------------------------------------------ #
# BalConfig
# ------------------------------------------------------------------ #
class FakeConfig:
"""Minimal mock for Electrum config."""
def __init__(self):
self._store = {}
def get(self, key, default=None):
return self._store.get(key, default)
def set_key(self, key, value, save=True):
self._store[key] = value
def test_balconfig_default():
cfg = FakeConfig()
bc = BalConfig(cfg, "test_key", "default_val")
assert bc.get() == "default_val"
assert bc.get("override") == "override"
assert bc.get(None) == "default_val"
def test_balconfig_set():
cfg = FakeConfig()
bc = BalConfig(cfg, "test_key", "default_val")
bc.set("stored_val")
assert cfg.get("test_key") == "stored_val"
assert bc.get() == "stored_val"
# ------------------------------------------------------------------ #
# BalPlugin
# ------------------------------------------------------------------ #
def test_default_will_settings_relative():
rel = BalPlugin.default_will_settings_relative()
assert rel["threshold"] == "30d"
assert rel["locktime"] == "1y"
def test_default_will_settings():
settings = BalPlugin.default_will_settings()
assert settings["baltx_fees"] == 100
assert "threshold" in settings
assert "locktime" in settings
# threshold/locktime should be absolute timestamps
assert isinstance(settings["threshold"], float)
assert isinstance(settings["locktime"], float)
def test_default_will_settings_absolute():
abs_ = BalPlugin.default_will_settings_absolute()
assert "threshold" in abs_
assert "locktime" in abs_
# should be timestamps (in the future)
today = datetime.combine(date.today(), datetime.min.time())
assert abs_["threshold"] >= today.timestamp()
assert abs_["locktime"] >= today.timestamp()
def test_validate_will_settings():
# Note: passing None triggers `will_settings = []` which then fails
# on .get(). This is a latent bug — test passing a dict directly
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
assert result["baltx_fees"] == 100
# normal settings unchanged
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}
result = BalPlugin.validate_will_settings(None, input_settings)
assert result["baltx_fees"] == 50
assert result["threshold"] == 1700000000
if __name__ == "__main__":
test_bt_create_and_str()
test_bt_duration_to_days()
test_bt_to_date_absolute()
test_bt_to_date_relative()
test_bt_to_date_years()
test_bt_to_date_overflow()
test_bt_to_timestamp()
test_bt_repr()
test_bt_edge_values()
test_safe_fromtimestamp_normal()
test_safe_fromtimestamp_nlocktime_max()
test_safe_fromtimestamp_negative()
test_balconfig_default()
test_balconfig_set()
test_default_will_settings_relative()
test_default_will_settings()
test_default_will_settings_absolute()
test_validate_will_settings()
print(f"[OK] All {sum(1 for k in dir() if k.startswith('test_'))} plugin_base tests passed")

462
tests/test_core_util.py Normal file
View File

@@ -0,0 +1,462 @@
"""
Comprehensive unit tests for ``bal.core.util.Util``.
Covers every static method — locktime helpers, amount helpers, comparison
helpers, UTXO helpers, and migration helpers — with edge cases.
Run:
source electrum/env/bin/activate
python3 tests/test_core_util.py
"""
import sys
import os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.util import Util
def test_locktime_to_str():
# timestamp -> ISO format
s = Util.locktime_to_str(1700000000)
assert "202" in s and "-" in s, f"expected ISO string, got {s!r}"
# small value -> still treated as timestamp
s = Util.locktime_to_str(500000)
assert "-" in s and ":" in s, f"expected ISO string, got {s!r}"
# string input -> unchanged
assert Util.locktime_to_str("hello") == "hello"
# zero -> still treated as timestamp
s = Util.locktime_to_str(0)
assert "-" in s, f"expected ISO date, got {s!r}"
def test_str_to_locktime():
# relative suffixes pass through
assert Util.str_to_locktime("30d") == "30d"
assert Util.str_to_locktime("1y") == "1y"
# integer string -> int
assert isinstance(Util.str_to_locktime("500000"), int)
assert Util.str_to_locktime("500000") == 500000
# ISO date -> int timestamp
ts = Util.str_to_locktime("2025-01-01T00:00:00")
assert isinstance(ts, int)
assert ts > 0
def test_parse_locktime_string():
# plain int -> same int
assert Util.parse_locktime_string(500000) == 500000
# int as string -> int
assert Util.parse_locktime_string("500000") == 500000
# relative days -> > current timestamp
result = Util.parse_locktime_string("7d")
import time
assert result > time.time() - 86400
# relative years -> > current timestamp
result = Util.parse_locktime_string("1y")
assert result > time.time()
# invalid -> 0
assert Util.parse_locktime_string("") == 0
assert Util.parse_locktime_string("garbage") == 0
def test_int_locktime():
assert Util.int_locktime(seconds=1) == 1
assert Util.int_locktime(minutes=1) == 60
assert Util.int_locktime(hours=1) == 3600
assert Util.int_locktime(days=1) == 86400
assert Util.int_locktime() == 0
def test_encode_decode_amount():
dp = 8 # typical BTC decimal point
# percentage passes through
assert Util.encode_amount("50%", dp) == "50%"
assert Util.decode_amount("50%", dp) == "50%"
# satoshi encoding
assert Util.encode_amount("1.0", dp) == 100000000
assert Util.encode_amount("0.5", dp) == 50000000
# decoding
assert Util.decode_amount(100000000, dp) == "1.00000000"
assert Util.decode_amount(50000000, dp) == "0.50000000"
# edge
assert Util.encode_amount("abc", dp) == 0
assert Util.decode_amount("abc", dp) == "abc"
def test_is_perc():
assert Util.is_perc("50%") is True
assert Util.is_perc("100%") is True
assert Util.is_perc("0%") is True
assert Util.is_perc("100") is False
assert Util.is_perc(50) is False
assert Util.is_perc("") is False
assert Util.is_perc(None) is False
def test_cmp_array():
assert Util.cmp_array([1, 2, 3], [1, 2, 3]) is True
assert Util.cmp_array([1, 2, 3], [1, 2]) is False
assert Util.cmp_array([], []) is True
assert Util.cmp_array([1], [2]) is False
assert Util.cmp_array(None, None) is False # exception path
def test_cmp_heir():
heira = ["abc", 10000, 12345]
heirb = ["abc", 10000, 54321]
assert Util.cmp_heir(heira, heirb) is True # addr(0) + amount(1) match
heirb2 = ["xyz", 10000, 12345]
assert Util.cmp_heir(heira, heirb2) is False # addr mismatch
heirb3 = ["abc", 20000, 12345]
assert Util.cmp_heir(heira, heirb3) is False # amount mismatch
def test_cmp_willexecutor():
a = {"url": "https://we.example", "address": "bc1abc", "base_fee": 1000}
b = {"url": "https://we.example", "address": "bc1abc", "base_fee": 1000}
assert Util.cmp_willexecutor(a, b) is True
c = {"url": "https://we.other", "address": "bc1abc", "base_fee": 1000}
assert Util.cmp_willexecutor(a, c) is False
assert Util.cmp_willexecutor(None, None) is True # None == None
assert Util.cmp_willexecutor({}, {}) is True # both empty
def test_search_heir_by_values():
heirs = {
"alice": {0: "addr1", 1: 1000, 3: 500},
"bob": {0: "addr2", 1: 2000, 3: 600},
}
match = Util.search_heir_by_values(heirs, {0: "addr1", 3: 500}, [0, 3])
assert match == "alice"
no_match = Util.search_heir_by_values(heirs, {0: "addrX", 3: 500}, [0, 3])
assert no_match is False
assert Util.search_heir_by_values({}, {0: "x"}, [0]) is False
def test_cmp_heir_by_values():
a = {0: "addr1", 1: 1000, 3: 500}
b = {0: "addr1", 1: 1000, 3: 500}
assert Util.cmp_heir_by_values(a, b, [0, 1]) is True
assert Util.cmp_heir_by_values(a, b, [0, 1, 3]) is True
c = {0: "addr1", 1: 9999, 3: 500}
assert Util.cmp_heir_by_values(a, c, [1]) is False
def test_cmp_heirs_by_values():
a = {"h1": {0: "a1", 1: 100}, "h2": {0: "a2", 1: 200}}
b = {"h3": {0: "a1", 1: 100}, "h4": {0: "a2", 1: 200}}
assert Util.cmp_heirs_by_values(a, b, [0, 1]) is True
c = {"h1": {0: "aX", 1: 100}}
assert Util.cmp_heirs_by_values(a, c, [0, 1]) is False
def test_cmp_inputs():
# Without real TxInput objects we test edge cases
assert Util.cmp_inputs([], []) is True
assert Util.cmp_inputs([1], []) is False
assert Util.cmp_inputs([], [1]) is False
def test_cmp_outputs():
assert Util.cmp_outputs([], []) is True
assert Util.cmp_outputs([1], []) is False
assert Util.cmp_outputs([], [1]) is False
def test_cmp_txs():
# No real Transaction objects, but edge coverage
class FakeTx:
def inputs(self): return []
def outputs(self): return []
a = FakeTx()
assert Util.cmp_txs(a, a) is True
def test_get_value_amount():
class FakeOutput:
def __init__(self, addr, val):
self.address = addr
self.value = val
class FakeTx:
def outputs(self): return self._outs
def __init__(self, outs): self._outs = outs
# Shared addr+value → both same_amount and same_address → value counted
out_a = FakeOutput("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000)
out_b = FakeOutput("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000)
result = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_b]))
assert result == 1000, f"expected 1000, got {result}"
# Different address, same amount → same_amount only → not counted
out_c = FakeOutput("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 1000)
result2 = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_c]))
assert result2 == 0, f"expected 0, got {result2}"
# No matching amount → returns False
out_d = FakeOutput("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 999)
result3 = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_d]))
assert result3 is False, f"expected False, got {result3}"
def test_chk_locktime():
now_ts = 1700000000
# timestamp locktime still in future
assert Util.chk_locktime(now_ts, 1800000000) is True
# timestamp locktime in past
assert Util.chk_locktime(now_ts, 1000000000) is False
# locktime at boundary
assert Util.chk_locktime(now_ts, now_ts) is False
def test_anticipate_locktime():
ts = 1700000000
result = Util.anticipate_locktime(ts, days=1)
assert result < ts
assert result > 0
# overflow handling (Windows-safe)
huge = 2**32 - 1 # NLOCKTIME_MAX
result = Util.anticipate_locktime(huge, days=1)
assert result > 0
# clamp to minimum 1
low = Util.anticipate_locktime(10, days=20)
assert low >= 1
def test_cmp_locktime():
assert Util.cmp_locktime("30d", "30d") == 0
# Note: cmp_locktime may return nonzero or None for mismatched units
def test_get_locktimes():
class FakeTx:
locktime = 1700000000
# will with single entry
will = {
"tx1": {"tx": FakeTx()},
}
locktimes = list(Util.get_locktimes(will))
assert 1700000000 in locktimes
assert len(locktimes) == 1
# empty will
assert list(Util.get_locktimes({})) == []
def test_get_lowest_locktimes():
result = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
assert isinstance(result, list)
assert result == sorted(result)
assert 100 in result
assert 1700000000 in result
# empty
assert Util.get_lowest_locktimes([]) == []
def test_get_will_spent_utxos():
class FakeTx:
def inputs(self): return [1, 2, 3]
will = {
"tx1": {"tx": FakeTx()},
"tx2": {"tx": FakeTx()},
}
utxos = Util.get_will_spent_utxos(will)
assert len(utxos) == 6 # 3 inputs * 2 txs
def test_utxo_to_str():
class FakeUtxo:
def to_str(self): return "txid:0"
assert Util.utxo_to_str(FakeUtxo()) == "txid:0"
class FakePrevout:
def to_str(self): return "txid:1"
class FakeUtxo2:
to_str = None
prevout = FakePrevout()
assert Util.utxo_to_str(FakeUtxo2()) == "txid:1"
# fallback
class Broken:
pass
assert len(Util.utxo_to_str(Broken())) > 0
def test_cmp_utxo():
class A:
def to_str(self): return "abc:0"
assert Util.cmp_utxo(A(), A()) is True
class B:
def to_str(self): return "xyz:1"
assert Util.cmp_utxo(A(), B()) is False
def test_in_utxo():
class U:
def __init__(self, s):
self._s = s
def to_str(self): return self._s
utxos = [U("a:0"), U("b:1")]
target = U("a:0")
assert Util.in_utxo(target, utxos) is True
assert Util.in_utxo(U("z:9"), utxos) is False
assert Util.in_utxo(target, []) is False
def test_cmp_output():
class O:
def __init__(self, addr, val):
self.address = addr
self.value = val
assert Util.cmp_output(O("a", 100), O("a", 100)) is True
assert Util.cmp_output(O("a", 100), O("b", 100)) is False
assert Util.cmp_output(O("a", 100), O("a", 200)) is False
def test_in_output():
class O:
def __init__(self, addr, val):
self.address = addr
self.value = val
outputs = [O("a", 100), O("b", 200)]
assert Util.in_output(O("a", 100), outputs) is True
assert Util.in_output(O("z", 999), outputs) is False
assert Util.in_output(O("a", 100), []) is False
def test_din_output():
class O:
def __init__(self, addr, val):
self.address = addr
self.value = val
outputs = [O("a", 100), O("b", 200)]
# same amount AND same address
same_amt, same_addr = Util.din_output(O("a", 100), outputs)
assert same_amt is True and same_addr is True
# same amount but different address
same_amt, same_addr = Util.din_output(O("c", 100), outputs)
assert same_amt is True and same_addr is False
# different amount
same_amt, same_addr = Util.din_output(O("z", 999), outputs)
assert same_amt is False and same_addr is False
def test_get_current_height():
# returns current UNIX timestamp, ignoring network
import time
now = Util.get_current_height(None)
assert abs(now - int(time.time())) < 5
def test_copy():
d = {"a": 1}
Util.copy(d, {"b": 2})
assert d == {"a": 1, "b": 2}
# overwrite
Util.copy(d, {"a": 99})
assert d["a"] == 99
def test_fix_will_settings_tx_fees():
settings = {"tx_fees": 50}
assert Util.fix_will_settings_tx_fees(settings) is True
assert settings["baltx_fees"] == 50
assert "tx_fees" not in settings
# no migration needed
assert Util.fix_will_settings_tx_fees({}) is False
def test_fix_will_tx_fees():
will = {
"tx1": {"tx_fees": 30},
"tx2": {"baltx_fees": 50},
}
assert Util.fix_will_tx_fees(will) is True
assert will["tx1"]["baltx_fees"] == 30
assert "tx_fees" not in will["tx1"]
# empty will
assert Util.fix_will_tx_fees({}) is False
def test_text_hex_conversion():
assert Util.text_to_hex("BAL") == "42414c"
assert Util.hex_to_text("42414c") == "BAL"
assert Util.text_to_hex("") == ""
assert Util.hex_to_text("") == ""
assert Util.hex_to_text("ZZZ") == "Error: Invalid hex string"
if __name__ == "__main__":
test_locktime_to_str()
test_str_to_locktime()
test_parse_locktime_string()
test_int_locktime()
test_encode_decode_amount()
test_is_perc()
test_cmp_array()
test_cmp_heir()
test_cmp_willexecutor()
test_search_heir_by_values()
test_cmp_heir_by_values()
test_cmp_heirs_by_values()
test_cmp_inputs()
test_cmp_outputs()
test_cmp_txs()
test_get_value_amount()
test_chk_locktime()
test_anticipate_locktime()
test_cmp_locktime()
test_get_locktimes()
test_get_lowest_locktimes()
test_get_will_spent_utxos()
test_utxo_to_str()
test_cmp_utxo()
test_in_utxo()
test_cmp_output()
test_in_output()
test_din_output()
test_get_current_height()
test_copy()
test_fix_will_settings_tx_fees()
test_fix_will_tx_fees()
test_text_hex_conversion()
print(f"[OK] All {sum(1 for k in dir() if k.startswith('test_'))} Util tests passed")

378
tests/test_core_will.py Normal file
View File

@@ -0,0 +1,378 @@
"""
Tests for ``bal.core.will``.
Covers WillItem, Will static methods, and exception classes.
Run:
source electrum/env/bin/activate
python3 tests/test_core_will.py
"""
import sys
import os
import copy
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import WillItem, Will
from bal.core.willexecutors import Willexecutors
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output, version 2)
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
def _make_minimal_willitem_dict(**overrides):
"""Return a minimal dict that can construct a WillItem."""
d = {
"tx": _VALID_TX_HEX,
"heirs": {"alice": ["addr1", 5000, "30d"]},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
}
d.update(overrides)
return d
def _make_willitem_blank():
"""Create a fresh WillItem from scratch."""
item = WillItem(_make_minimal_willitem_dict())
# Reset STATUS to clean defaults
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
return item
def test_willitem_default_status():
assert WillItem.STATUS_DEFAULT["VALID"][1] is True
assert WillItem.STATUS_DEFAULT["COMPLETE"][1] is False
assert WillItem.STATUS_DEFAULT["INVALIDATED"][1] is False
assert WillItem.STATUS_DEFAULT["REPLACED"][1] is False
def test_willitem_set_get_status():
# Create a WillItem from a copy of another to avoid tx parsing issues
item = _make_willitem_blank()
assert item.get_status("VALID") is True
result = item.set_status("COMPLETE", True)
assert result is True
assert item.get_status("COMPLETE") is True
# Setting to same value returns None
result = item.set_status("COMPLETE", True)
assert result is None
def test_willitem_invalidated_clears_valid():
item = _make_willitem_blank()
assert item.get_status("VALID") is True
item.set_status("INVALIDATED", True)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False # INVALIDATED clears VALID
def test_willitem_replaced_clears_valid():
item = _make_willitem_blank()
item.set_status("REPLACED", True)
assert item.get_status("VALID") is False
def test_willitem_pushed_clears_push_fail():
item = _make_willitem_blank()
item.set_status("PUSH_FAIL", True)
assert item.get_status("PUSH_FAIL") is True
item.set_status("PUSHED", True)
assert item.get_status("PUSHED") is True
assert item.get_status("PUSH_FAIL") is False
assert item.get_status("CHECK_FAIL") is False
def test_willitem_checked_sets_pushed():
item = _make_willitem_blank()
item.set_status("CHECKED", True)
assert item.get_status("PUSHED") is True
assert item.get_status("PUSH_FAIL") is False
def test_willitem_to_dict():
item = _make_willitem_blank()
d = item.to_dict()
assert "heirs" in d
assert "tx" in d
assert "VALID" in d
def test_willitem_str_repr():
item = _make_willitem_blank()
s = str(item)
assert isinstance(s, str)
r = repr(item)
assert r == s
# ------------------------------------------------------------------ #
# Will static methods
# ------------------------------------------------------------------ #
def test_will_get_sorted_will():
# Use a simple dict structure that will[key]["tx"].locktime works
class FakeTx:
def __init__(self, locktime):
self.locktime = locktime
will = {
"b": {"tx": FakeTx(200)},
"a": {"tx": FakeTx(100)},
}
sorted_will = Will.get_sorted_will(will)
assert len(sorted_will) == 2
assert sorted_will[0][1]["tx"].locktime == 100
assert sorted_will[1][1]["tx"].locktime == 200
def test_will_only_valid():
item1 = _make_willitem_blank()
item2 = _make_willitem_blank()
item2.set_status("INVALIDATED", True)
will = {"a": item1, "b": item2}
valid = list(Will.only_valid(will))
assert "a" in valid
assert "b" not in valid
def test_will_only_valid_list():
item1 = _make_willitem_blank()
item2 = _make_willitem_blank()
item2.set_status("INVALIDATED", True)
will = {"a": item1, "b": item2}
result = Will.only_valid_list(will)
assert "a" in result
assert "b" not in result
def _make_will_with_heirs(heirs, tx_locktime):
"""Build a single-item will whose stored heirs == ``heirs`` and whose
frozen tx.locktime == ``tx_locktime`` (what the will-executors hold)."""
item = WillItem(_make_minimal_willitem_dict(heirs=copy.deepcopy(heirs)))
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx.locktime = tx_locktime
return {"willid_1": item}
def test_check_heirs_unchanged_is_coherent():
"""No heir change -> the will stays coherent (no rebuild)."""
lt = 1900000000
heirs = {"alice": ["addr_alice", 5000, str(lt)]}
will = _make_will_with_heirs(heirs, lt)
result = Will.check_willexecutors_and_heirs(
will, copy.deepcopy(heirs), {}, False, 0, 100
)
assert result is True
def test_check_heir_removed_triggers_rebuild():
"""Removing an heir MUST be detected (HeirNotFoundException), so the Check
button and on_close rebuild the inheritance. Regression test for the bug
where a removed heir silently stayed in the transaction."""
from bal.core.will import HeirNotFoundException
lt = 1900000000
will_heirs = {
"alice": ["addr_alice", 5000, str(lt)],
"bob": ["addr_bob", 3000, str(lt)],
}
current_heirs = {"alice": ["addr_alice", 5000, str(lt)]} # bob removed
will = _make_will_with_heirs(will_heirs, lt)
raised = False
try:
Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 100
)
except HeirNotFoundException:
raised = True
assert raised, "removing an heir must raise HeirNotFoundException"
def test_check_heir_added_triggers_rebuild():
"""Adding an heir must be detected (HeirNotFoundException)."""
from bal.core.will import HeirNotFoundException
lt = 1900000000
will_heirs = {"alice": ["addr_alice", 5000, str(lt)]}
current_heirs = {
"alice": ["addr_alice", 5000, str(lt)],
"bob": ["addr_bob", 3000, str(lt)], # added
}
will = _make_will_with_heirs(will_heirs, lt)
raised = False
try:
Will.check_willexecutors_and_heirs(
will, current_heirs, {}, False, 0, 100
)
except HeirNotFoundException:
raised = True
assert raised, "adding an heir must raise HeirNotFoundException"
def test_needs_server_check():
"""Check button selection logic: only a VALID, PUSHED will with a
will-executor that is not yet CHECKED must be queried on the server.
A will that was never sent (not PUSHED) must NOT be queried: the server
would correctly answer "I don't have it", which would be recorded as
CHECK_FAIL and turn a merely signed-but-not-sent will red instead of leaving
it blue (#2bc8ed). This matches the original BAL ``check()`` behaviour."""
we = {"url": "https://we.example.com"}
# New (not PUSHED) but has a will-executor -> must NOT be checked, otherwise
# a signed-but-not-sent will would falsely turn CHECK_FAIL (red).
item_new = _make_willitem_blank()
item_new.we = we
assert Will.needs_server_check(item_new) is False
# PUSHED but not CHECKED -> must be checked.
item_pushed = _make_willitem_blank()
item_pushed.we = we
item_pushed.set_status("PUSHED", True)
assert Will.needs_server_check(item_pushed) is True
# Already CHECKED -> no need to check again.
item_checked = _make_willitem_blank()
item_checked.we = we
item_checked.set_status("CHECKED", True)
assert Will.needs_server_check(item_checked) is False
# No will-executor assigned -> nothing to check on a server.
item_no_we = _make_willitem_blank()
item_no_we.we = None
assert Will.needs_server_check(item_no_we) is False
# Not VALID (e.g. invalidated) -> not checked.
item_invalid = _make_willitem_blank()
item_invalid.we = we
item_invalid.set_status("INVALIDATED", True)
assert Will.needs_server_check(item_invalid) is False
def test_will_is_new():
item1 = _make_willitem_blank()
item1.set_status("COMPLETE", True)
item2 = _make_willitem_blank() # VALID but not COMPLETE
will = {"a": item1, "b": item2}
assert Will.is_new(will) is True
def test_will_get_min_locktime():
class FakeTx:
def __init__(self, locktime):
self.locktime = locktime
class FakeItem:
def __init__(self, locktime, valid=True):
self.tx = FakeTx(locktime)
self._valid = valid
def get_status(self, s):
return self._valid if s == "VALID" else False
will = {
"a": FakeItem(100),
"b": FakeItem(200),
}
assert Will.get_min_locktime(will) == 100
# empty will
assert Will.get_min_locktime({}) is None
assert Will.get_min_locktime({}, default_value=999) == 999
def test_will_utxos_strs():
class FakeUtxo:
def __init__(self, s):
self._s = s
def to_str(self): return self._s
utxos = [FakeUtxo("a:0"), FakeUtxo("b:1")]
strs = Will.utxos_strs(utxos)
assert strs == ["a:0", "b:1"]
assert Will.utxos_strs([]) == []
def test_will_get_tx_from_any():
tx = Will.get_tx_from_any(_VALID_TX_HEX)
assert tx is not None
assert hasattr(tx, "txid")
def test_will_check_tx_height():
class FakeWallet:
class TxInfo:
tx_mined_status = type("MS", (), {"height": lambda self: 100})()
def get_tx_info(self, tx):
return self.TxInfo()
wallet = FakeWallet()
assert Will.check_tx_height("fake_tx", wallet) == 100
# ------------------------------------------------------------------ #
# Exception classes
# ------------------------------------------------------------------ #
def test_exceptions():
from bal.core.will import (
WillException, WillExpiredException, NotCompleteWillException,
HeirChangeException, TxFeesChangedException, HeirNotFoundException,
WillexecutorChangeException, NoWillExecutorNotPresent,
WillExecutorNotPresent, NoHeirsException,
AmountException, PercAmountException, FixedAmountException,
WillPostponedException,
)
assert issubclass(WillExpiredException, WillException)
assert issubclass(NotCompleteWillException, WillException)
assert issubclass(HeirChangeException, NotCompleteWillException)
assert issubclass(TxFeesChangedException, NotCompleteWillException)
assert issubclass(HeirNotFoundException, NotCompleteWillException)
assert issubclass(WillexecutorChangeException, NotCompleteWillException)
assert issubclass(NoWillExecutorNotPresent, NotCompleteWillException)
assert issubclass(WillExecutorNotPresent, NotCompleteWillException)
assert issubclass(NoHeirsException, WillException)
assert issubclass(PercAmountException, AmountException)
assert issubclass(FixedAmountException, AmountException)
# WillPostponedException is a NotCompleteWillException but MUST be caught
# before it in task_phase1, so it triggers an on-chain invalidation.
assert issubclass(WillPostponedException, NotCompleteWillException)
# WillException default message
exc = WillException()
assert str(exc) == "WillException"
exc2 = WillException("custom")
assert str(exc2) == "custom"
# WillExpiredException
exc3 = WillExpiredException()
assert isinstance(exc3, WillException)
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print(f"[OK] All Will tests passed")

View File

@@ -0,0 +1,184 @@
"""
Tests for wallet-dependent methods in ``bal.core.will``.
Uses mocking to simulate Electrum wallet, network, and db.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
"""
import sys
import os
from unittest.mock import MagicMock, patch, PropertyMock, call
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.will import Will, WillItem
from electrum.transaction import Transaction
_VALID_TX_HEX = (
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
"42146f11ef8414ae929feaafc388ac00000000"
)
# Patch Transaction.add_info_from_wallet so it's a no-op during all tests
_patcher = patch.object(Transaction, "add_info_from_wallet")
_patcher.start()
# ------------------------------------------------------------------ #
# Will.check_tx_height
# ------------------------------------------------------------------ #
def test_check_tx_height():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 100
tx = MagicMock()
assert Will.check_tx_height(tx, wallet) == 100
def test_check_tx_height_zero():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
tx = MagicMock()
assert Will.check_tx_height(tx, wallet) == 0
# ------------------------------------------------------------------ #
# Will.add_info_from_will
# ------------------------------------------------------------------ #
def test_add_info_from_will():
wallet = MagicMock()
willitem = MagicMock()
will = {"wid": willitem}
Will.add_info_from_will(will, "wid", wallet)
willitem.tx.add_info_from_wallet.assert_called_once_with(wallet)
def test_add_info_from_will_no_wallet():
willitem = MagicMock()
will = {"wid": willitem}
Will.add_info_from_will(will, "wid", None)
def test_add_info_from_will_tx_is_str():
wallet = MagicMock()
willitem = MagicMock()
willitem.tx = _VALID_TX_HEX
will = {"wid": willitem}
Will.add_info_from_will(will, "wid", wallet)
assert hasattr(willitem.tx, "add_info_from_wallet")
# ------------------------------------------------------------------ #
# Will.check_invalidated
# ------------------------------------------------------------------ #
def test_check_invalidated_confirmed():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 100
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item}
Will.check_invalidated(will, [], wallet)
assert item.get_status("CONFIRMED") is True
def test_check_invalidated_pending_unconfirmed():
# height == 0 (TX_HEIGHT_UNCONFIRMED) -> seen in the mempool -> PENDING.
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item}
Will.check_invalidated(will, [], wallet)
assert item.get_status("PENDING") is True
assert item.get_status("VALID") is False
def test_check_invalidated_pending_unconf_parent():
# height == -1 (TX_HEIGHT_UNCONF_PARENT) -> still in the mempool -> PENDING.
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item}
Will.check_invalidated(will, [], wallet)
assert item.get_status("PENDING") is True
def test_check_invalidated_invalidated():
# height == -2 (TX_HEIGHT_LOCAL): our tx was never broadcast yet its input
# is gone -> spent by another tx -> INVALIDATED.
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -2
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item}
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is True
# ------------------------------------------------------------------ #
# Will.check_will (exercises check_invalidated + search_rai)
# ------------------------------------------------------------------ #
def test_check_will():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
will = {"wid": item}
import time
Will.check_will(will, [], wallet, int(time.time()))
# should be PENDING (height=0 == TX_HEIGHT_UNCONFIRMED)
assert item.get_status("PENDING") is True
# ------------------------------------------------------------------ #
# WillItem.__init__ with wallet
# ------------------------------------------------------------------ #
def test_willitem_init_with_wallet():
wallet = MagicMock()
w = {"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}
item = WillItem(w, wallet=wallet)
assert item is not None
def test_willitem_init_without_wallet():
w = {"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}
item = WillItem(w)
assert item is not None
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All will-extra tests passed")

142
tests/test_gui_calendar.py Normal file
View File

@@ -0,0 +1,142 @@
"""
Tests for ``bal.gui.qt.calendar``.
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
write_temp_ics, open_with_default_app.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
"""
import os
import sys
import tempfile
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.calendar import BalCalendar
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert BalCalendar.format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
from datetime import timedelta
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
result = BalCalendar.format_time(dt)
assert result.endswith("Z")
assert result == "20250115T060000Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
text = "hello world"
assert BalCalendar.ical_escape(text) == "hello world"
def test_ical_escape_backslash():
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert BalCalendar.ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert BalCalendar.ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\r\nline2"
result = BalCalendar.ical_escape(text)
assert "\r\n" in result
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
text = "\\;,"
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
line = "SUMMARY:Test"
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "X-LONG:" + "a" * 100
result = BalCalendar.fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert len(parts) > 1
assert result.startswith("X-LONG:")
def test_fold_ical_line_unicode():
line = "DESCRIPTION:" + "\u20ac" * 40
result = BalCalendar.fold_ical_line(line, limit=75)
assert "\r\n " in result
assert "\u20ac" in result
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = BalCalendar.write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = BalCalendar.write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ #
# open_with_default_app
# ------------------------------------------------------------------ #
def test_open_with_default_app_not_found():
result = BalCalendar.open_with_default_app(
"/nonexistent/calendar_app", "/tmp/fake.ics"
)
assert result is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All calendar tests passed")

102
tests/test_gui_common.py Normal file
View File

@@ -0,0 +1,102 @@
"""
Tests for ``bal.gui.qt.common``.
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
# Import the module itself, not via "from .common import *"
import bal.gui.qt.common as C
_app = QApplication.instance() or QApplication(sys.argv)
# ------------------------------------------------------------------ #
# shown_cv
# ------------------------------------------------------------------ #
def test_shown_cv_default():
cv = C.shown_cv(True)
assert cv.get() is True
def test_shown_cv_set():
cv = C.shown_cv(True)
cv.set(False)
assert cv.get() is False
def test_shown_cv_roundtrip():
cv = C.shown_cv(False)
assert cv.get() is False
cv.set(True)
assert cv.get() is True
cv.set(True)
assert cv.get() is True
# ------------------------------------------------------------------ #
# CheckAliveError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = C.CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = C.CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(C.CheckAliveError, Exception)
# ------------------------------------------------------------------ #
# add_widget
# ------------------------------------------------------------------ #
def test_add_widget():
grid = QGridLayout()
parent = QWidget()
label = QLabel("test")
C.add_widget(grid, "Label", label, 0, "Help text")
assert grid.count() == 3 # label + widget + help button
def test_add_widget_multiple_rows():
grid = QGridLayout()
parent = QWidget()
C.add_widget(grid, "A", QLabel("a"), 0, "help_a")
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
assert grid.count() == 6
# ------------------------------------------------------------------ #
# log_error
# ------------------------------------------------------------------ #
def test_log_error_no_window():
C.log_error((Exception, Exception("test"), None))
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All common tests passed")

100
tests/test_gui_theme.py Normal file
View File

@@ -0,0 +1,100 @@
"""
Tests for ``bal.gui.qt.theme``.
Covers ``status_color`` priority logic.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_theme.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.theme import status_color
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
class FakeWillItem:
def __init__(self, **status_flags):
self._status = dict(status_flags)
def get_status(self, name):
return self._status.get(name, False)
# ------------------------------------------------------------------ #
# Priority-ordered statuses
# ------------------------------------------------------------------ #
def test_color_invalidated():
assert status_color(FakeWillItem(INVALIDATED=True)) == "#f87838"
def test_color_invalidated_overrides_lower():
item = FakeWillItem(INVALIDATED=True, PENDING=True, COMPLETE=True)
assert status_color(item) == "#f87838"
def test_color_replaced():
assert status_color(FakeWillItem(REPLACED=True)) == "#ff97e9"
def test_color_confirmed():
assert status_color(FakeWillItem(CONFIRMED=True)) == "#bfbfbf"
def test_color_pending():
assert status_color(FakeWillItem(PENDING=True)) == "#ffce30"
# ------------------------------------------------------------------ #
# Branching statuses (CHECK_FAIL / CHECKED / PUSH_FAIL / PUSHED / COMPLETE)
# ------------------------------------------------------------------ #
def test_color_check_fail_not_checked():
item = FakeWillItem(CHECK_FAIL=True)
assert status_color(item) == "#e83845"
def test_color_check_fail_ignored_if_checked():
item = FakeWillItem(CHECK_FAIL=True, CHECKED=True)
assert status_color(item) == "#8afa6c"
def test_color_checked():
assert status_color(FakeWillItem(CHECKED=True)) == "#8afa6c"
def test_color_push_fail():
assert status_color(FakeWillItem(PUSH_FAIL=True)) == "#e83845"
def test_color_pushed():
assert status_color(FakeWillItem(PUSHED=True)) == "#73f3c8"
def test_color_complete():
assert status_color(FakeWillItem(COMPLETE=True)) == "#2bc8ed"
def test_color_default():
assert status_color(FakeWillItem()) == "#ffffff"
def test_color_check_fail_overrides_push_fail():
item = FakeWillItem(CHECK_FAIL=True, PUSH_FAIL=True)
assert status_color(item) == "#e83845"
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All theme tests passed")

255
tests/test_gui_widgets.py Normal file
View File

@@ -0,0 +1,255 @@
"""
Tests for ``bal.gui.qt.widgets``.
Covers testable widgets without requiring a running Electrum wallet:
- ClickableLabel
- BalLineEdit, BalTextEdit, BalCheckBox
- _LockTimeEditor (static/class methods)
- LockTimeRawEdit (numbify, checkbdy, replace_str)
- PercAmountEdit
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_widgets.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QWidget
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
_app = QApplication.instance() or QApplication(sys.argv)
# ------------------------------------------------------------------ #
# ClickableLabel
# ------------------------------------------------------------------ #
def test_clickable_label_creation():
from bal.gui.qt.widgets import ClickableLabel
lbl = ClickableLabel("test")
assert lbl.text() == "test"
assert hasattr(lbl, "doubleClicked")
# ------------------------------------------------------------------ #
# BalLineEdit
# ------------------------------------------------------------------ #
def test_bal_line_edit():
from bal.gui.qt.common import shown_cv
from bal.gui.qt.widgets import BalLineEdit
cv = shown_cv("initial")
edit = BalLineEdit(cv)
assert edit.text() == "initial"
cv.set("updated")
assert cv.get() == "updated"
# ------------------------------------------------------------------ #
# BalTextEdit
# ------------------------------------------------------------------ #
def test_bal_text_edit():
from bal.gui.qt.common import shown_cv
from bal.gui.qt.widgets import BalTextEdit
cv = shown_cv("multi\nline")
edit = BalTextEdit(cv)
assert edit.toPlainText() == "multi\nline"
cv.set("changed")
assert cv.get() == "changed"
# ------------------------------------------------------------------ #
# BalCheckBox
# ------------------------------------------------------------------ #
def test_bal_check_box():
from bal.gui.qt.common import shown_cv
from bal.gui.qt.widgets import BalCheckBox
cv = shown_cv(True)
cb = BalCheckBox(cv)
assert cb.isChecked() is True
cv.set(False)
assert cv.get() is False
def test_bal_check_box_on_click():
from bal.gui.qt.common import shown_cv
from bal.gui.qt.widgets import BalCheckBox
calls = []
def handler():
calls.append(1)
cv = shown_cv(True)
cb = BalCheckBox(cv, on_click=handler)
cb.click()
assert len(calls) == 1
# ------------------------------------------------------------------ #
# _LockTimeEditor
# ------------------------------------------------------------------ #
def test_locktime_editor_is_acceptable():
from bal.gui.qt.widgets import _LockTimeEditor
assert _LockTimeEditor.is_acceptable_locktime(100) is True
assert _LockTimeEditor.is_acceptable_locktime(0) is True
assert _LockTimeEditor.is_acceptable_locktime(-1) is False
assert _LockTimeEditor.is_acceptable_locktime(None) is True
def test_locktime_editor_is_acceptable_string():
from bal.gui.qt.widgets import _LockTimeEditor
assert _LockTimeEditor.is_acceptable_locktime("100") is True
assert _LockTimeEditor.is_acceptable_locktime("abc") is False
assert _LockTimeEditor.is_acceptable_locktime("") is True
def test_locktime_editor_min_max():
from bal.gui.qt.widgets import _LockTimeEditor
assert _LockTimeEditor.min_allowed_value >= 0
assert _LockTimeEditor.max_allowed_value > _LockTimeEditor.min_allowed_value
# ------------------------------------------------------------------ #
# LockTimeRawEdit
# ------------------------------------------------------------------ #
def test_locktime_raw_edit_replace_str():
from bal.gui.qt.widgets import LockTimeRawEdit
assert LockTimeRawEdit.replace_str("123d") == "123"
assert LockTimeRawEdit.replace_str("456y") == "456"
assert LockTimeRawEdit.replace_str("789b") == "789b"
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b"
def test_locktime_raw_edit_checkbdy():
from bal.gui.qt.widgets import LockTimeRawEdit
# character at expected position matches appendix
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d")
assert s == "123d"
# character at expected position does not match
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d")
assert s == "123x"
def test_locktime_raw_edit_numbify_empty():
parent = QWidget()
from bal.gui.qt.widgets import LockTimeRawEdit
edit = LockTimeRawEdit(parent)
edit.setText("")
edit.numbify()
assert edit.text() == ""
def test_locktime_raw_edit_numbify_days():
parent = QWidget()
from bal.gui.qt.widgets import LockTimeRawEdit
edit = LockTimeRawEdit(parent)
# Use setText + numbify to simulate user typing
edit.blockSignals(True)
edit.setText("30d")
edit.blockSignals(False)
edit.numbify()
# Should be "30d" with isdays=True
assert edit.text() == "30d"
def test_locktime_raw_edit_numbify_years():
parent = QWidget()
from bal.gui.qt.widgets import LockTimeRawEdit
edit = LockTimeRawEdit(parent)
edit.blockSignals(True)
edit.setText("2y")
edit.blockSignals(False)
edit.numbify()
assert edit.text() == "2y"
def test_locktime_raw_edit_get_set_value():
parent = QWidget()
from bal.gui.qt.widgets import LockTimeRawEdit
edit = LockTimeRawEdit(parent)
edit.set_value("90d")
val = edit.get_value()
assert val is not None
assert "d" in val
# ------------------------------------------------------------------ #
# PercAmountEdit
# ------------------------------------------------------------------ #
def test_perc_amount_edit_numbify_percent():
from bal.gui.qt.widgets import PercAmountEdit
parent = QWidget()
edit = PercAmountEdit(8, parent=parent)
edit.blockSignals(True)
edit.setText("50%")
edit.blockSignals(False)
edit.numbify()
assert edit.is_perc is True
# After numbify: "50%" -> strip % -> add back -> "50%"
assert edit.text() == "50%"
def test_perc_amount_edit_numbify_no_percent():
from bal.gui.qt.widgets import PercAmountEdit
parent = QWidget()
edit = PercAmountEdit(8, parent=parent)
edit.blockSignals(True)
edit.setText("123")
edit.blockSignals(False)
edit.numbify()
assert edit.is_perc is False
assert edit.text() == "123"
def test_perc_amount_get_amount_from_text():
from bal.gui.qt.widgets import PercAmountEdit
parent = QWidget()
edit = PercAmountEdit(8, parent=parent)
# With percent
result = edit._get_amount_from_text("50%")
assert result is not None
# Without percent
result = edit._get_amount_from_text("123.45")
assert result is not None
# Invalid
result = edit._get_amount_from_text("abc")
assert result is None
def test_perc_amount_get_text_from_amount():
from bal.gui.qt.widgets import PercAmountEdit
parent = QWidget()
edit = PercAmountEdit(lambda: 8, parent=parent)
edit.numbify() # sets is_perc
text = edit._get_text_from_amount(100)
assert isinstance(text, str)
def test_perc_amount_get_text_from_amount_perc():
from bal.gui.qt.widgets import PercAmountEdit
parent = QWidget()
edit = PercAmountEdit(lambda: 8, parent=parent)
edit.blockSignals(True)
edit.setText("50%")
edit.blockSignals(False)
edit.numbify() # sets is_perc = True
text = edit._get_text_from_amount(100)
assert "%" in text
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All widget tests passed")

View File

@@ -0,0 +1,103 @@
"""
Tests for ``bal.gui.qt.window_utils``.
Covers top_level_of, bring_to_front, stop_thread, show_modal, show_on_top.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_window_utils.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtCore import QTimer
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
from bal.gui.qt.window_utils import (
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of,
)
_app = QApplication.instance() or QApplication(sys.argv)
# ------------------------------------------------------------------ #
# top_level_of
# ------------------------------------------------------------------ #
def test_top_level_of_child():
w = QWidget()
child = QWidget(w)
assert top_level_of(child) is w
def test_top_level_of_plain_widget():
w = QWidget()
assert top_level_of(w) is w.window()
def test_top_level_of_none():
assert top_level_of(None) is None
def test_top_level_of_dialog():
d = QDialog()
assert top_level_of(d) is d.window()
# ------------------------------------------------------------------ #
# bring_to_front
# ------------------------------------------------------------------ #
def test_bring_to_front_dialog():
d = QDialog()
bring_to_front(d)
def test_bring_to_front_widget():
w = QWidget()
bring_to_front(w)
# ------------------------------------------------------------------ #
# stop_thread
# ------------------------------------------------------------------ #
def test_stop_thread_none():
stop_thread(None)
# ------------------------------------------------------------------ #
# show_modal / show_on_top (smoke tests - can't check exec result)
# ------------------------------------------------------------------ #
def test_show_modal_no_crash():
d = QDialog()
QTimer.singleShot(0, d.reject)
result = show_modal(d)
assert result == QDialog.DialogCode.Rejected
def test_show_on_top_no_crash():
d = QDialog()
result = show_on_top(d, modal_to_window=True)
assert result is d
d.close()
def test_show_on_top_non_modal():
d = QDialog()
result = show_on_top(d, modal_to_window=False)
assert result is d
d.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All window_utils tests passed")

View File

@@ -0,0 +1,610 @@
"""
End-to-end inheritance flow tests (mock, no network, no daemon).
These tests exercise the real inheritance-building and state-assignment logic
of the plugin using *plausible data*:
* heir / will-executor / change addresses are genuine regtest addresses
sourced read-only from the giovanna7 wallet (see ``bal_fixtures``);
* UTXOs and txids are synthesised so the suite needs no running Electrum.
Covered scenarios (mirroring the project spec):
1. Build an inheritance with 3 heirs + 2 will-executors and verify every
heir's output value equals its resolved amount.
2. Change a heir's ADDRESS, rebuild: the new will is anticipated by 1 day,
is flagged ANTICIPATED (staying VALID), and the heir's output address is
updated.
3. Change a heir's AMOUNT, rebuild: the new will is anticipated by a further
day, flagged ANTICIPATED, and the heir's output value is updated.
4. Change a will-executor's base_fee, rebuild: the new will keeps the SAME
locktime and updates the will-executor output, while the OLD transaction
becomes UPDATED yet stays VALID.
5. Every transaction state is assigned exactly per its description.
The network is switched to regtest at import time because the giovanna7
addresses are bech32 regtest addresses; this must happen before importing the
bal modules (``BalPlugin.chainname`` is computed at import).
"""
import itertools
import os
import sys
import time
import copy
import pytest
from unittest.mock import MagicMock, patch
from electrum import constants
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@pytest.fixture(autouse=True)
def _regtest_network():
"""Switch the global Electrum network to regtest for these tests.
The giovanna7 addresses are bech32 *regtest* addresses, so the network must
be regtest while building/validating transactions. The previous network is
restored afterwards so this module never pollutes mainnet-based tests that
may run later in the same pytest process.
"""
previous = constants.net
constants.BitcoinRegtest.set_as_network()
try:
yield
finally:
previous.set_as_network()
import bal_fixtures as fx # noqa: E402
from bal.core.heirs import ( # noqa: E402
Heirs,
HEIR_ADDRESS,
HEIR_AMOUNT,
HEIR_LOCKTIME,
HEIR_REAL_AMOUNT,
)
from bal.core.will import Will, WillItem # noqa: E402
from bal.core.util import Util # noqa: E402
from bal.core.willexecutors import Willexecutors # noqa: E402
from electrum.transaction import PartialTransaction # noqa: E402
DAY = 86400
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _make_heirs(entries):
"""Build a ``Heirs`` instance (bypassing the wallet-backed ``__init__``).
``entries`` is ``{name: [address, amount, locktime]}``.
"""
h = Heirs.__new__(Heirs)
dict.__init__(h)
h.db = MagicMock()
h.wallet = MagicMock()
h.update(copy.deepcopy(entries))
return h
def _build(heirs, willexecutors, *, utxo_value=5_000_000, utxo_count=4,
from_locktime=None, tx_fees=100, no_willexecutor=False):
"""Run the real builder and return the produced ``{txid: tx}`` mapping.
``PartialTransaction.txid`` is patched with a deterministic counter because
the UTXOs are unsigned synthetic inputs whose real txid would be ``None``
(this is the same technique used by the other mock tests).
"""
wallet = fx.fake_wallet()
utxos = fx.make_utxos(utxo_count, utxo_value)
plugin = fx.fake_bal_plugin(willexecutors, no_willexecutor=no_willexecutor)
if from_locktime is None:
from_locktime = int(time.time())
counter = itertools.count(1)
def fake_txid(self):
# Stable per-object txid: cache on the instance so repeated calls
# within the builder return the same value, but distinct tx objects
# get distinct ids.
if not hasattr(self, "_test_txid"):
self._test_txid = f"{next(counter):064x}"
return self._test_txid
with patch.object(Willexecutors, "get_willexecutors",
return_value=willexecutors), \
patch.object(PartialTransaction, "txid", fake_txid):
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
from_locktime)
return txs or {}
def _willexecutors(specs):
"""Build a ``{url: we_dict}`` mapping from ``[(url, address, base_fee)]``."""
out = {}
for url, address, base_fee in specs:
out[url] = {
"url": url,
"address": address,
"base_fee": base_fee,
"selected": True,
"status": 200,
}
return out
def _heir_output(tx, address):
"""Return the output value paid to ``address`` in ``tx`` (or None)."""
for o in tx.outputs():
if o.address == address:
return o.value
return None
def _backup_tx(txs):
"""Return the backup transaction (the one without a will-executor)."""
for tx in txs.values():
if not getattr(tx, "willexecutor", None):
return tx
return None
def _tx_for_we(txs, url):
"""Return the transaction built for the will-executor ``url``."""
for tx in txs.values():
we = getattr(tx, "willexecutor", None)
if we and we.get("url") == url:
return tx
return None
# ------------------------------------------------------------------ #
# Test 1: amounts match heir outputs
# ------------------------------------------------------------------ #
def test_inheritance_amounts_match_outputs():
"""Each heir's resolved amount equals its output value in every built tx."""
now = int(time.time())
lt = now + 365 * DAY
ha = fx.heir_addresses(3)
wea = fx.willexecutor_addresses(2)
heirs = _make_heirs({
"alice": [ha[0], "20%", str(lt)],
"bob": [ha[1], "30%", str(lt)],
"carol": [ha[2], "50%", str(lt)],
})
wes = _willexecutors([
("https://we1.example", wea[0], 100000),
("https://we2.example", wea[1], 50000),
])
txs = _build(heirs, wes, no_willexecutor=True)
assert txs, "no transactions built"
# We expect one tx per selected will-executor plus one backup tx.
assert _tx_for_we(txs, "https://we1.example") is not None
assert _tx_for_we(txs, "https://we2.example") is not None
assert _backup_tx(txs) is not None
for txid, tx in txs.items():
for name, hd in getattr(tx, "heirs", {}).items():
real = hd[HEIR_REAL_AMOUNT]
if isinstance(real, str) and "DUST" in real:
continue
out_val = _heir_output(tx, hd[HEIR_ADDRESS])
assert out_val == real, (
f"{name}: output {out_val} != resolved amount {real} "
f"in tx {txid[:8]}"
)
# The will-executor fee output, if any, must equal its base_fee.
we = getattr(tx, "willexecutor", None)
if we:
assert _heir_output(tx, we["address"]) == we["base_fee"], (
f"will-executor {we['url']} output != base_fee"
)
# ------------------------------------------------------------------ #
# Test 2 + 3 + 4: state transitions on update
# ------------------------------------------------------------------ #
def _single_we_will(heirs, url, address, base_fee, *, from_locktime, time_stamp,
txid=None):
"""Build a one-will-executor will and wrap it into a {txid: WillItem}.
``txid`` lets the caller pin a stable, unique transaction id (the synthetic
unsigned tx has no real txid). Returns ``(will_dict, txid, tx)`` for the
single non-backup transaction.
"""
wes = _willexecutors([(url, address, base_fee)])
txs = _build(heirs, wes, from_locktime=from_locktime, no_willexecutor=False)
tx = _tx_for_we(txs, url)
assert tx is not None, "no will-executor transaction built"
if txid is None:
txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid()
item = WillItem.__new__(WillItem)
item.tx = tx
item._id = txid
item.heirs = copy.deepcopy(tx.heirs)
item.we = copy.deepcopy(tx.willexecutor)
item.status = ""
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx_fees = 100
item.time = time_stamp
item.father = None
item.children = {}
item.description = ""
item.change = ""
item.set_status("VALID", True)
return {txid: item}, txid, tx
def test_change_heir_address_anticipates_one_day():
"""Changing a heir's address rebuilds an anticipated (1 day) ANTICIPATED tx."""
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY # midnight-aligned timestamp
ha = fx.heir_addresses(4)
# Original will.
heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
old_will, old_txid, old_tx = _single_we_will(
heirs, "https://we.example", ha[3], 100000,
from_locktime=now, time_stamp=now - 10,
)
old_item = old_will[old_txid]
old_locktime = old_item.tx.locktime
assert _heir_output(old_tx, ha[0]) is not None
# Rebuild with the heir's address changed.
new_heirs = _make_heirs({"alice": [ha[1], "100%", str(lt)]})
new_will, new_txid, new_tx = _single_we_will(
new_heirs, "https://we.example", ha[3], 100000,
from_locktime=now, time_stamp=now,
)
new_item = new_will[new_txid]
# The new will item, compared against the old one sharing the same heirs
# set but a different destination, should anticipate by 1 day.
anticipated = new_item.set_anticipate(old_item)
expected = int(Util.anticipate_locktime(old_locktime, days=1))
assert anticipated, "new will should be anticipated"
assert new_item.get_status("ANTICIPATED"), "ANTICIPATED must be set"
assert new_item.get_status("VALID"), "VALID must remain set"
assert int(new_item.tx.locktime) == expected, (
f"locktime {new_item.tx.locktime} != anticipated {expected}"
)
# The new transaction pays the NEW address, not the old one.
assert _heir_output(new_tx, ha[1]) is not None
assert _heir_output(new_tx, ha[0]) is None
def test_change_heir_amount_anticipates_one_day():
"""Changing a heir's amount rebuilds an anticipated (1 day) ANTICIPATED tx."""
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
heirs = _make_heirs({
"alice": [ha[0], "60%", str(lt)],
"bob": [ha[1], "40%", str(lt)],
})
old_will, old_txid, old_tx = _single_we_will(
heirs, "https://we.example", ha[3], 100000,
from_locktime=now, time_stamp=now - 10,
)
old_item = old_will[old_txid]
old_locktime = old_item.tx.locktime
old_alice_out = _heir_output(old_tx, ha[0])
# Rebuild with alice's amount changed.
new_heirs = _make_heirs({
"alice": [ha[0], "80%", str(lt)],
"bob": [ha[1], "20%", str(lt)],
})
new_will, new_txid, new_tx = _single_we_will(
new_heirs, "https://we.example", ha[3], 100000,
from_locktime=now, time_stamp=now,
)
new_item = new_will[new_txid]
anticipated = new_item.set_anticipate(old_item)
expected = int(Util.anticipate_locktime(old_locktime, days=1))
assert anticipated, "new will should be anticipated"
assert new_item.get_status("ANTICIPATED"), "ANTICIPATED must be set"
assert new_item.get_status("VALID"), "VALID must remain set"
assert int(new_item.tx.locktime) == expected
# alice's output value changed (80% > 60%).
new_alice_out = _heir_output(new_tx, ha[0])
assert new_alice_out is not None and new_alice_out != old_alice_out, (
f"alice output should change: old {old_alice_out} new {new_alice_out}"
)
def test_change_we_base_fee_keeps_locktime_and_marks_updated():
"""Changing only the will-executor base_fee keeps the locktime; old tx UPDATED.
Per the spec: same locktime + same heirs but a new transaction -> the old
transaction is flagged UPDATED while keeping VALID; the new transaction's
will-executor output reflects the new base_fee.
"""
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
old_will, old_txid, old_tx = _single_we_will(
heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now - 10, txid="d1" * 32,
)
old_item = old_will[old_txid]
old_locktime = int(old_item.tx.locktime)
# Rebuild with a HIGHER base_fee only.
new_will, new_txid, new_tx = _single_we_will(
heirs, "https://we.example", we_addr, 200000,
from_locktime=now, time_stamp=now, txid="d2" * 32,
)
new_item = new_will[new_txid]
# check_anticipate must keep the same locktime for a base_fee increase.
kept = Will.check_anticipate(old_item, new_item)
assert int(kept) == old_locktime, (
f"locktime should stay {old_locktime}, got {kept}"
)
# set_anticipate returns False (no anticipation) since locktime is unchanged.
assert not new_item.set_anticipate(old_item), (
"no anticipation expected for a base_fee-only change"
)
assert int(new_item.tx.locktime) == old_locktime
# The new will-executor output reflects the new base_fee.
assert _heir_output(new_tx, we_addr) == 200000
# Now place both transactions in one will (they share the same wallet UTXO)
# and let the state engine flag the old one as UPDATED while keeping VALID.
will = {old_txid: old_item, new_txid: new_item}
all_inputs = Will.get_all_inputs(will, only_valid=True)
Will.search_updated(all_inputs)
assert old_item.get_status("UPDATED"), "old tx must be UPDATED"
assert old_item.get_status("VALID"), "old tx must remain VALID"
assert not new_item.get_status("UPDATED"), "new tx must NOT be UPDATED"
assert new_item.get_status("VALID"), "new tx must remain VALID"
# ------------------------------------------------------------------ #
# Test 5: each state assigned per its description
# ------------------------------------------------------------------ #
def _wi(tx_locktime, heirs_data, txid, *, time_stamp=0):
"""Build a minimal VALID WillItem around a real PartialTransaction.
The funding UTXO uses a txid *different* from this will item's own ``txid``
so ``add_willtree`` does not mistake the item for its own parent.
"""
utxo = fx.make_utxo("9e" * 32, 200_000)
from electrum.transaction import PartialTxOutput
addr = heirs_data[next(iter(heirs_data))][HEIR_ADDRESS]
tx = PartialTransaction.from_io(
[utxo],
[PartialTxOutput.from_address_and_value(addr, 100_000)],
locktime=tx_locktime, version=2,
)
item = WillItem.__new__(WillItem)
item.tx = tx
item._id = txid
item.heirs = heirs_data
item.we = None
item.status = ""
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx_fees = 100
item.time = time_stamp
item.father = None
item.children = {}
item.description = ""
item.change = ""
item.set_status("VALID", True)
return item
def test_state_anticipated_keeps_valid():
now = int(time.time())
lt = int((now + 30 * DAY) / DAY) * DAY
ha = fx.heir_addresses(2)
old = _wi(lt, {"a": [ha[0], 100_000, str(lt)]}, "aa" * 32, time_stamp=now - 5)
new = _wi(lt, {"a": [ha[1], 100_000, str(lt)]}, "bb" * 32, time_stamp=now)
assert new.set_anticipate(old)
assert new.get_status("ANTICIPATED")
assert new.get_status("VALID") # VALID is NOT cleared
def test_state_replaced_clears_valid_and_propagates():
"""REPLACED: input spent by a lower-locktime tx; cascades to children."""
now = int(time.time())
lt_parent = now + 60 * DAY
lt_child = now + 30 * DAY
lt_replacer = now + 15 * DAY
ha = fx.heir_addresses(3)
from electrum.transaction import PartialTxOutput, PartialTxInput, TxOutpoint
from electrum.util import bfh
parent_txid = "aa" * 32
parent_tx = PartialTransaction.from_io(
[fx.make_utxo("aa" * 32, 200_000)],
[PartialTxOutput.from_address_and_value(ha[0], 100_000),
PartialTxOutput.from_address_and_value(fx.change_address(), 99_000)],
locktime=lt_parent, version=2,
)
pwi = WillItem.__new__(WillItem)
pwi.tx = parent_tx; pwi._id = parent_txid
pwi.heirs = {"a": [ha[0], 100_000, str(lt_parent)]}; pwi.we = None
pwi.status = ""; pwi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
pwi.tx_fees = 100; pwi.time = now; pwi.father = None; pwi.children = {}
pwi.description = ""; pwi.change = ""
pwi.set_status("VALID", True)
ci = PartialTxInput(prevout=TxOutpoint(txid=bfh(parent_txid), out_idx=1))
ci._trusted_value_sats = 99_000; ci.is_mine = True
child_tx = PartialTransaction.from_io(
[ci], [PartialTxOutput.from_address_and_value(ha[1], 50_000)],
locktime=lt_child, version=2,
)
cwi = WillItem.__new__(WillItem)
cwi.tx = child_tx; cwi._id = "bb" * 32
cwi.heirs = {"b": [ha[1], 50_000, str(lt_child)]}; cwi.we = None
cwi.status = ""; cwi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
cwi.tx_fees = 100; cwi.time = now; cwi.father = None; cwi.children = {}
cwi.description = ""; cwi.change = ""
cwi.set_status("VALID", True)
replacer_tx = PartialTransaction.from_io(
[fx.make_utxo("aa" * 32, 200_000)],
[PartialTxOutput.from_address_and_value(ha[2], 150_000)],
locktime=lt_replacer, version=2,
)
rwi = WillItem.__new__(WillItem)
rwi.tx = replacer_tx; rwi._id = "cc" * 32
rwi.heirs = {"c": [ha[2], 150_000, str(lt_replacer)]}; rwi.we = None
rwi.status = ""; rwi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
rwi.tx_fees = 100; rwi.time = now; rwi.father = None; rwi.children = {}
rwi.description = ""; rwi.change = ""
rwi.set_status("VALID", True)
will = {pwi._id: pwi, cwi._id: cwi, rwi._id: rwi}
Will.add_willtree(will)
Will.search_rai(Will.get_all_inputs(will, only_valid=True),
[fx.make_utxo("aa" * 32, 200_000)], will, fx.fake_wallet())
assert pwi.get_status("REPLACED") and not pwi.get_status("VALID")
assert cwi.get_status("REPLACED") and not cwi.get_status("VALID")
assert rwi.get_status("VALID") and not rwi.get_status("REPLACED")
def test_state_invalidated_clears_valid():
"""INVALIDATED: input spent elsewhere and parent tx not in the will."""
now = int(time.time())
lt = now + 30 * DAY
ha = fx.heir_addresses(1)
from electrum.transaction import PartialTxOutput, PartialTxInput, TxOutpoint
from electrum.util import bfh
txin = PartialTxInput(prevout=TxOutpoint(txid=bfh("dd" * 32), out_idx=0))
txin._trusted_value_sats = 100_000; txin.is_mine = True
tx = PartialTransaction.from_io(
[txin], [PartialTxOutput.from_address_and_value(ha[0], 90_000)],
locktime=lt, version=2,
)
wi = WillItem.__new__(WillItem)
wi.tx = tx; wi._id = "ee" * 32
wi.heirs = {"a": [ha[0], 90_000, str(lt)]}; wi.we = None
wi.status = ""; wi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
wi.tx_fees = 100; wi.time = now; wi.father = None; wi.children = {}
wi.description = ""; wi.change = ""
wi.set_status("VALID", True)
will = {wi._id: wi}
Will.add_willtree(will)
# all_utxos empty -> input is gone; wallet has no record of our tx.
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will,
fx.fake_wallet())
assert wi.get_status("INVALIDATED")
assert not wi.get_status("VALID")
def test_state_pending_clears_valid():
"""PENDING: our tx is seen unconfirmed in the wallet (height 0)."""
now = int(time.time())
lt = now + 30 * DAY
ha = fx.heir_addresses(1)
wi = _wi(lt, {"a": [ha[0], 100_000, str(lt)]}, "ab" * 32, time_stamp=now)
wallet = fx.fake_wallet()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
will = {wi._id: wi}
Will.add_willtree(will)
# utxos_list empty -> the input is spent (our tx is in mempool spending it).
Will.check_invalidated(will, [], wallet)
assert wi.get_status("PENDING")
assert not wi.get_status("VALID")
def test_state_confirmed_clears_valid():
"""CONFIRMED: our tx is mined (height > 0)."""
now = int(time.time())
lt = now + 30 * DAY
ha = fx.heir_addresses(1)
wi = _wi(lt, {"a": [ha[0], 100_000, str(lt)]}, "ac" * 32, time_stamp=now)
wallet = fx.fake_wallet()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 123
will = {wi._id: wi}
Will.add_willtree(will)
Will.check_invalidated(will, [], wallet)
assert wi.get_status("CONFIRMED")
assert not wi.get_status("VALID")
def test_state_updated_keeps_valid():
"""UPDATED: same locktime + same heirs, older tx flagged UPDATED, stays VALID."""
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(1)
from electrum.transaction import PartialTxOutput
shared_utxo = lambda: fx.make_utxo("fa" * 32, 500_000)
older = WillItem.__new__(WillItem)
older.tx = PartialTransaction.from_io(
[shared_utxo()],
[PartialTxOutput.from_address_and_value(ha[0], 100_000),
PartialTxOutput.from_address_and_value(fx.willexecutor_addresses(1)[0], 100_000)],
locktime=lt, version=2)
older._id = "f1" * 32
older.heirs = {"a": [ha[0], 100_000, str(lt)]}
older.we = {"url": "https://we.example", "base_fee": 100000}
older.status = ""; older.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
older.tx_fees = 100; older.time = now - 100; older.father = None
older.children = {}; older.description = ""; older.change = ""
older.set_status("VALID", True)
newer = WillItem.__new__(WillItem)
newer.tx = PartialTransaction.from_io(
[shared_utxo()],
[PartialTxOutput.from_address_and_value(ha[0], 100_000),
PartialTxOutput.from_address_and_value(fx.willexecutor_addresses(1)[0], 200_000)],
locktime=lt, version=2)
newer._id = "f2" * 32
newer.heirs = {"a": [ha[0], 100_000, str(lt)]}
newer.we = {"url": "https://we.example", "base_fee": 200000}
newer.status = ""; newer.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
newer.tx_fees = 100; newer.time = now; newer.father = None
newer.children = {}; newer.description = ""; newer.change = ""
newer.set_status("VALID", True)
will = {older._id: older, newer._id: newer}
Will.search_updated(Will.get_all_inputs(will, only_valid=True))
assert older.get_status("UPDATED") and older.get_status("VALID")
assert not newer.get_status("UPDATED") and newer.get_status("VALID")
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
obj()
print(f" [OK] {name}")
print("[OK] All inheritance-flow tests passed")

View File

@@ -0,0 +1,543 @@
"""
Regression tests for the high-level inheritance rules described in the
project documentation (``docs/inheritance-options.md``).
These tests verify the decision flow for changing a will:
* an unsigned old will is simply replaced, never invalidated on-chain;
* adding / changing / removing an heir triggers anticipation (1 day earlier);
* adding or changing a will-executor rebuilds the tx while keeping the same
locktime;
* postponing an already signed will requires on-chain invalidation;
* a Check-Alive threshold in the past triggers invalidation;
* relative locktimes are compared against the will creation time, not
against the current wall-clock time.
The tests use the same mock fixtures as the rest of the suite: genuine regtest
addresses from the giovanna7 wallet, synthetic UTXOs and a patched
``PartialTransaction.txid`` so the builder can run without a live Electrum.
"""
import copy
import datetime as real_dt
import itertools
import os
import sys
import time
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from electrum import constants
sys.path.insert(0, os.path.dirname(__file__))
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@pytest.fixture(autouse=True)
def _regtest_network():
previous = constants.net
constants.BitcoinRegtest.set_as_network()
try:
yield
finally:
previous.set_as_network()
import bal_fixtures as fx # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.will import ( # noqa: E402
Will, WillItem, WillPostponedException, HeirNotFoundException,
NotCompleteWillException,
)
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.core.util import Util # noqa: E402
from bal.core.plugin_base import BalTimestamp # noqa: E402
from electrum.transaction import PartialTransaction # noqa: E402
DAY = 86400
_TXID_COUNTER = itertools.count(1)
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _willexecutors(specs):
out = {}
for url, address, base_fee in specs:
out[url] = {
"url": url,
"address": address,
"base_fee": base_fee,
"selected": True,
"status": 200,
}
return out
def _make_heirs(entries):
h = Heirs.__new__(Heirs)
dict.__init__(h)
h.db = MagicMock()
h.wallet = MagicMock()
h.update(copy.deepcopy(entries))
return h
def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100,
no_willexecutor=False):
wallet = fx.fake_wallet()
utxos = fx.make_utxos(4, 5_000_000)
plugin = fx.fake_bal_plugin(willexecutors, no_willexecutor=no_willexecutor)
if from_locktime is None:
from_locktime = int(time.time())
counter = itertools.count(1)
def fake_txid(self):
if not hasattr(self, "_test_txid"):
self._test_txid = f"{next(counter):064x}"
return self._test_txid
with patch.object(Willexecutors, "get_willexecutors",
return_value=willexecutors), \
patch.object(PartialTransaction, "txid", fake_txid):
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
from_locktime)
return txs or {}
def _tx_for_we(txs, url):
for tx in txs.values():
we = getattr(tx, "willexecutor", None)
if we and we.get("url") == url:
return tx
return None
def _backup_tx(txs):
for tx in txs.values():
if not getattr(tx, "willexecutor", None):
return tx
return None
def _item_from_tx(tx, *, txid=None, complete=False, pushed=False, time=None,
we=None, heirs=None):
if txid is None:
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
if heirs is None:
heirs = copy.deepcopy(getattr(tx, "heirs", {}))
if we is None and hasattr(tx, "willexecutor"):
we = copy.deepcopy(tx.willexecutor)
d = {
"tx": tx,
"heirs": heirs,
"willexecutor": we,
"status": "",
"description": getattr(tx, "description", ""),
"time": time,
"change": "",
"baltx_fees": getattr(tx, "tx_fees", 100),
}
item = WillItem(d, _id=txid)
item.set_status("VALID", True)
if complete:
item.set_status("COMPLETE", True)
if pushed:
item.set_status("PUSHED", True)
return item
def _build_item(heirs, url, address, base_fee, *, from_locktime, time_stamp,
complete=False, pushed=False):
wes = _willexecutors([(url, address, base_fee)])
txs = _build(heirs, wes, from_locktime=from_locktime)
tx = _tx_for_we(txs, url)
assert tx is not None, "no will-executor transaction built"
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
return _item_from_tx(tx, txid=txid, complete=complete, pushed=pushed,
time=time_stamp), txid, tx
# ------------------------------------------------------------------ #
# 1. Unsigned old will is replaced, not invalidated
# ------------------------------------------------------------------ #
def test_unsigned_will_postpone_triggers_rebuild_not_invalidation():
"""Postponing a will that was never signed/sent does NOT raise
WillPostponedException: it is treated as a plain rebuild."""
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
wes = _willexecutors([("https://we.example", we_addr, 100000)])
heirs_initial = {
"alice": [ha[0], "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = _make_heirs(heirs_initial)
txs = _build(h_initial, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
item = _item_from_tx(tx, txid=txid, complete=False, pushed=False, time=now)
heirs_postponed = _make_heirs({
"alice": [ha[0], "40%", "1y"],
"bob": [ha[1], "30%", "1y"],
"carol": [ha[2], "30%", "1y"],
})
check_date = BalTimestamp("30d").to_timestamp()
with pytest.raises(NotCompleteWillException) as exc_info:
Will.check_willexecutors_and_heirs(
{txid: item}, heirs_postponed, wes, False, check_date, 100
)
assert not isinstance(exc_info.value, WillPostponedException)
def test_unsigned_will_change_is_anticipated():
"""A change to an unsigned will is anticipated (replaced), not invalidated."""
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
old_item, _, old_tx = _build_item(
old_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now - 10,
complete=False, pushed=False,
)
new_heirs = _make_heirs({"alice": [ha[1], "100%", str(lt)]})
new_item, _, _ = _build_item(
new_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now,
complete=False, pushed=False,
)
assert new_item.set_anticipate(old_item)
assert new_item.get_status("ANTICIPATED")
assert int(new_item.tx.locktime) == int(
Util.anticipate_locktime(old_tx.locktime, days=1)
)
# ------------------------------------------------------------------ #
# 2. Adding / changing / removing an heir anticipates the locktime
# ------------------------------------------------------------------ #
def test_add_heir_anticipates_locktime():
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
old_item, _, old_tx = _build_item(
old_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now - 10,
complete=True, pushed=True,
)
new_heirs = _make_heirs({
"alice": [ha[0], "60%", str(lt)],
"bob": [ha[1], "40%", str(lt)],
})
new_item, _, _ = _build_item(
new_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now,
complete=False, pushed=False,
)
assert new_item.set_anticipate(old_item)
assert new_item.get_status("ANTICIPATED")
assert int(new_item.tx.locktime) == int(
Util.anticipate_locktime(old_tx.locktime, days=1)
)
def test_change_heir_amount_anticipates_locktime():
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
old_heirs = _make_heirs({
"alice": [ha[0], "60%", str(lt)],
"bob": [ha[1], "40%", str(lt)],
})
old_item, _, old_tx = _build_item(
old_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now - 10,
complete=True, pushed=True,
)
new_heirs = _make_heirs({
"alice": [ha[0], "80%", str(lt)],
"bob": [ha[1], "20%", str(lt)],
})
new_item, _, _ = _build_item(
new_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now,
complete=False, pushed=False,
)
assert new_item.set_anticipate(old_item)
assert new_item.get_status("ANTICIPATED")
assert int(new_item.tx.locktime) == int(
Util.anticipate_locktime(old_tx.locktime, days=1)
)
def test_remove_heir_anticipates_locktime():
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
old_heirs = _make_heirs({
"alice": [ha[0], "60%", str(lt)],
"bob": [ha[1], "40%", str(lt)],
})
old_item, _, old_tx = _build_item(
old_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now - 10,
complete=True, pushed=True,
)
new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
new_item, _, _ = _build_item(
new_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now,
complete=False, pushed=False,
)
assert new_item.set_anticipate(old_item)
assert new_item.get_status("ANTICIPATED")
assert int(new_item.tx.locktime) == int(
Util.anticipate_locktime(old_tx.locktime, days=1)
)
# ------------------------------------------------------------------ #
# 3. Adding or changing a will-executor keeps the locktime
# ------------------------------------------------------------------ #
def test_add_willexecutor_keeps_locktime():
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
# Old will: local backup only, no will-executor.
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
old_txs = _build(old_heirs, {}, from_locktime=now, no_willexecutor=True)
old_tx = _backup_tx(old_txs)
assert old_tx is not None, "no backup transaction built"
old_item = _item_from_tx(
old_tx, complete=True, pushed=True, time=now - 10, we=None
)
old_locktime = int(old_tx.locktime)
# New will: same heir, same locktime, but now with a will-executor.
new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
new_item, _, _ = _build_item(
new_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now,
complete=False, pushed=False,
)
kept = Will.check_anticipate(old_item, new_item)
assert int(kept) == old_locktime
assert not new_item.set_anticipate(old_item)
assert int(new_item.tx.locktime) == old_locktime
def test_modify_willexecutor_keeps_locktime():
now = int(time.time())
lt = int((now + 365 * DAY) / DAY) * DAY
ha = fx.heir_addresses(4)
we_addr = ha[3]
old_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
old_item, _, old_tx = _build_item(
old_heirs, "https://we.example", we_addr, 100000,
from_locktime=now, time_stamp=now - 10,
complete=True, pushed=True,
)
old_locktime = int(old_tx.locktime)
# Same URL, higher base_fee -> locktime must stay the same.
new_heirs = _make_heirs({"alice": [ha[0], "100%", str(lt)]})
new_item, _, _ = _build_item(
new_heirs, "https://we.example", we_addr, 150000,
from_locktime=now, time_stamp=now,
complete=False, pushed=False,
)
kept = Will.check_anticipate(old_item, new_item)
assert int(kept) == old_locktime
assert not new_item.set_anticipate(old_item)
assert int(new_item.tx.locktime) == old_locktime
# ------------------------------------------------------------------ #
# 4. Postponing a signed will requires invalidation
# ------------------------------------------------------------------ #
def test_postpone_signed_will_requires_invalidation():
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
wes = _willexecutors([("https://we.example", we_addr, 100000)])
heirs_initial = {
"alice": [ha[0], "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = _make_heirs(heirs_initial)
txs = _build(h_initial, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
willitems = _item_from_tx(tx, txid=txid, complete=True, pushed=True,
time=now)
heirs_postponed = _make_heirs({
"alice": [ha[0], "40%", "1y"],
"bob": [ha[1], "30%", "1y"],
"carol": [ha[2], "30%", "1y"],
})
check_date = BalTimestamp("30d").to_timestamp()
with pytest.raises(WillPostponedException):
Will.check_willexecutors_and_heirs(
{txid: willitems}, heirs_postponed, wes, False, check_date, 100
)
# ------------------------------------------------------------------ #
# 5. Check-Alive threshold in the past triggers invalidation
# ------------------------------------------------------------------ #
def test_threshold_in_past_raises_check_alive_error():
"""A Check-Alive threshold that is already in the past makes
``BalWindow.init_class_variables`` raise ``CheckAliveError``."""
from bal.gui.qt.window import BalWindow
from bal.gui.qt.common import CheckAliveError
import bal.gui.qt.window as window_mod
now = int(time.time())
bw = object.__new__(BalWindow)
bw.heirs = {"alice": [fx.heir_addresses(1)[0], "100%", "300d"]}
bw.will_settings = {"threshold": now - 1000, "locktime": "1y"}
bw.bal_plugin = MagicMock()
bw.bal_plugin.NO_WILLEXECUTOR.get.return_value = False
bw.bal_plugin.ENABLE_MULTIVERSE.get.return_value = False
bw.willexecutors = {}
class _PatchedDt:
@classmethod
def now(cls):
return real_dt.datetime.fromtimestamp(now)
@classmethod
def fromtimestamp(cls, ts):
return real_dt.datetime.fromtimestamp(ts)
@classmethod
def today(cls):
return real_dt.datetime.fromtimestamp(now).date()
_PatchedDt.timedelta = real_dt.timedelta
with patch.object(window_mod, "datetime", _PatchedDt), \
patch.object(window_mod, "Willexecutors") as mock_we:
mock_we.get_willexecutors.return_value = {}
with pytest.raises(CheckAliveError):
bw.init_class_variables()
# ------------------------------------------------------------------ #
# 6. Relative locktime postpone must compare against creation time
# ------------------------------------------------------------------ #
def test_relative_locktime_postpone_uses_creation_time():
"""For relative locktimes the postpone check resolves the requested
locktime against the will creation time (``w.time``), not against the
current ``datetime.now()``.
This also verifies that the comparison is effectively relative: the same
relative string stays coherent even when wall-clock time advances, while a
longer relative string is detected as a postpone."""
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
wes = _willexecutors([("https://we.example", we_addr, 100000)])
heirs_initial = {
"alice": [ha[0], "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = _make_heirs(heirs_initial)
txs = _build(h_initial, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
txid = getattr(tx, "_test_txid", None) or f"tx{next(_TXID_COUNTER):08x}"
item = _item_from_tx(tx, txid=txid, complete=True, pushed=True, time=now)
# Advance wall-clock by two days.
advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY)
import bal.core.plugin_base as pb_mod
class _PatchedDt:
@classmethod
def now(cls):
return advanced_now_dt
@classmethod
def fromtimestamp(cls, ts):
return real_dt.datetime.fromtimestamp(ts)
@classmethod
def today(cls):
return advanced_now_dt.date()
_PatchedDt.timedelta = real_dt.timedelta
with patch.object(pb_mod, "datetime", _PatchedDt):
check_date = BalTimestamp("30d").to_timestamp()
# Same relative locktime after two days must remain coherent (no spurious
# postpone detection).
Will.check_willexecutors_and_heirs(
{txid: item}, h_initial, wes, False, check_date, 100
)
# Moving the relative locktime from "300d" to "1y" is a real postpone and
# must be detected using the creation-time base.
heirs_postponed = _make_heirs({
"alice": [ha[0], "40%", "1y"],
"bob": [ha[1], "30%", "1y"],
"carol": [ha[2], "30%", "1y"],
})
with pytest.raises(WillPostponedException):
Will.check_willexecutors_and_heirs(
{txid: item}, heirs_postponed, wes, False, check_date, 100
)
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
obj()
print(f" [OK] {name}")
print("[OK] All inheritance-rules tests passed")

View File

@@ -0,0 +1,281 @@
"""
Mock tests for inheritance core logic.
Tests:
1. prepare_transactions builds outputs matching heir amounts
2. prepare_transactions handles multiple locktime groups
3. new will item gets ANTICIPATED status via set_anticipate
4. REPLACED status propagates through the will tree
5. INVALIDATED status when input is spent and parent tx not tracked
"""
import sys, os, time, copy
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
Heirs, prepare_transactions, HEIR_ADDRESS, HEIR_REAL_AMOUNT,
)
from bal.core.will import Will, WillItem
from bal.core.util import Util
from electrum.transaction import (
PartialTransaction, PartialTxInput, PartialTxOutput, TxOutpoint,
)
from electrum.util import bfh
ADDR1 = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
ADDR2 = "1BvBMSEYstWetqTFn5Au4m4GFg7xJaNVN2"
ADDR3 = "12c6DSiU4Rq3P4ZxziKxzrL5LmMBrzjrJX"
ADDR_CHG = "3J98t1WpEZ73CNmQviecrnyiWrnqRhWNLy"
TX_FEES = 100
def _utxo(txid_hex, value=500_000):
txin = PartialTxInput(prevout=TxOutpoint(txid=bfh(txid_hex), out_idx=0))
txin._trusted_value_sats = value
txin._TxInput__value_sats = value
txin.is_mine = True
return txin
def _tx(utxos, outputs, locktime):
return PartialTransaction.from_io(utxos, outputs, locktime=locktime, version=2)
def _wi(tx, heirs_data):
wi = WillItem.__new__(WillItem)
wi.tx = tx
wi._id = tx.txid()
wi.heirs = heirs_data
wi.we = None
wi.status = ""
wi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
wi.tx_fees = TX_FEES
wi.father = None
wi.children = {}
wi.set_status("VALID", True)
return wi
def _txid_gen():
i = 0
while True:
yield f"{i:064x}"
i += 1
def _wallet():
w = MagicMock()
w.dust_threshold.return_value = 500
w.get_change_addresses_for_new_transaction.return_value = [ADDR_CHG]
w.get_utxos.return_value = []
w.db.get.return_value = {}
w.db.get_transaction.return_value = None
return w
# ------------------------------------------------------------------ #
# Test 1: prepare_transactions builds outputs matching heir amounts
# ------------------------------------------------------------------ #
@patch("electrum.transaction.bitcoin.address_to_script", return_value=b"\x00\x14" + b"\x00" * 20)
@patch("bal.core.heirs.bitcoin.is_address", return_value=True)
@patch("electrum.transaction.bitcoin.is_address", return_value=True)
@patch("bal.core.heirs.PartialTransaction.txid", side_effect=_txid_gen())
def test_prepare_transactions_verify_amounts(*_):
wallet = _wallet()
now = int(time.time())
lt = now + 30 * 86400
locktimes = {
lt: {
"alice": [ADDR1, "fix", str(lt), 1_000_000],
"bob": [ADDR2, "fix", str(lt), 1_000_000],
"charlie": [ADDR3, "fix", str(lt), 1_000_000],
}
}
utxos = [_utxo(f"{i:02x}" * 32, 500_000) for i in range(8)]
fees = {lt: 10000}
txs = prepare_transactions(locktimes, utxos[:], fees, wallet)
assert txs, "no txs built"
for txid, tx in txs.items():
outs = tx.outputs()
for name, hdata in getattr(tx, "heirs", {}).items():
if "w!ll3x3c" in name:
continue
addr = hdata[HEIR_ADDRESS]
exp = hdata[HEIR_REAL_AMOUNT]
assert any(o.value == exp for o in outs), \
f"{name}: amount {exp} not in outputs"
print(f"[OK] prepare_transactions built {len(txs)} tx(s)")
# ------------------------------------------------------------------ #
# Test 2: prepare_transactions with multiple locktime groups
# ------------------------------------------------------------------ #
@patch("electrum.transaction.bitcoin.address_to_script", return_value=b"\x00\x14" + b"\x00" * 20)
@patch("bal.core.heirs.bitcoin.is_address", return_value=True)
@patch("electrum.transaction.bitcoin.is_address", return_value=True)
@patch("bal.core.heirs.PartialTransaction.txid", side_effect=_txid_gen())
def test_prepare_transactions_multiple_calls(*_):
wallet = _wallet()
now = int(time.time())
lt1, lt2 = now + 30 * 86400, now + 60 * 86400
utxos_pool = [_utxo(f"{i:02x}" * 32, 600_000) for i in range(2)]
# First call: process lt1 group
txs1 = prepare_transactions(
{lt1: {"alice": [ADDR1, 500_000, str(lt1)]}},
utxos_pool[:], {lt1: 5000}, wallet,
)
assert txs1, "no txs from first group"
t1_heirs = set()
for tx in txs1.values():
t1_heirs.update(getattr(tx, "heirs", {}).keys())
assert "alice" in t1_heirs, "alice missing from first call"
# Second call: process lt2 group (with fresh mock data)
txs2 = prepare_transactions(
{lt2: {"bob": [ADDR2, 300_000, str(lt2)]}},
[_utxo("ff" * 32, 600_000)], {lt2: 5000}, wallet,
)
assert txs2, "no txs from second group"
t2_heirs = set()
for tx in txs2.values():
t2_heirs.update(getattr(tx, "heirs", {}).keys())
assert "bob" in t2_heirs, "bob missing from second call"
print("[OK] prepare_transactions handles multiple sequential calls")
# ------------------------------------------------------------------ #
# Test 3: set_anticipate gives ANTICIPATED status + lower locktime
# ------------------------------------------------------------------ #
def test_set_anticipate():
now = int(time.time())
lt = int((now + 30 * 86400) / 86400) * 86400
old_tx = _tx([_utxo("aa" * 32, 200_000)],
[PartialTxOutput.from_address_and_value(ADDR1, 100_000)],
lt)
old_wi = _wi(old_tx, {"alice": [ADDR1, 100_000, str(lt)]})
new_tx = _tx([_utxo("bb" * 32, 200_000)],
[PartialTxOutput.from_address_and_value(ADDR2, 100_000)],
lt)
new_wi = _wi(new_tx, {"alice": [ADDR2, 100_000, str(lt)]})
anticipated = new_wi.set_anticipate(old_wi)
assert anticipated, "set_anticipate should return True"
assert new_wi.get_status("ANTICIPATED"), "ANTICIPATED should be set"
assert new_wi.get_status("VALID"), "VALID should remain True"
assert new_wi.tx.locktime <= old_wi.tx.locktime, \
f"new locktime {new_wi.tx.locktime} should be <= old {old_wi.tx.locktime}"
print("[OK] set_anticipate sets ANTICIPATED, keeps VALID, lowers locktime")
# ------------------------------------------------------------------ #
# Test 4: REPLACED propagates through will tree
# ------------------------------------------------------------------ #
def test_replaced_propagates():
now = int(time.time())
lt_parent = int(now + 60 * 86400)
lt_child = int(now + 30 * 86400)
lt_replacer = int(now + 15 * 86400)
# Pre-determine parent txid (can't use real tx.txid() on unsigned PSBT)
parent_txid_hex = "aa" * 32
# Build parent tx spending the wallet utxo
parent_utxo = _utxo("aa" * 32, 200_000)
parent_tx = _tx([parent_utxo],
[PartialTxOutput.from_address_and_value(ADDR1, 100_000),
PartialTxOutput.from_address_and_value(ADDR_CHG, 99_000)],
lt_parent)
# Build child tx spending the parent's change output (parent_txid:1)
ci = PartialTxInput(prevout=TxOutpoint(txid=bfh(parent_txid_hex), out_idx=1))
ci._trusted_value_sats = 99_000; ci.is_mine = True
child_tx = _tx([ci],
[PartialTxOutput.from_address_and_value(ADDR2, 50_000)], lt_child)
# Build replacer tx spending the SAME utxo as parent, with lower locktime
replacer_tx = _tx([_utxo("aa" * 32, 200_000)],
[PartialTxOutput.from_address_and_value(ADDR3, 150_000)], lt_replacer)
hp = {"a": [ADDR1, 100_000, str(lt_parent)]}
hc = {"b": [ADDR2, 50_000, str(lt_child)]}
hr = {"c": [ADDR3, 150_000, str(lt_replacer)]}
pwi = _wi(parent_tx, hp)
pwi._id = parent_txid_hex
cwi = _wi(child_tx, hc)
cwi._id = "bb" * 32
rwi = _wi(replacer_tx, hr)
rwi._id = "cc" * 32
will = {pwi._id: pwi, cwi._id: cwi, rwi._id: rwi}
Will.add_willtree(will)
all_utxos = [_utxo("aa" * 32, 200_000)]
Will.search_rai(Will.get_all_inputs(will, only_valid=True), all_utxos, will, _wallet())
assert pwi.get_status("REPLACED"), "parent should be REPLACED"
assert not pwi.get_status("VALID"), "parent should NOT be VALID"
assert cwi.get_status("REPLACED"), "child should be REPLACED (propagated)"
assert not cwi.get_status("VALID"), "child should NOT be VALID"
assert rwi.get_status("VALID"), "replacer should be VALID"
assert not rwi.get_status("REPLACED"), "replacer should NOT be REPLACED"
print("[OK] REPLACED propagation to children verified")
# ------------------------------------------------------------------ #
# Test 5: INVALIDATED when input not in utxos and parent TX untracked
# ------------------------------------------------------------------ #
def test_invalidated():
now = int(time.time())
lt = int(now + 30 * 86400)
addr = ADDR1
# TX that spends an unknown input (not in wallet utxos)
txin = PartialTxInput(prevout=TxOutpoint(txid=bfh("dd" * 32), out_idx=0))
txin._trusted_value_sats = 100_000; txin.is_mine = True
tx = _tx([txin],
[PartialTxOutput.from_address_and_value(addr, 90_000)], lt)
wi = _wi(tx, {"a": [addr, 90_000, str(lt)]})
will = {wi._id: wi}
Will.add_willtree(will)
# all_utxos is empty, wallet.db.get_transaction returns None
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, _wallet())
assert wi.get_status("INVALIDATED"), "should be INVALIDATED"
assert not wi.get_status("VALID"), "should NOT be VALID"
print("[OK] INVALIDATED when input not ours and parent TX untracked")
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import logging
logging.basicConfig(level=logging.WARNING)
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print("[OK] All mock inheritance tests passed")

View File

@@ -0,0 +1,276 @@
"""
Test: modifying an heir (address or amount) on a signed/pushed will triggers
a rebuild (HeirNotFoundException) but NOT an on-chain invalidation transaction.
Scenario:
The user has a signed/pushed will. Changing the address or the
percentage/amount of an existing heir changes the transaction's output
script or value, so the old transaction must be rebuilt.
In ``check_willexecutors_and_heirs``, the condition
``heir[0] == their[0] and heir[1] == their[1]`` (address and amount) is
False for the modified heir → that heir is never counted in
``heirs_found`` → ``HeirNotFoundException`` is raised at line 911.
This holds as long as the locktime is not also postponed (which would
raise ``WillPostponedException`` instead, requesting an on-chain
invalidation transaction).
The threshold ("30d") also stays in the future, so ``CheckAliveError`` /
``WillExpiredException`` must not fire either.
Two sub-tests:
1. ``test_change_address_triggers_rebuild``
- Same percentage, same locktime → only the address differs.
2. ``test_change_amount_triggers_rebuild``
- Same address, same locktime → only the percentage/amount differs.
Both must raise ``HeirNotFoundException`` and NOT
``WillPostponedException``.
"""
import copy
import os
import sys
import time
import itertools
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from electrum import constants
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@pytest.fixture(autouse=True)
def _regtest_network():
previous = constants.net
constants.BitcoinRegtest.set_as_network()
try:
yield
finally:
previous.set_as_network()
import bal_fixtures as fx # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.will import ( # noqa: E402
Will, WillItem, HeirNotFoundException,
WillPostponedException, WillExpiredException,
)
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.core.plugin_base import BalTimestamp # noqa: E402
from electrum.transaction import PartialTransaction # noqa: E402
DAY = 86400
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _willexecutors(specs):
out = {}
for url, address, base_fee in specs:
out[url] = {
"url": url, "address": address,
"base_fee": base_fee, "selected": True, "status": 200,
}
return out
def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100):
wallet = fx.fake_wallet()
utxos = fx.make_utxos(4, 5_000_000)
plugin = fx.fake_bal_plugin(willexecutors)
if from_locktime is None:
from_locktime = int(time.time())
counter = itertools.count(1)
def fake_txid(self):
if not hasattr(self, "_test_txid"):
self._test_txid = f"{next(counter):064x}"
return self._test_txid
with patch.object(Willexecutors, "get_willexecutors",
return_value=willexecutors), \
patch.object(PartialTransaction, "txid", fake_txid):
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
from_locktime)
return txs or {}
def _tx_for_we(txs, url):
for tx in txs.values():
we = getattr(tx, "willexecutor", None)
if we and we.get("url") == url:
return tx
return None
def _build_signed_pushed_will(heirs_dict, wes, now):
"""Build, sign (COMPLETE) and push (PUSHED) a will. Return the willitems
dict and the will-item creation time *w.time*."""
txs = _build(heirs_dict, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
assert tx is not None, "no will-executor transaction built"
txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid()
item = WillItem.__new__(WillItem)
item.tx = tx
item._id = txid
item.heirs = copy.deepcopy(tx.heirs)
item.we = copy.deepcopy(tx.willexecutor)
item.status = ""
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx_fees = 100
item.time = now
item.father = None
item.children = {}
item.description = ""
item.change = ""
item.set_status("VALID", True)
item.set_status("COMPLETE", True)
item.set_status("PUSHED", True)
return {txid: item}, now
# ------------------------------------------------------------------ #
# Tests
# ------------------------------------------------------------------ #
def test_change_address_triggers_rebuild():
"""Changing the address of an existing heir triggers HeirNotFoundException
(plain rebuild), NOT WillPostponedException (on-chain invalidation)."""
now = int(time.time())
ha = fx.heir_addresses(4)
we_addr = fx.willexecutor_addresses(1)[0]
wes = _willexecutors([("https://we.example", we_addr, 100000)])
addr_alice_old = ha[0]
addr_alice_new = ha[3] # different address
# ── 1. Build signed/pushed will ─────────────────────────────────
heirs_initial = {
"alice": [addr_alice_old, "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = Heirs.__new__(Heirs)
dict.__init__(h_initial)
h_initial.db = MagicMock()
h_initial.wallet = MagicMock()
h_initial.update(copy.deepcopy(heirs_initial))
willitems, w_time = _build_signed_pushed_will(h_initial, wes, now)
# ── 2. Change only alice's address ──────────────────────────────
heirs_changed = {
"alice": [addr_alice_new, "40%", "300d"], # same %, same locktime
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_changed = Heirs.__new__(Heirs)
dict.__init__(h_changed)
h_changed.db = MagicMock()
h_changed.wallet = MagicMock()
h_changed.update(copy.deepcopy(heirs_changed))
check_date = BalTimestamp("30d").to_timestamp()
# ── 3. Assert HeirNotFoundException, NOT WillPostponedException ─
with pytest.raises(HeirNotFoundException) as exc_info:
Will.check_willexecutors_and_heirs(
willitems, h_changed, wes,
False, # no_willexecutor
check_date,
100,
)
# The exception should name the modified heir.
assert "alice" in str(exc_info.value)
# Double-check: WillPostponedException must NOT be raised.
try:
Will.check_willexecutors_and_heirs(
willitems, h_changed, wes,
False,
check_date,
100,
)
except HeirNotFoundException:
pass # expected
except WillPostponedException:
pytest.fail("changing address must NOT request an invalidation "
"transaction (WillPostponedException)")
except Exception:
pass # other rebuild-related exceptions are acceptable
def test_change_amount_triggers_rebuild():
"""Changing the percentage/amount of an existing heir triggers
HeirNotFoundException (plain rebuild), NOT WillPostponedException (on-chain
invalidation)."""
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
wes = _willexecutors([("https://we.example", we_addr, 100000)])
# ── 1. Build signed/pushed will ─────────────────────────────────
heirs_initial = {
"alice": [ha[0], "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = Heirs.__new__(Heirs)
dict.__init__(h_initial)
h_initial.db = MagicMock()
h_initial.wallet = MagicMock()
h_initial.update(copy.deepcopy(heirs_initial))
willitems, w_time = _build_signed_pushed_will(h_initial, wes, now)
# ── 2. Change only alice's percentage ───────────────────────────
heirs_changed = {
"alice": [ha[0], "50%", "300d"], # same addr, same locktime
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_changed = Heirs.__new__(Heirs)
dict.__init__(h_changed)
h_changed.db = MagicMock()
h_changed.wallet = MagicMock()
h_changed.update(copy.deepcopy(heirs_changed))
check_date = BalTimestamp("30d").to_timestamp()
# ── 3. Assert HeirNotFoundException, NOT WillPostponedException ─
with pytest.raises(HeirNotFoundException) as exc_info:
Will.check_willexecutors_and_heirs(
willitems, h_changed, wes,
False,
check_date,
100,
)
assert "alice" in str(exc_info.value)
try:
Will.check_willexecutors_and_heirs(
willitems, h_changed, wes,
False,
check_date,
100,
)
except HeirNotFoundException:
pass # expected
except WillPostponedException:
pytest.fail("changing amount must NOT request an invalidation "
"transaction (WillPostponedException)")
except Exception:
pass

285
tests/test_multiverse.py Normal file
View File

@@ -0,0 +1,285 @@
"""
Tests for multiverse feature: multiple locktimes among heirs.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_multiverse.py
"""
import sys
import os
import time
import copy
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import (
Heirs, prepare_transactions,
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
)
from bal.core.util import Util
from bal.core.plugin_base import BalConfig
from bal.core.will import WillItem, Will
from electrum.transaction import (
PartialTransaction, PartialTxInput, TxOutpoint,
)
from electrum.util import bfh
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def fake_wallet(change_addr="1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"):
w = MagicMock()
w.dust_threshold.return_value = 500
change_mock = MagicMock()
change_mock.__getitem__.return_value = change_addr
w.get_change_addresses_for_new_transaction.return_value = change_mock
return w
MAINNET_ADDR = "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
def make_heir(name, addr, amount, locktime):
return {name: [addr, amount, locktime]}
# ------------------------------------------------------------------ #
# parse_locktime_string various formats
# ------------------------------------------------------------------ #
def test_parse_d_suffix_is_timestamp():
result = Util.parse_locktime_string("30d")
assert result > 500000000
def test_parse_y_suffix_is_timestamp():
result = Util.parse_locktime_string("1y")
assert result > 500000000
def test_parse_plain_int_preserved():
ts = int(time.time()) + 86400
assert Util.parse_locktime_string(str(ts)) == ts
# ------------------------------------------------------------------ #
# get_lowest_locktimes returns sorted list
# ------------------------------------------------------------------ #
def test_get_lowest_locktimes_returns_sorted_list():
result = Util.get_lowest_locktimes(["30d", "90d", "1y"])
assert isinstance(result, list)
assert len(result) == 3
for lt in result:
assert lt > 500000000
assert result == sorted(result)
def test_get_lowest_locktimes_empty():
assert Util.get_lowest_locktimes([]) == []
def test_get_lowest_locktimes_min():
now = int(time.time())
locktimes_list = [now + 86400, now, now + 2 * 86400]
result = Util.get_lowest_locktimes(locktimes_list)
assert result[0] == now
assert result == sorted(result)
# ------------------------------------------------------------------ #
# prepare_lists with multiple locktimes (multiverse)
# ------------------------------------------------------------------ #
def test_prepare_lists_single_locktime():
"""All heirs share the same locktime -> single group."""
wallet = fake_wallet()
h = Heirs.__new__(Heirs)
h.update(make_heir("a", MAINNET_ADDR, 5000, "30d"))
h.update(make_heir("b", MAINNET_ADDR, 3000, "30d"))
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) == 1
locktime = list(result.keys())[0]
assert locktime > 500000000
assert len(result[locktime]) == 2
def test_prepare_lists_multiple_locktimes():
"""Heirs with different locktimes -> multiple groups."""
wallet = fake_wallet()
h = Heirs.__new__(Heirs)
far_future_1 = int(time.time()) + 365 * 86400
far_future_2 = int(time.time()) + 2 * 365 * 86400
h.update(make_heir("a", MAINNET_ADDR, 5000, str(far_future_1)))
h.update(make_heir("b", MAINNET_ADDR, 3000, str(far_future_2)))
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) == 2
locktimes = sorted(result.keys())
assert locktimes[0] == far_future_1
assert locktimes[1] == far_future_2
def test_prepare_lists_mixed_locktimes():
"""Two heirs share a locktime, one has different -> 2 groups."""
wallet = fake_wallet()
h = Heirs.__new__(Heirs)
far_future_1 = int(time.time()) + 365 * 86400
far_future_2 = int(time.time()) + 730 * 86400
h.update(make_heir("a", MAINNET_ADDR, 5000, str(far_future_1)))
h.update(make_heir("b", MAINNET_ADDR, 3000, str(far_future_1)))
h.update(make_heir("c", MAINNET_ADDR, 2000, str(far_future_2)))
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
assert len(result) == 2
two_heir_group = [g for g in result.values() if len(g) == 2]
assert len(two_heir_group) == 1
one_heir_group = [g for g in result.values() if len(g) == 1]
assert len(one_heir_group) == 1
# ------------------------------------------------------------------ #
# get_locktimes from different locktime styles
# ------------------------------------------------------------------ #
def test_get_locktimes_various():
"""Mix of relative and absolute locktimes produces sorted timestamps."""
now = int(time.time())
tomorrow = now + 86400
day_after = now + 2 * 86400
h = Heirs.__new__(Heirs)
h.update(make_heir("a", "addr1", 5000, "1d"))
h.update(make_heir("b", "addr2", 3000, str(day_after)))
h.update(make_heir("c", "addr3", 2000, str(tomorrow)))
locktimes = h.get_locktimes(0)
assert len(locktimes) == 3
for lt in locktimes:
assert lt > 500000000
# ------------------------------------------------------------------ #
# Locktime propagation: tx.nLockTime matches lowest heir locktime
# ------------------------------------------------------------------ #
def make_utxo(txid="deadbeef" * 8, vout=0, value=1_000_000):
"""Build a ``PartialTxInput`` with the minimum fields required by the
transaction builder. ``prepare_transactions`` calls
``tx.remove_signatures()``, so we patch it out here."""
prevout = TxOutpoint(txid=bfh(txid), out_idx=vout)
txin = PartialTxInput(prevout=prevout)
txin._trusted_value_sats = value
return txin
def test_transaction_locktime_matches_lowest_heir():
"""The built tx's nLockTime equals the resolved lowest heir locktime.
With multiple locktime groups only the lowest is processed."""
lt_low = int(time.time()) + 86400
lt_high = int(time.time()) + 365 * 86400
locktimes = {
lt_high: {"b": [MAINNET_ADDR, 10000, str(lt_high)]},
lt_low: {"a": [MAINNET_ADDR, 10000, str(lt_low)]},
}
utxos = [make_utxo("aa" * 32, 0, 500_000)]
fees = {lt_low: 100, lt_high: 100}
with patch("bal.core.heirs.PartialTransaction.remove_signatures"), \
patch("bal.core.heirs.PartialTransaction.txid",
side_effect=["10" * 32, "10" * 32]):
wallet = fake_wallet()
result = prepare_transactions(locktimes, utxos, fees, wallet)
assert len(result) == 1, f"expected 1 tx (lowest locktime), got {len(result)}"
tx = list(result.values())[0]
assert tx.locktime == lt_low
# ------------------------------------------------------------------ #
# Anticipate: build txs, modify heirs, rebuild, verify replacement
# ------------------------------------------------------------------ #
def _make_willitem(tx, txid):
"""Minimal ``WillItem`` without serialization round-trip."""
wi = WillItem.__new__(WillItem)
wi.tx = tx
wi._id = txid
wi.heirs = getattr(tx, "heirs", None)
wi.we = getattr(tx, "willexecutor", None)
wi.status = ""
wi.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
return wi
def test_anticipate_single_heir_locktime_reduction():
"""Old tx is REPLACED when the heir's locktime moves earlier."""
now = int(time.time())
lt_old = now + 10 * 86400
lt_new = now + 3 * 86400
h = Heirs.__new__(Heirs)
h.update(make_heir("a", MAINNET_ADDR, 10000, str(lt_old)))
locktimes_old, _ = h.prepare_lists(100000, 100, fake_wallet())
utxos = [make_utxo("10" * 32, 0, 500_000)]
fees_old = {lt_old: 100}
with patch("bal.core.heirs.PartialTransaction.remove_signatures"), \
patch("bal.core.heirs.PartialTransaction.txid",
side_effect=["66" * 32, "66" * 32]):
txs_old = prepare_transactions(locktimes_old, utxos[:], fees_old, fake_wallet())
old_txid = list(txs_old.keys())[0]
old_wi = _make_willitem(list(txs_old.values())[0], old_txid)
old_wi.set_status("VALID", True)
h2 = Heirs.__new__(Heirs)
h2.update(make_heir("a", MAINNET_ADDR, 10000, str(lt_new)))
locktimes_new, _ = h2.prepare_lists(100000, 100, fake_wallet())
fees_new = {lt_new: 100}
with patch("bal.core.heirs.PartialTransaction.remove_signatures"), \
patch("bal.core.heirs.PartialTransaction.txid",
side_effect=["88" * 32, "88" * 32]):
txs_new = prepare_transactions(locktimes_new, utxos[:], fees_new, fake_wallet())
new_txid = list(txs_new.keys())[0]
new_wi = _make_willitem(list(txs_new.values())[0], new_txid)
new_wi.set_status("VALID", True)
will = {old_txid: old_wi, new_txid: new_wi}
all_inputs = Will.get_all_inputs(will)
Will.search_rai(all_inputs, utxos, will, None)
assert old_wi.get_status("REPLACED"), \
"old tx should be REPLACED (new locktime is earlier)"
assert not old_wi.get_status("VALID"), \
"old tx should no longer be VALID"
assert new_wi.get_status("VALID"), \
"new tx should remain VALID"
assert not new_wi.get_status("REPLACED"), \
"new tx should not be REPLACED"
# ------------------------------------------------------------------ #
# Multiverse default (plugin_base)
# ------------------------------------------------------------------ #
def test_multiverse_default_true():
"""ENABLE_MULTIVERSE should default to True."""
config = MagicMock()
config.get.side_effect = lambda key, default: default
mv = BalConfig(config, "bal_enable_multiverse", True)
assert mv.get() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
obj()
print(f" [OK] {name}")
print("[OK] All multiverse tests passed")

View File

@@ -0,0 +1,223 @@
"""
Test: no invalidation is requested when time passes with unchanged settings.
Scenario:
The user creates a will with RELATIVE locktime ("1y") and RELATIVE threshold
("30d"), signs it and pushes it to the will-executor. After 2 days, they
run Check again. The system must NOT raise any of:
* CheckAliveError (threshold resolved against ``now`` → always future)
* WillExpiredException (locktime resolved against *creation time* →
stable, does not drift)
* WillPostponedException (same locktime → no postpone detected)
This verifies that the "cancel/invalidate" prompt never appears
spuriously when the user simply lets time pass without changing any setting.
"""
import copy
import os
import sys
import time
import itertools
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from electrum import constants
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@pytest.fixture(autouse=True)
def _regtest_network():
previous = constants.net
constants.BitcoinRegtest.set_as_network()
try:
yield
finally:
previous.set_as_network()
import bal_fixtures as fx # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.will import ( # noqa: E402
Will, WillItem, WillExpiredException, WillPostponedException,
)
from bal.core.util import Util # noqa: E402
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.core.plugin_base import BalTimestamp # noqa: E402
from electrum.transaction import PartialTransaction # noqa: E402
DAY = 86400
# ------------------------------------------------------------------ #
# Helpers (same patterns as test_inheritance_flow.py)
# ------------------------------------------------------------------ #
def _willexecutors(specs):
out = {}
for url, address, base_fee in specs:
out[url] = {
"url": url, "address": address,
"base_fee": base_fee, "selected": True, "status": 200,
}
return out
def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100,
no_willexecutor=False):
wallet = fx.fake_wallet()
utxos = fx.make_utxos(4, 5_000_000)
plugin = fx.fake_bal_plugin(willexecutors, no_willexecutor=no_willexecutor)
if from_locktime is None:
from_locktime = int(time.time())
counter = itertools.count(1)
def fake_txid(self):
if not hasattr(self, "_test_txid"):
self._test_txid = f"{next(counter):064x}"
return self._test_txid
with patch.object(Willexecutors, "get_willexecutors",
return_value=willexecutors), \
patch.object(PartialTransaction, "txid", fake_txid):
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
from_locktime)
return txs or {}
def _tx_for_we(txs, url):
for tx in txs.values():
we = getattr(tx, "willexecutor", None)
if we and we.get("url") == url:
return tx
return None
# ------------------------------------------------------------------ #
# The actual test
# ------------------------------------------------------------------ #
def test_no_invalidation_after_two_days_with_relative_settings():
"""No invalidation after 2 days when locktime/threshold are RELATIVE strings.
This is the critical scenario the user reported: a will created with
"1y" locktime and "30d" threshold (both relative), signed and pushed,
should NOT trigger any cancellation request when Check is re-run 2 days
later with no settings changed.
"""
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
# ── 1. Create heirs with RELATIVE locktime "1y" ──────────────────
heirs_dict = {
"alice": [ha[0], "40%", "1y"],
"bob": [ha[1], "30%", "1y"],
"carol": [ha[2], "30%", "1y"],
}
h = Heirs.__new__(Heirs)
dict.__init__(h)
h.db = MagicMock()
h.wallet = MagicMock()
h.update(copy.deepcopy(heirs_dict))
wes = _willexecutors([("https://we.example", we_addr, 100000)])
# ── 2. Build the will ───────────────────────────────────────────
txs = _build(h, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
assert tx is not None, "no will-executor transaction built"
txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid()
# ── 3. Wrap in a WillItem, mark as SIGNED (COMPLETE + PUSHED) ──
# IMPORTANT: use copy.deepcopy(tx.heirs) — the builder stores the
# original percentage strings (not resolved integers), and the
# postpone comparison in check_willexecutors_and_heirs expects
# those same strings so they can be compared with the current
# heirs dict entries.
item = WillItem.__new__(WillItem)
item.tx = tx
item._id = txid
item.heirs = copy.deepcopy(tx.heirs)
item.we = copy.deepcopy(tx.willexecutor)
item.status = ""
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx_fees = 100
item.time = now # will creation time — the stable base for "1y"
item.father = None
item.children = {}
item.description = ""
item.change = ""
item.set_status("VALID", True)
item.set_status("COMPLETE", True) # ← signed
item.set_status("PUSHED", True) # ← sent to will-executor
willitems = {txid: item}
old_locktime = int(tx.locktime)
# ── 4. Baseline: check flow at ORIGINAL time (must pass) ────────
check_date = BalTimestamp("30d").to_timestamp()
assert check_date > now, "baseline: threshold is in the future"
all_inputs = Will.get_all_inputs(willitems, only_valid=True)
all_inputs_min = Will.get_all_inputs_min_locktime(all_inputs)
Will.check_will_expired(all_inputs_min, check_date)
# ── 5. Advance "now" by 2 days ──────────────────────────────────
advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY)
# BalTimestamp("30d").to_timestamp() calls datetime.now() internally.
# Patch only that module's reference so fromtimestamp etc. still work.
import bal.core.plugin_base as pb_mod
import datetime as real_dt
# We need the patched datetime to proxy fromtimestamp/timedelta to the
# real ones, otherwise BalTimestamp._safe_fromtimestamp and timedelta
# arithmetic break.
class _PatchedDt:
"""Minimal proxy: only .now() returns the advanced time, everything
else delegates to the real datetime class."""
@classmethod
def now(cls):
return advanced_now_dt
@classmethod
def fromtimestamp(cls, ts):
return real_dt.datetime.fromtimestamp(ts)
@classmethod
def today(cls):
return advanced_now_dt.date()
_PatchedDt.timedelta = real_dt.timedelta
with patch.object(pb_mod, "datetime", _PatchedDt):
check_date_2d = BalTimestamp("30d").to_timestamp()
# The threshold resolved against the advanced "now" is STILL future.
assert check_date_2d > advanced_now_dt.timestamp(), (
"30d threshold is still in the future even after 2 days"
)
# ── 6. check_will_expired — no drift ────────────────────────────
# The existing tx locktime was resolved from "1y" against from_locktime
# (= the original `now`) during build, so it is immutable. The check
# compares it against check_date_2d (which is *future*), so it never
# raises WillExpiredException.
all_inputs_2d = Will.get_all_inputs(willitems, only_valid=True)
all_inputs_min_2d = Will.get_all_inputs_min_locktime(all_inputs_2d)
Will.check_will_expired(all_inputs_min_2d, check_date_2d)
# ── 7. check_willexecutors_and_heirs — no spurious postpone ─────
# The function resolves "1y" against w.time (= the original `now`),
# giving the same absolute locktime as tx.locktime → no postpone.
Will.check_willexecutors_and_heirs(
willitems, h, wes,
False, # no_willexecutor
check_date_2d,
100,
)
# If we reach here, no invalidation was requested. ✓

View File

@@ -0,0 +1,208 @@
"""
Test: postponing a signed will requests invalidation of the old transaction.
Scenario:
The user has a signed/pushed will with RELATIVE locktime "300d". After 2
days they change the locktime to "1y" (365d, longer → postpone). The
check flow must raise ``WillPostponedException`` because an already-sent
will with an earlier locktime must be invalidated on-chain FIRST (otherwise
a will-executor could broadcast the old tx and execute the inheritance too
early).
At the same time, the threshold check (``CheckAliveError``) must NOT fire
because the threshold is the relative string "30d", which resolves against
``datetime.now()`` → always future.
"""
import copy
import os
import sys
import time
import itertools
import pytest
from unittest.mock import MagicMock, patch
from datetime import datetime
from electrum import constants
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
@pytest.fixture(autouse=True)
def _regtest_network():
previous = constants.net
constants.BitcoinRegtest.set_as_network()
try:
yield
finally:
previous.set_as_network()
import bal_fixtures as fx # noqa: E402
from bal.core.heirs import Heirs # noqa: E402
from bal.core.will import ( # noqa: E402
Will, WillItem, WillPostponedException,
)
from bal.core.willexecutors import Willexecutors # noqa: E402
from bal.core.plugin_base import BalTimestamp # noqa: E402
from electrum.transaction import PartialTransaction # noqa: E402
DAY = 86400
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
def _willexecutors(specs):
out = {}
for url, address, base_fee in specs:
out[url] = {
"url": url, "address": address,
"base_fee": base_fee, "selected": True, "status": 200,
}
return out
def _build(heirs, willexecutors, *, from_locktime=None, tx_fees=100):
wallet = fx.fake_wallet()
utxos = fx.make_utxos(4, 5_000_000)
plugin = fx.fake_bal_plugin(willexecutors)
if from_locktime is None:
from_locktime = int(time.time())
counter = itertools.count(1)
def fake_txid(self):
if not hasattr(self, "_test_txid"):
self._test_txid = f"{next(counter):064x}"
return self._test_txid
with patch.object(Willexecutors, "get_willexecutors",
return_value=willexecutors), \
patch.object(PartialTransaction, "txid", fake_txid):
txs = heirs.get_transactions(plugin, wallet, tx_fees, utxos,
from_locktime)
return txs or {}
def _tx_for_we(txs, url):
for tx in txs.values():
we = getattr(tx, "willexecutor", None)
if we and we.get("url") == url:
return tx
return None
# ------------------------------------------------------------------ #
# The test
# ------------------------------------------------------------------ #
def test_postpone_after_two_days_requests_invalidation():
"""Postponing a signed will after 2 days raises WillPostponedException."""
now = int(time.time())
ha = fx.heir_addresses(3)
we_addr = fx.willexecutor_addresses(1)[0]
# ── 1. Create heirs with INITIAL locktime "300d" ─────────────────
heirs_initial = {
"alice": [ha[0], "40%", "300d"],
"bob": [ha[1], "30%", "300d"],
"carol": [ha[2], "30%", "300d"],
}
h_initial = Heirs.__new__(Heirs)
dict.__init__(h_initial)
h_initial.db = MagicMock()
h_initial.wallet = MagicMock()
h_initial.update(copy.deepcopy(heirs_initial))
wes = _willexecutors([("https://we.example", we_addr, 100000)])
# ── 2. Build the will ───────────────────────────────────────────
txs = _build(h_initial, wes, from_locktime=now)
tx = _tx_for_we(txs, "https://we.example")
assert tx is not None, "no will-executor transaction built"
txid = tx._test_txid if hasattr(tx, "_test_txid") else tx.txid()
# ── 3. Wrap in WillItem, mark SIGNED + PUSHED ───────────────────
item = WillItem.__new__(WillItem)
item.tx = tx
item._id = txid
item.heirs = copy.deepcopy(tx.heirs)
item.we = copy.deepcopy(tx.willexecutor)
item.status = ""
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
item.tx_fees = 100
item.time = now
item.father = None
item.children = {}
item.description = ""
item.change = ""
item.set_status("VALID", True)
item.set_status("COMPLETE", True) # ← signed
item.set_status("PUSHED", True) # ← sent to will-executor
willitems = {txid: item}
old_locktime = int(tx.locktime)
# ── 4. Advance "now" by 2 days ──────────────────────────────────
advanced_now_dt = datetime.fromtimestamp(now + 2 * DAY)
# Patch BalTimestamp's datetime.now() for threshold resolution.
import bal.core.plugin_base as pb_mod
import datetime as real_dt
class _PatchedDt:
@classmethod
def now(cls):
return advanced_now_dt
@classmethod
def fromtimestamp(cls, ts):
return real_dt.datetime.fromtimestamp(ts)
@classmethod
def today(cls):
return advanced_now_dt.date()
_PatchedDt.timedelta = real_dt.timedelta
with patch.object(pb_mod, "datetime", _PatchedDt):
check_date_2d = BalTimestamp("30d").to_timestamp()
# ── 5. Threshold still in future → no CheckAliveError ───────────
assert check_date_2d > advanced_now_dt.timestamp(), (
"30d threshold is still future after 2 days"
)
# ── 6. check_will_expired — old locktime (now+300d) > check_date ─
all_inputs = Will.get_all_inputs(willitems, only_valid=True)
all_inputs_min = Will.get_all_inputs_min_locktime(all_inputs)
Will.check_will_expired(all_inputs_min, check_date_2d)
# ── 7. check_willexecutors_and_heirs with CHANGED locktime ──────
# Change the locktime from "300d" to "1y" (postpone).
heirs_changed = {
"alice": [ha[0], "40%", "1y"],
"bob": [ha[1], "30%", "1y"],
"carol": [ha[2], "30%", "1y"],
}
h_changed = Heirs.__new__(Heirs)
dict.__init__(h_changed)
h_changed.db = MagicMock()
h_changed.wallet = MagicMock()
h_changed.update(copy.deepcopy(heirs_changed))
with pytest.raises(WillPostponedException) as exc_info:
Will.check_willexecutors_and_heirs(
willitems, h_changed, wes,
False, # no_willexecutor
check_date_2d,
100, # tx_fees (unchanged)
)
msg = str(exc_info.value)
assert "postponed" in msg, f"exception should indicate postponement: {msg}"
assert "300d" in msg, (
f"exception should mention the old locktime spec: {msg}"
)
assert "1y" in msg, (
f"exception should mention the new locktime spec: {msg}"
)

View File

@@ -0,0 +1,151 @@
"""
Live will-executor / welist query tests.
These tests contact the real servers documented in the project spec:
* the default will-executor ``https://we.bitcoin-after.life`` (``/bitcoin``)
* the welist ``https://welist.bitcoin-after.life`` (``/data/bitcoin``)
They are automatically **skipped** when the machine is offline or the servers
are unreachable, so the suite never fails spuriously in a sandbox / CI without
internet.
Two things are verified for each endpoint:
1. The live response has the documented JSON shape.
2. The plugin's own parsing logic (``Willexecutors.get_info_task`` /
``Willexecutors.download_list``) consumes that live response correctly.
To test the parsing without needing a running Electrum daemon, the live
JSON is fetched once with ``urllib`` and then fed back through the plugin
by mocking only the transport layer.
"""
import json
import os
import sys
import urllib.error
import urllib.request
import pytest
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.willexecutors import Willexecutors
WE_URL = "https://we.bitcoin-after.life"
WE_INFO_URL = "https://we.bitcoin-after.life/bitcoin/info"
WELIST_URL = "https://welist.bitcoin-after.life"
WELIST_DATA_URL = "https://welist.bitcoin-after.life/data/bitcoin?page=0&limit=100"
HTTP_TIMEOUT = 8
def _fetch_json(url):
"""Fetch and JSON-decode ``url``; skip the test on any network problem."""
req = urllib.request.Request(
url, headers={"User-Agent": "BalPluginTest"}
)
try:
with urllib.request.urlopen(req, timeout=HTTP_TIMEOUT) as resp:
raw = resp.read().decode("utf-8")
except (urllib.error.URLError, OSError, TimeoutError) as e:
pytest.skip(f"network unavailable for {url}: {e}")
try:
return json.loads(raw)
except json.JSONDecodeError as e:
pytest.fail(f"{url} returned non-JSON: {e}: {raw[:200]!r}")
# ------------------------------------------------------------------ #
# Will-executor /info endpoint
# ------------------------------------------------------------------ #
def test_live_willexecutor_info_shape():
"""The live /bitcoin/info reply has the documented fields."""
data = _fetch_json(WE_INFO_URL)
assert isinstance(data, dict)
for key in ("address", "base_fee", "info"):
assert key in data, f"missing '{key}' in info reply: {data}"
assert isinstance(data["base_fee"], int)
assert data["base_fee"] > 0
assert isinstance(data["address"], str) and data["address"]
def test_live_willexecutor_info_parsed_by_plugin():
"""Feed the live info reply through ``get_info_task`` and check it parses."""
data = _fetch_json(WE_INFO_URL)
we = {"url": WE_URL, "selected": True, "status": "New"}
# The plugin reaches the server via send_request -> Network transport. We
# make the transport return exactly what the live server returned, so we
# exercise the real parsing path without a running Electrum daemon.
with patch("bal.core.willexecutors.Network.get_instance",
return_value=MagicMock()), \
patch("bal.core.willexecutors.Network.send_http_on_proxy",
return_value=data):
out = Willexecutors.get_info_task(WE_URL, we, max_retries=0)
assert out["status"] == 200
assert out["base_fee"] == data["base_fee"]
assert out["address"] == data["address"]
assert out["info"] == data["info"]
# ------------------------------------------------------------------ #
# Welist /data/bitcoin endpoint
# ------------------------------------------------------------------ #
def test_live_welist_shape():
"""The live welist reply maps URLs to will-executor descriptors."""
data = _fetch_json(WELIST_DATA_URL)
assert isinstance(data, dict)
# Drop the optional bookkeeping keys before inspecting executors.
executors = {k: v for k, v in data.items() if k not in ("status", "url")}
assert executors, "welist returned no will-executors"
for url, we in executors.items():
assert url.startswith("http"), f"bad executor url: {url}"
assert isinstance(we, dict)
assert "base_fee" in we, f"executor {url} missing base_fee"
assert "url" in we
def test_live_welist_contains_default_executor():
"""The default will-executor should be listed in the welist."""
data = _fetch_json(WELIST_DATA_URL)
assert WE_URL in data, f"{WE_URL} not present in welist: {list(data)[:5]}"
def test_live_welist_parsed_by_plugin():
"""Feed the live welist reply through ``download_list`` and check parsing.
``download_list`` normalises each executor entry (sets url / status /
selected / address) and must not crash on the real payload.
"""
data = _fetch_json(WELIST_DATA_URL)
with patch("bal.core.willexecutors.Network.get_instance",
return_value=MagicMock()), \
patch("bal.core.willexecutors.Network.send_http_on_proxy",
return_value=data):
out = Willexecutors.download_list({}, WELIST_URL)
assert isinstance(out, dict)
executors = {k: v for k, v in out.items() if k not in ("status", "url")}
assert executors, "download_list produced no executors"
for url, we in executors.items():
# initialize_willexecutor must have stamped the normalised fields.
assert we["url"] == url
assert "selected" in we
assert "address" in we
assert "status" in we
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
try:
obj()
print(f" [OK] {name}")
except Exception as e: # pragma: no cover
print(f" [FAIL/SKIP] {name}: {e}")
print("[done] live willexecutor tests")

View File

@@ -0,0 +1,97 @@
"""
Regression test for the Windows year-2038 OverflowError crash.
Background
----------
On Windows ``time_t`` is 32-bit, so ``datetime.fromtimestamp(ts)`` raises
``OverflowError: Python int too large to convert to C int`` for any timestamp
past 2038 (e.g. ``NLOCKTIME_MAX = 2**32 - 1``, used as the default/sentinel
locktime). On 64-bit Linux the same call succeeds, which is why the bug only
showed up on the user's Windows build: ``BalWindow.__init__`` ->
``create_heirs_tab`` -> ``WillSettingsWidget`` -> ``on_locktime_change`` ->
``BalTimestamp.to_date`` -> ``datetime.fromtimestamp(NLOCKTIME_MAX)`` crashed,
which aborted ``init_menubar`` / ``load_wallet`` and left the Will/Heirs tabs
and the menu entry half-built (the garbled/condensed element under the logo).
This test forces ``datetime.fromtimestamp`` to behave like the Windows 32-bit
implementation, then exercises ``BalTimestamp`` with NLOCKTIME_MAX to prove the
overflow-safe conversion no longer raises and clamps to INT32_MAX.
Run with:
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
python3 tests/windows_overflow_test.py <PLUGIN_IMPORT_NAME>
"""
import datetime as _datetime_mod
import importlib
import sys
PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal"
INT32_MAX = 2 ** 31 - 1
NLOCKTIME_MAX = 2 ** 32 - 1 # 4294967295, the value seen in the crash log
_real_datetime = _datetime_mod.datetime
class _WindowsLikeDatetime(_real_datetime):
"""A datetime subclass whose fromtimestamp emulates Windows' 32-bit limit."""
@classmethod
def fromtimestamp(cls, ts, tz=None):
if tz is None and (ts > INT32_MAX or ts < 0):
raise OverflowError("Python int too large to convert to C int")
return _real_datetime.fromtimestamp(ts, tz)
def main():
plugin_base = importlib.import_module(f"{PKG}.core.plugin_base")
BalTimestamp = plugin_base.BalTimestamp
# 1) Sanity: with the real (Linux 64-bit) datetime, large ts works already.
bt = BalTimestamp(NLOCKTIME_MAX)
d = bt.to_date()
assert isinstance(d, _real_datetime), d
print("[OK] BalTimestamp(NLOCKTIME_MAX).to_date() works on this platform")
# 2) Now emulate Windows: patch datetime in the plugin_base module so that
# fromtimestamp raises OverflowError past 2038, exactly like Windows.
original = plugin_base.datetime
plugin_base.datetime = _WindowsLikeDatetime
try:
# 2a) Absolute sentinel timestamp (the exact crash path from the log).
bt = BalTimestamp(NLOCKTIME_MAX)
d = bt.to_date() # must NOT raise OverflowError anymore
assert d.year <= 2038, f"expected clamp to <=2038, got {d!r}"
print("[OK] to_date(NLOCKTIME_MAX) no longer raises (clamped to INT32_MAX)")
# 2b) to_timestamp must also be safe.
ts = bt.to_timestamp()
assert ts <= INT32_MAX, ts
print("[OK] to_timestamp(NLOCKTIME_MAX) clamped & safe")
# 2c) __str__ / __repr__ must not raise either.
_ = str(bt)
_ = repr(bt)
print("[OK] str()/repr() on out-of-range timestamp are safe")
# 2d) Relative durations that overflow when added (e.g. huge 'd').
bt_rel = BalTimestamp(f"{10 ** 9}d") # ~2.7M years -> overflow
d2 = bt_rel.to_date()
assert d2 is not None
print("[OK] huge relative duration no longer raises")
# 2e) Normal values are unchanged (behaviour-preserving check).
bt_norm = BalTimestamp("90d")
d3 = bt_norm.to_date()
# 90 days from now, normalised to midnight
assert d3.hour == 0 and d3.minute == 0 and d3.second == 0
print("[OK] normal '90d' value still resolves to a midnight datetime")
finally:
plugin_base.datetime = original
print(f"\n[OK] Windows overflow regression passed for package {PKG!r}")
return 0
if __name__ == "__main__":
sys.exit(main())