forked from bitcoinafterlife/bal-electrum-plugin
add tests
This commit is contained in:
134
tests/bal_fixtures.py
Normal file
134
tests/bal_fixtures.py
Normal 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
|
||||
64
tests/external_zip_test.py
Normal file
64
tests/external_zip_test.py
Normal 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
152
tests/gui_fixes_test.py
Normal 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
353
tests/parallel_ping_test.py
Normal 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())
|
||||
110
tests/preview_build_will_dialog.py
Normal file
110
tests/preview_build_will_dialog.py
Normal 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"))
|
||||
105
tests/preview_building_will_close_btn.py
Normal file
105
tests/preview_building_will_close_btn.py
Normal 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
105
tests/preview_we_rows.py
Normal 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"))
|
||||
139
tests/preview_wizard_button.py
Normal file
139
tests/preview_wizard_button.py
Normal 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()
|
||||
151
tests/preview_wizard_settings_align.py
Normal file
151
tests/preview_wizard_settings_align.py
Normal 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
171
tests/sim_update_flows.py
Normal 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
92
tests/smoke_test.py
Normal 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()
|
||||
232
tests/test_anticipate_no_invalidation.py
Normal file
232
tests/test_anticipate_no_invalidation.py
Normal 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
|
||||
219
tests/test_calendar_event.py
Normal file
219
tests/test_calendar_event.py
Normal 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
293
tests/test_connectivity.py
Normal 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
266
tests/test_core_heirs.py
Normal 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")
|
||||
182
tests/test_core_heirs_extra.py
Normal file
182
tests/test_core_heirs_extra.py
Normal 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")
|
||||
259
tests/test_core_plugin_base.py
Normal file
259
tests/test_core_plugin_base.py
Normal 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
462
tests/test_core_util.py
Normal 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
378
tests/test_core_will.py
Normal 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")
|
||||
184
tests/test_core_will_extra.py
Normal file
184
tests/test_core_will_extra.py
Normal 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
142
tests/test_gui_calendar.py
Normal 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
102
tests/test_gui_common.py
Normal 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
100
tests/test_gui_theme.py
Normal 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
255
tests/test_gui_widgets.py
Normal 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")
|
||||
103
tests/test_gui_window_utils.py
Normal file
103
tests/test_gui_window_utils.py
Normal 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")
|
||||
610
tests/test_inheritance_flow.py
Normal file
610
tests/test_inheritance_flow.py
Normal 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")
|
||||
543
tests/test_inheritance_rules.py
Normal file
543
tests/test_inheritance_rules.py
Normal 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")
|
||||
281
tests/test_mock_inheritance.py
Normal file
281
tests/test_mock_inheritance.py
Normal 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")
|
||||
276
tests/test_modify_heir_triggers_rebuild.py
Normal file
276
tests/test_modify_heir_triggers_rebuild.py
Normal 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
285
tests/test_multiverse.py
Normal 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")
|
||||
223
tests/test_no_invalidation_after_time.py
Normal file
223
tests/test_no_invalidation_after_time.py
Normal 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. ✓
|
||||
208
tests/test_postpone_requests_invalidation.py
Normal file
208
tests/test_postpone_requests_invalidation.py
Normal 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}"
|
||||
)
|
||||
151
tests/test_willexecutors_live.py
Normal file
151
tests/test_willexecutors_live.py
Normal 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")
|
||||
97
tests/windows_overflow_test.py
Normal file
97
tests/windows_overflow_test.py
Normal 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())
|
||||
Reference in New Issue
Block a user