gui+cli+core: anticipated-delivery warning, task #67 amount checks, CLI tools

GUI - "Building your will" row (owner request):
- when a rebuild anticipates the delivery date by one day, show
  "Anticipated - new delivery date: <date>" on the "Building your will" row
  instead of a plain "Ok";
- the detection compares the will's minimum locktime BEFORE and AFTER the
  rebuild, so it also covers relative recipes ("1y"/"30d") that the previous
  stored-vs-built comparison missed;
- the sign explanation stays on its own "Signing your will" row so it no
  longer overwrites the anticipation warning.

Task #67 - "Checking variables" stuck on "Wait":
- core: Will.check_amounts only requires a fixed amount to be funded when it
  is > 0, and only requires percentages to total 100% when the will actually
  has percentage heirs; fixed-only / empty-wallet wills no longer raise;
- gui: the amount warning is written on the "Checking variables" row itself
  (COLOR_WARNING) instead of being overwritten by the next row; the redundant
  "adjustment of the amounts" sentence was dropped.

CLI - will-executor helpers:
- willexecutors_update accepts promo_code;
- willexecutors_select supports bulk apply with only_valid;
- willexecutors_export supports selected/valid subsets;
- import overwrites existing records with sane defaults;
- raise NoServersForChainError with a clear user-facing message; map
  NotCompleteWill / HeirChange / WillexecutorChange in _user_facing.

Tests and docs:
- tests for the anticipation row, the CLI helpers and the amount checks;
- i18n catalogs refreshed; CHANGELOG section 67; agent task memory updated.
This commit is contained in:
2026-10-15 21:55:03 -04:00
parent c9f3f77a15
commit f3deccb626
14 changed files with 1396 additions and 1010 deletions

View File

@@ -25,9 +25,14 @@ sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.simple_config import SimpleConfig
from electrum.util import UserFacingException
from bal.cli.controller import BalController
from bal.cli.controller import BalController, _user_facing
from bal.core.heirs import Heirs
from bal.core.util import Util
from bal.core.will import (
HeirChangeException,
NotCompleteWillException,
WillexecutorChangeException,
)
VALID_ADDRESS = "bc1qusymuetsz2psaqzqxv8qmzcy64d9meckj3lxxf"
@@ -102,6 +107,13 @@ def _make_controller(plugin):
return BalController(plugin, FakeWallet())
def _tmp_path():
"""Return the path of a fresh temporary JSON file (caller removes it)."""
fd, path = tempfile.mkstemp(prefix="bal_cli_", suffix=".json")
os.close(fd)
return path
def test_controller_init_empty():
with Plugin() as plugin:
c = _make_controller(plugin)
@@ -194,6 +206,179 @@ def test_willexecutors_crud():
assert renamed not in c.willexecutors
def test_willexecutors_update_accepts_promo_code():
"""Regression: the command transport used to forward six positional args to
a five-parameter controller method, raising TypeError. ``promo_code`` must
also be applied to the record instead of being silently dropped.
"""
with Plugin() as plugin:
c = _make_controller(plugin)
url = "https://executor-promo.example.invalid"
c.willexecutors_add(url, address="", base_fee=250)
# Positional call mirroring the transport layer: (url, address,
# base_fee, info, promo_code, rename_to).
result = c.willexecutors_update(
url, "", "300", "Promo executor", "PROMO10", None
)
assert result["willexecutor"]["base_fee"] == 300
assert result["willexecutor"]["info"] == "Promo executor"
assert c.willexecutors[url]["promo_code"] == "PROMO10"
def test_heirs_locktime_forced_when_multiverse_disabled():
"""When multiverse is disabled the heir locktime follows the will locktime,
exactly like the GUI's ``set_heir`` (which forces ``heir[3]``)."""
with Plugin() as plugin:
plugin.WILL_SETTINGS.set(
{"threshold": "-150d", "locktime": "2y", "baltx_fees": 20}
)
plugin.ENABLE_MULTIVERSE.set(False)
c = _make_controller(plugin)
c.heirs_add("bob", VALID_ADDRESS, "100000", locktime="30d")
assert c.heirs["bob"][2] == "2y"
def test_heirs_locktime_kept_when_multiverse_enabled():
with Plugin() as plugin:
plugin.WILL_SETTINGS.set({"threshold": "-150d", "locktime": "2y", "baltx_fees": 20})
plugin.ENABLE_MULTIVERSE.set(True)
c = _make_controller(plugin)
c.heirs_add("carol", VALID_ADDRESS, "100000", locktime="30d")
assert c.heirs["carol"][2] == "30d"
def test_user_facing_maps_not_complete_will():
"""The check flow must translate every NotCompleteWillException subtype to a
user-facing message instead of leaking the raw internal exception.
"""
assert "heirs changed" in str(_user_facing(HeirChangeException())).lower()
assert "will-executor changed" in str(
_user_facing(WillexecutorChangeException())
).lower()
assert "no valid transaction" in str(
_user_facing(NotCompleteWillException())
).lower()
assert "does not cover" in str(
_user_facing(NotCompleteWillException("utxo"))
).lower()
def test_willexecutors_delete_missing_is_noop():
with Plugin() as plugin:
c = _make_controller(plugin)
assert c.willexecutors_delete(["https://missing.example.invalid"]) == {
"deleted": []
}
def test_willexecutors_select_only_valid():
with Plugin() as plugin:
c = _make_controller(plugin)
valid_url = "https://valid.example.invalid"
invalid_url = "https://invalid.example.invalid"
# Valid: real address, base_fee within [dust, max_fee].
c.willexecutors_add(valid_url, address=VALID_ADDRESS, base_fee=1000)
# Invalid: no address.
c.willexecutors_add(invalid_url, address="", base_fee=1000)
c.willexecutors_select(None, select=True, only_valid=True)
assert c.willexecutors[valid_url]["selected"] is True
assert c.willexecutors[invalid_url]["selected"] is False
# Select everything, then deselect only-valid: the valid one keeps its
# state, only the invalid one is cleared.
c.willexecutors_select(None, select=True)
c.willexecutors_select(None, select=False, only_valid=True)
assert c.willexecutors[valid_url]["selected"] is True
assert c.willexecutors[invalid_url]["selected"] is False
def test_willexecutors_import_overwrites_existing():
import json
with Plugin() as plugin:
c = _make_controller(plugin)
url = "https://existing.example.invalid"
c.willexecutors_add(url, address="", base_fee=100, info="Old")
path = _tmp_path()
try:
with open(path, "w", encoding="utf-8") as f:
json.dump(
{
url: {
"address": "",
"base_fee": 999,
"info": "New",
"selected": True,
}
},
f,
)
result = c.willexecutors_import(path)
assert result["imported"] == 1
# The imported record replaces the existing one (GUI behaviour).
assert c.willexecutors[url]["base_fee"] == 999
assert c.willexecutors[url]["info"] == "New"
assert c.willexecutors[url]["selected"] is True
finally:
os.remove(path)
def test_willexecutors_export_subsets():
import json
with Plugin() as plugin:
c = _make_controller(plugin)
selected_url = "https://selected.example.invalid"
other_url = "https://other.example.invalid"
invalid_url = "https://invalid.example.invalid"
c.willexecutors_add(selected_url, address=VALID_ADDRESS, base_fee=1000)
c.willexecutors_add(other_url, address=VALID_ADDRESS, base_fee=1000)
c.willexecutors_add(invalid_url, address="", base_fee=1000)
c.willexecutors_select([selected_url], select=True)
def exported(subset):
path = _tmp_path()
try:
c.willexecutors_export(path, subset)
with open(path, encoding="utf-8") as f:
return json.load(f)
finally:
os.remove(path)
assert set(exported(None)) == {selected_url, other_url, invalid_url}
assert set(exported("selected")) == {selected_url}
# "valid" keeps both real-address records and drops the address-less one.
assert set(exported("valid")) == {selected_url, other_url}
def test_user_facing_maps_no_servers_for_chain():
from bal.core.willexecutors import Willexecutors
advanced = Willexecutors.NoServersForChainError(
"bitcoin", url="https://welist.example/", reason="boom"
)
assert "https://welist.example/" in str(_user_facing(advanced))
basic = Willexecutors.NoServersForChainError("bitcoin")
assert "bitcoin" in str(_user_facing(basic))
def test_prepare_will_warns_on_amount_exception():
from bal.core import will as will_mod
with Plugin() as plugin:
plugin.NO_WILLEXECUTOR.set(True)
c = _make_controller(plugin)
c.heirs["alice"] = [VALID_ADDRESS, "100%", "2y"]
with mock.patch.object(
will_mod.Will,
"check_amounts",
side_effect=will_mod.FixedAmountException("too much"),
), mock.patch.object(will_mod.Will, "is_will_valid", return_value=True):
result = c.prepare_will()
# The amount mismatch is a warning, not an abort: the flow completes.
assert result["result"] == "coherent"
def test_will_status_empty():
with Plugin() as plugin:
c = _make_controller(plugin)