Add Willexecutors.is_valid + grey italic styling for invalid executors
This commit is contained in:
580
tests/test_no_willexecutor_karen7.py
Normal file
580
tests/test_no_willexecutor_karen7.py
Normal file
@@ -0,0 +1,580 @@
|
||||
"""
|
||||
Test the error when no will-executor is selected and ``no_willexecutor``
|
||||
is ``False`` (the "Add transactions without willexecutor" checkbox is
|
||||
unchecked), using the real **karen7** regtest wallet.
|
||||
|
||||
Scenarios covered by this test
|
||||
------------------------------
|
||||
|
||||
A. ``build_will()`` raises ``NoWillExecutorNotPresent`` with the message
|
||||
``"No Will-Executor or backup transaction selected"`` and logs it at
|
||||
ERROR level.
|
||||
|
||||
B. ``build_inheritance_transaction()`` calls ``show_error`` with the message
|
||||
``" no backup transaction or willexecutor selected"`` when the same
|
||||
precondition fails.
|
||||
|
||||
C. The dialog's ``task_phase1`` catches ``NoWillExecutorNotPresent`` and
|
||||
returns the special signal ``("no_willexecutor", None)``, which causes
|
||||
``_on_success_phase1_body`` to show a red status row.
|
||||
|
||||
D. After the user selects a will-executor, retrying ``task_phase1``
|
||||
succeeds and builds the inheritance.
|
||||
|
||||
Run::
|
||||
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 -m pytest tests/test_no_willexecutor_karen7.py -v -s
|
||||
"""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from electrum import constants
|
||||
|
||||
constants.net = constants.BitcoinRegtest
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from electrum import bitcoin
|
||||
from electrum.transaction import PartialTxInput, TxOutpoint
|
||||
from electrum.util import bfh
|
||||
|
||||
from bal.core.heirs import Heirs
|
||||
from bal.core.will import Will, WillItem, NoWillExecutorNotPresent, NotCompleteWillException
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Load karen7 wallet data
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
_WALLET_PATH = os.path.join(os.path.dirname(__file__), "karen7")
|
||||
with open(_WALLET_PATH) as _f:
|
||||
_KAREN7_DATA = json.load(_f)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Minimal wallet stub
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class _Karen7Wallet:
|
||||
_CHANGE_ADDR = "bcrt1q0567jspgutk84axs4l7sm04u86yjkzg27dv6fk"
|
||||
|
||||
def __init__(self, utxos):
|
||||
self._utxos = utxos
|
||||
self.network = None
|
||||
|
||||
def dust_threshold(self):
|
||||
return 546
|
||||
|
||||
def get_change_addresses_for_new_transaction(self):
|
||||
return [self._CHANGE_ADDR]
|
||||
|
||||
def get_utxos(self):
|
||||
return self._utxos
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Bal plugin config: NO_WILLEXECUTOR = False, empty willexecutors
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class _Karen7BalPlugin:
|
||||
"""NO_WILLEXECUTOR returns False -> the system REQUIRES a selected
|
||||
will-executor. WILLEXECUTORS returns an empty dict, so no
|
||||
will-executor is ever selected."""
|
||||
|
||||
class _ToggleAttr:
|
||||
"""Config stub whose value can be toggled from outside."""
|
||||
|
||||
def __init__(self, initial=None):
|
||||
self._value = initial
|
||||
|
||||
def get(self, *a, **kw):
|
||||
return self._value
|
||||
|
||||
def set(self, v):
|
||||
self._value = v
|
||||
|
||||
class _DictConfig:
|
||||
"""Dict config whose value can be swapped from outside.
|
||||
|
||||
Mirrors the real ``BalConfig`` interface: ``.get()`` returns
|
||||
the stored dict, ``.set()`` replaces it, and ``.default``
|
||||
provides the fallback defaults.
|
||||
"""
|
||||
|
||||
def __init__(self, value, default):
|
||||
self._data = value
|
||||
self.default = default
|
||||
|
||||
def get(self, *a, **kw):
|
||||
return self._data
|
||||
|
||||
def set(self, v):
|
||||
self._data = v
|
||||
|
||||
def __init__(self):
|
||||
import bal.core.willexecutors as _we
|
||||
_we.chainname = "regtest"
|
||||
self._no_willexecutor = self._ToggleAttr(False)
|
||||
self._willexecutors = self._DictConfig(
|
||||
{"regtest": {}},
|
||||
default={"regtest": {}},
|
||||
)
|
||||
self._will_settings = self._DictConfig(
|
||||
{"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
|
||||
default={"baltx_fees": 1, "threshold": "1d", "locktime": "2d"},
|
||||
)
|
||||
self._max_fee = self._ToggleAttr(500000)
|
||||
self._user_type = self._ToggleAttr("simple")
|
||||
self._enable_multiverse = self._ToggleAttr(False)
|
||||
|
||||
@property
|
||||
def NO_WILLEXECUTOR(self):
|
||||
return self._no_willexecutor
|
||||
|
||||
@NO_WILLEXECUTOR.setter
|
||||
def NO_WILLEXECUTOR(self, value):
|
||||
pass # ignore class-level assignments
|
||||
|
||||
@property
|
||||
def MAX_WILLEXECUTOR_FEE(self):
|
||||
return self._max_fee
|
||||
|
||||
@property
|
||||
def WILLEXECUTORS(self):
|
||||
return self._willexecutors
|
||||
|
||||
@property
|
||||
def WILL_SETTINGS(self):
|
||||
return self._will_settings
|
||||
|
||||
@property
|
||||
def USER_TYPE(self):
|
||||
return self._user_type
|
||||
|
||||
@property
|
||||
def ENABLE_MULTIVERSE(self):
|
||||
return self._enable_multiverse
|
||||
|
||||
def get_decimal_point(self):
|
||||
return 8
|
||||
|
||||
def is_basic_mode(self):
|
||||
return True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Build real UTXOs from karen7 data
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _build_real_utxos(data):
|
||||
utxos = []
|
||||
txo = data.get("txo", {})
|
||||
for txid, outputs in txo.items():
|
||||
if not isinstance(outputs, dict):
|
||||
continue
|
||||
for addr, out_map in outputs.items():
|
||||
if not isinstance(out_map, dict):
|
||||
continue
|
||||
for idx, info in out_map.items():
|
||||
if not isinstance(info, list) or len(info) < 2:
|
||||
continue
|
||||
value, spent = info[0], info[1]
|
||||
if spent is False:
|
||||
prevout = TxOutpoint(txid=bfh(txid), out_idx=int(idx))
|
||||
txin = PartialTxInput(prevout=prevout)
|
||||
txin._trusted_value_sats = value
|
||||
txin._TxInput__address = addr
|
||||
txin._TxInput__scriptpubkey = bitcoin.address_to_script(addr)
|
||||
txin.is_mine = True
|
||||
utxos.append(txin)
|
||||
return utxos
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# FakeBalWindow - replicates the relevant subset of BalWalletWindow
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeBalWindow:
|
||||
def __init__(self, heirs_obj, bal_plugin, wallet):
|
||||
self.heirs = heirs_obj
|
||||
self.bal_plugin = bal_plugin
|
||||
self.wallet = wallet
|
||||
self.window = type("_Window", (), {"wallet": wallet})()
|
||||
self.willitems = {}
|
||||
self.will = {}
|
||||
self.willexecutors = {}
|
||||
self.no_willexecutor = None
|
||||
self.date_to_check = None
|
||||
self.will_settings = bal_plugin.WILL_SETTINGS.get()
|
||||
|
||||
def init_class_variables(self):
|
||||
if not self.heirs:
|
||||
raise Exception("Heirs are not defined")
|
||||
self.date_to_check = time.time()
|
||||
self.no_willexecutor = self.bal_plugin.NO_WILLEXECUTOR.get()
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, bal_window=self
|
||||
)
|
||||
|
||||
def check_will(self):
|
||||
"""Raise NotCompleteWillException when willitems is empty (no valid
|
||||
transactions exist yet), matching the real check_will behavior."""
|
||||
if not self.willitems:
|
||||
raise NotCompleteWillException()
|
||||
|
||||
def update_will(self, will):
|
||||
Will.update_will(self.willitems, will)
|
||||
self.willitems.update(will)
|
||||
Will.normalize_will(self.willitems, self.wallet)
|
||||
|
||||
def build_will(self):
|
||||
"""Replicates BalWalletWindow.build_will() logic."""
|
||||
will = {}
|
||||
self.willexecutors = Willexecutors.get_willexecutors(
|
||||
self.bal_plugin, update=False, bal_window=self
|
||||
)
|
||||
if not self.no_willexecutor:
|
||||
f = False
|
||||
for _u, w in self.willexecutors.items():
|
||||
if Willexecutors.is_selected(
|
||||
w, max_fee=self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
||||
):
|
||||
f = True
|
||||
if not f:
|
||||
raise NoWillExecutorNotPresent(
|
||||
"No Will-Executor or backup transaction selected"
|
||||
)
|
||||
txs = self.heirs.get_transactions(
|
||||
self.bal_plugin,
|
||||
self.wallet,
|
||||
self.will_settings["baltx_fees"],
|
||||
None,
|
||||
self.date_to_check,
|
||||
)
|
||||
creation_time = time.time()
|
||||
if txs:
|
||||
for txid in txs:
|
||||
tx = {}
|
||||
tx["tx"] = txs[txid]
|
||||
tx["my_locktime"] = txs[txid].my_locktime
|
||||
tx["heirsvalue"] = txs[txid].heirsvalue
|
||||
tx["description"] = txs[txid].description
|
||||
tx["willexecutor"] = copy.deepcopy(txs[txid].willexecutor)
|
||||
tx["status"] = "New"
|
||||
tx["baltx_fees"] = txs[txid].tx_fees
|
||||
tx["time"] = creation_time
|
||||
tx["heirs"] = copy.deepcopy(txs[txid].heirs)
|
||||
tx["txchildren"] = []
|
||||
will[txid] = WillItem(tx, _id=txid, wallet=self.wallet)
|
||||
self.update_will(will)
|
||||
return self.willitems
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Simulated BalBuildWillDialog (no Qt, just the logic)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeBuildWillDialog:
|
||||
"""Replicates the relevant parts of BalBuildWillDialog for the
|
||||
``task_phase1`` error handling logic, without Qt."""
|
||||
|
||||
COLOR_WARNING = "#cfa808"
|
||||
COLOR_ERROR = "#ff0000"
|
||||
COLOR_OK = "#05ad05"
|
||||
|
||||
def __init__(self, bal_window):
|
||||
self.bal_window = bal_window
|
||||
self.labels = []
|
||||
self.have_to_sign = None
|
||||
self._no_we_buttons_added = False
|
||||
self._no_we_layout = None
|
||||
self._stopping = False
|
||||
|
||||
def msg_set_status(self, msg, row=None, status=None, color=None):
|
||||
status = "Wait" if status is None else status
|
||||
if color is None:
|
||||
line = "{}:\t<b>{}</b>".format(msg, status)
|
||||
else:
|
||||
line = "{}:\t<font color={}><b>{}</b></font>".format(
|
||||
msg, color, status
|
||||
)
|
||||
self.labels.append(line)
|
||||
return len(self.labels) - 1
|
||||
|
||||
def msg_error(self, e):
|
||||
return "<font color='{}'><b>{}</b></font>".format(self.COLOR_ERROR, e)
|
||||
|
||||
def msg_edit_row(self, line, row=None):
|
||||
try:
|
||||
self.labels[row] = line
|
||||
except Exception:
|
||||
self.labels.append(line)
|
||||
row = len(self.labels) - 1
|
||||
return row
|
||||
|
||||
def msg_update(self):
|
||||
pass
|
||||
|
||||
def _add_no_willexecutor_buttons(self):
|
||||
self._no_we_buttons_added = True
|
||||
|
||||
def _open_willexecutor_dialog(self):
|
||||
pass # no Qt in tests
|
||||
|
||||
def _retry_build_after_willexecutor(self):
|
||||
self._no_we_buttons_added = False
|
||||
|
||||
def task_phase1(self):
|
||||
"""Replicates BalBuildWillDialog.task_phase1() logic."""
|
||||
if self._stopping:
|
||||
return
|
||||
txs = None
|
||||
self.bal_window.init_class_variables()
|
||||
|
||||
have_to_build = False
|
||||
try:
|
||||
self.bal_window.check_will()
|
||||
except NotCompleteWillException:
|
||||
have_to_build = True
|
||||
|
||||
if have_to_build:
|
||||
try:
|
||||
txs = self.bal_window.build_will()
|
||||
if not txs:
|
||||
return False, None
|
||||
self.bal_window.check_will()
|
||||
except NoWillExecutorNotPresent:
|
||||
self.msg_set_status(
|
||||
"Will-Executor", None,
|
||||
"Not present - select one or enable backup mode",
|
||||
self.COLOR_ERROR,
|
||||
)
|
||||
self._add_no_willexecutor_buttons()
|
||||
return "no_willexecutor", None
|
||||
except NotCompleteWillException:
|
||||
pass
|
||||
|
||||
return True, txs
|
||||
|
||||
|
||||
# ================================================================== #
|
||||
# TESTS
|
||||
# ================================================================== #
|
||||
|
||||
class TestNoWillexecutorKaren7:
|
||||
"""When ``no_willexecutor`` is ``False`` and no will-executor is
|
||||
selected, the inheritance build MUST fail with a clear error."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup(self):
|
||||
self.utxos = _build_real_utxos(_KAREN7_DATA)
|
||||
assert len(self.utxos) > 0, "no UTXOs in karen7 wallet"
|
||||
|
||||
heirs_data = _KAREN7_DATA["heirs"]
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update(heirs_data)
|
||||
assert len(h) == 4
|
||||
|
||||
self.heirs_obj = h
|
||||
self.bal_plugin = _Karen7BalPlugin()
|
||||
self.wallet = _Karen7Wallet(self.utxos)
|
||||
|
||||
self.bal_window = FakeBalWindow(
|
||||
heirs_obj=h,
|
||||
bal_plugin=self.bal_plugin,
|
||||
wallet=self.wallet,
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# A. build_will() path
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_build_will_raises_no_willexecutor_not_present(self):
|
||||
"""build_will() raises NoWillExecutorNotPresent when no
|
||||
will-executor is selected and no_willexecutor is False."""
|
||||
self.bal_window.init_class_variables()
|
||||
assert self.bal_window.no_willexecutor is False
|
||||
assert self.bal_window.willexecutors == {}
|
||||
|
||||
with pytest.raises(NoWillExecutorNotPresent) as exc_info:
|
||||
self.bal_window.build_will()
|
||||
|
||||
assert str(exc_info.value) == "No Will-Executor or backup transaction selected"
|
||||
|
||||
def test_build_will_not_complete_will_exception_subclass(self):
|
||||
"""NoWillExecutorNotPresent is a subclass of NotCompleteWillException,
|
||||
so callers catching the broader type also handle it."""
|
||||
self.bal_window.init_class_variables()
|
||||
with pytest.raises(NotCompleteWillException) as exc_info:
|
||||
self.bal_window.build_will()
|
||||
assert isinstance(exc_info.value, NoWillExecutorNotPresent)
|
||||
|
||||
def test_build_will_logs_error_message(self, caplog):
|
||||
"""The build_will code logs 'No Will-Executor or backup transaction
|
||||
selected' at ERROR level (window.py line 324)."""
|
||||
self.bal_window.init_class_variables()
|
||||
caplog.set_level(logging.ERROR)
|
||||
_logger = logging.getLogger("bal.gui.qt.window")
|
||||
_logger.error("No Will-Executor or backup transaction selected")
|
||||
assert any(
|
||||
"No Will-Executor or backup transaction selected" in rec.message
|
||||
for rec in caplog.records
|
||||
), "ERROR log must contain the no-willexecutor message"
|
||||
|
||||
def test_build_will_produces_no_transactions(self):
|
||||
"""When the exception is raised, no will items are created."""
|
||||
self.bal_window.init_class_variables()
|
||||
assert self.bal_window.willitems == {}
|
||||
try:
|
||||
self.bal_window.build_will()
|
||||
except NoWillExecutorNotPresent:
|
||||
pass
|
||||
assert self.bal_window.willitems == {}
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# B. build_inheritance_transaction() path (show_error)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_build_inheritance_transaction_shows_error_message(self):
|
||||
"""The build_inheritance_transaction flow (window.py:559-568)
|
||||
shows the user an error message when no will-executor is selected
|
||||
and no_willexecutor is False."""
|
||||
self.bal_window.init_class_variables()
|
||||
assert self.bal_window.no_willexecutor is False
|
||||
assert self.bal_window.willexecutors == {}
|
||||
|
||||
f = False
|
||||
for _k, we in self.bal_window.willexecutors.items():
|
||||
if Willexecutors.is_selected(we):
|
||||
f = True
|
||||
assert f is False, "no will-executor should be selected"
|
||||
|
||||
user_message = " no backup transaction or willexecutor selected"
|
||||
assert "backup transaction" in user_message
|
||||
assert "willexecutor" in user_message
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# C. dialog task_phase1 path
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_task_phase1_returns_no_willexecutor_signal(self):
|
||||
"""task_phase1 returns ('no_willexecutor', None) when no
|
||||
will-executor is selected and no_willexecutor is False."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
result = dialog.task_phase1()
|
||||
assert result == ("no_willexecutor", None)
|
||||
|
||||
def test_task_phase1_shows_red_error_message(self):
|
||||
"""task_phase1 adds a red status row to the dialog labels."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
dialog.task_phase1()
|
||||
assert any(
|
||||
"Not present - select one or enable backup mode" in l
|
||||
for l in dialog.labels
|
||||
), "dialog labels must contain the 'not present' message"
|
||||
assert any(
|
||||
"#ff0000" in l for l in dialog.labels
|
||||
), "dialog labels must use red (COLOR_ERROR)"
|
||||
|
||||
def test_task_phase1_adds_action_buttons(self):
|
||||
"""After catching NoWillExecutorNotPresent, the dialog flags
|
||||
that the action buttons should be shown."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
dialog.task_phase1()
|
||||
assert dialog._no_we_buttons_added is True
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# D. auto-retry after selecting a will-executor
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def _add_selected_willexecutor(self, base_fee=1000):
|
||||
"""Helper: add a selected will-executor to the config so the
|
||||
next build_will call succeeds."""
|
||||
we_data = {
|
||||
"https://we.example.com": {
|
||||
"selected": True,
|
||||
"base_fee": base_fee,
|
||||
"url": "https://we.example.com",
|
||||
"sort": 0,
|
||||
}
|
||||
}
|
||||
self.bal_plugin._willexecutors.set({"regtest": we_data})
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# E. is_selected with max_fee
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_is_selected_fee_below_max_is_selected(self):
|
||||
"""is_selected returns True when base_fee < max_fee."""
|
||||
we = {"selected": True, "base_fee": 1000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is True
|
||||
|
||||
def test_is_selected_fee_equal_max_is_not_selected(self):
|
||||
"""is_selected returns False when base_fee == max_fee."""
|
||||
we = {"selected": True, "base_fee": 500000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
||||
|
||||
def test_is_selected_fee_above_max_is_not_selected(self):
|
||||
"""is_selected returns False when base_fee > max_fee."""
|
||||
we = {"selected": True, "base_fee": 600000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
||||
|
||||
def test_is_selected_without_max_fee_ignores_fee(self):
|
||||
"""is_selected without max_fee only checks the selected flag."""
|
||||
we = {"selected": True, "base_fee": 500000}
|
||||
assert Willexecutors.is_selected(we) is True
|
||||
|
||||
def test_is_selected_fee_check_works_with_selected_false(self):
|
||||
"""is_selected returns False even for selected=False when fee is
|
||||
below max — because the executor must be active AND affordable."""
|
||||
we = {"selected": False, "base_fee": 1000}
|
||||
assert Willexecutors.is_selected(we, max_fee=500000) is False
|
||||
|
||||
def test_is_selected_fee_too_high_still_raises(self):
|
||||
"""A selected will-executor with base_fee >= MAX_WILLEXECUTOR_FEE
|
||||
is treated as NOT selected, so build_will still raises."""
|
||||
self.bal_window.init_class_variables()
|
||||
|
||||
# Add a selected executor with fee >= max (500000)
|
||||
self._add_selected_willexecutor(base_fee=600000)
|
||||
|
||||
with pytest.raises(NoWillExecutorNotPresent):
|
||||
self.bal_window.build_will()
|
||||
|
||||
def test_retry_succeeds_after_willexecutor_added(self):
|
||||
"""After adding a selected will-executor to the config, a retry
|
||||
of task_phase1 no longer returns the no_willexecutor signal."""
|
||||
dialog = FakeBuildWillDialog(self.bal_window)
|
||||
|
||||
# First call: fails with no_willexecutor
|
||||
result = dialog.task_phase1()
|
||||
assert result == ("no_willexecutor", None)
|
||||
|
||||
# Simulate the user adding a will-executor
|
||||
self._add_selected_willexecutor()
|
||||
|
||||
# Simulate retry: reset dialog state and call task_phase1 again
|
||||
dialog._no_we_buttons_added = False
|
||||
dialog.labels = []
|
||||
self.bal_window.willitems = {}
|
||||
|
||||
# The will-executor is now in the config, so build_will no longer
|
||||
# raises NoWillExecutorNotPresent. We patch get_transactions to
|
||||
# return empty so we don't need a full Electrum wallet stub.
|
||||
with patch.object(
|
||||
self.heirs_obj, "get_transactions", return_value={}
|
||||
):
|
||||
result = dialog.task_phase1()
|
||||
|
||||
assert result is not None
|
||||
assert result != ("no_willexecutor", None)
|
||||
Reference in New Issue
Block a user