Files
bal-electrum-plugin/tests/test_sync_locktime_built_txs.py
svatantrya f3deccb626 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.
2026-10-15 21:55:03 -04:00

267 lines
10 KiB
Python

"""
Tests for ``BalBuildWillDialog._sync_locktime_to_built_txs`` and the
"Building your will" row of ``BalBuildWillDialog._build_success_report``.
``_sync_locktime_to_built_txs`` is the post-build sync that keeps the plugin's
stored delivery date (WILL_SETTINGS["locktime"]) in lockstep with the BUILT
transactions' fixed locktime when the core AUTOMATICALLY anticipates it (one
day earlier than stored).
RELATIVE recipes ("90d" / "1y") are now PRESERVED: the daily-drift problem
that once forced freezing them to absolute timestamps is solved at the root by
anchoring every relative recipe against the built transactions
(``Util.resolve_locktime_against_tx`` for the postpone detection,
``resolve_date_to_check(..., built_locktime=...)`` for the reference
timestamp). Only a genuine automatic anticipation on an ABSOLUTE stored date
moves the stored value.
WHY THE FLAG IS NOT SET HERE ANY MORE: because a relative recipe is anchored to
the built tx, a stored-vs-built comparison cannot detect the anticipation for
it. ``_build_success_report`` therefore detects it by comparing the will's
minimum locktime BEFORE the rebuild with the one AFTER it (see there), which
works for both absolute and relative recipes.
The methods are exercised with a lightweight fake ``self`` (no Qt event loop,
no Electrum wallet) by calling them as unbound methods.
Run:
source "$BAL_HOME/electrum/env/bin/activate"
python3 tests/test_sync_locktime_built_txs.py
"""
import os
import sys
from types import SimpleNamespace
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.dialogs import BalBuildWillDialog # noqa: E402 (path insert above)
# ------------------------------------------------------------------ #
# Fakes
# ------------------------------------------------------------------ #
def _fake_willitem(tx_locktime):
"""Minimal stand-in for a WillItem: enough for Will.get_min_locktime and
the heir listing in _build_success_report."""
return SimpleNamespace(
tx=SimpleNamespace(locktime=tx_locktime),
get_status=lambda name: True,
heirs={},
)
def _make_dialog(will_settings, tx_locktimes, recorded):
"""Build a fake dialog ``self`` for _sync_locktime_to_built_txs."""
def update_setting_widgets(new_value, field, update_all=False):
will_settings[field] = new_value
recorded.append((field, new_value, update_all))
return SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt) for i, lt in enumerate(tx_locktimes)
},
will_settings=will_settings,
update_setting_widgets=update_setting_widgets,
),
)
def _call_sync(will_settings, tx_locktimes, recorded):
fake = _make_dialog(will_settings, tx_locktimes, recorded)
BalBuildWillDialog._sync_locktime_to_built_txs(fake)
return fake
# ------------------------------------------------------------------ #
# Tests
# ------------------------------------------------------------------ #
def test_relative_locktime_preserved():
"""A RELATIVE stored locktime ("90d"/"1y") is PRESERVED after a rebuild:
it is anchored against the built transactions on every check, so it must
not be frozen to an absolute timestamp in WILL_SETTINGS."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
assert fake.bal_window.will_settings["locktime"] == "90d"
assert fake.bal_window.will_settings["threshold"] == "30d"
assert recorded == [], "a relative recipe must never be rewritten"
def test_relative_threshold_preserved():
"""Same for the relative "Check Alive" threshold: it stays relative."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": "90d", "threshold": "30d"}, [tx_locktime], recorded
)
assert fake.bal_window.will_settings["threshold"] == "30d"
assert recorded == []
def test_absolute_locktime_unchanged_on_equal():
"""An absolute stored locktime that already matches the built txs is left
untouched (no spurious rewrite)."""
tx_locktime = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": tx_locktime, "threshold": 1_700_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
assert fake.bal_window.will_settings["threshold"] == 1_700_000_000
def test_anticipation_moves_stored_date_earlier():
"""A real automatic anticipation of an ABSOLUTE stored date (built earlier
than stored) moves the stored date earlier so the next Check does not
mistake it for a postpone."""
tx_locktime = 1_700_000_000
recorded = []
fake = _call_sync(
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
[tx_locktime],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == tx_locktime
def test_stored_earlier_than_built_never_moved_later():
"""A stored absolute date that is already EARLIER than the built txs (the
user moved the delivery later) is never pulled back up on rebuild: only
anticipation (built < stored) moves the value."""
stored = 1_800_000_000
recorded = []
fake = _call_sync(
{"locktime": stored, "threshold": 1_700_000_000},
[1_900_000_000],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == stored
def test_multiple_txs_uses_minimum_locktime():
"""When several ABSOLUTE transactions carry different locktimes, the minimum
is used for a genuine automatic anticipation (owner-confirmed behaviour for
the delivery date shown in the UI)."""
min_locktime = 1_750_000_000
recorded = []
fake = _call_sync(
{"locktime": 1_800_000_000, "threshold": 1_600_000_000},
[min_locktime, min_locktime + 86_400],
recorded,
)
assert fake.bal_window.will_settings["locktime"] == min_locktime
def test_relative_locktime_stays_coherent_via_anchor():
"""Daily-drift guard: an UNCHANGED relative recipe is resolved against the
tx build moment (``Util.resolve_locktime_against_tx``), so even WITHOUT
being frozen to an absolute value it still reads as COHERENT (== tx
locktime) on later days - the postpone check never fires again."""
from datetime import datetime, timedelta, timezone
from bal.core.util import Util
# resolve_locktime_against_tx normalises to UTC midnight before anchoring,
# so use a midnight-UTC frozen tx locktime (the timestamp the engine itself
# stores after building).
tx_locktime = int(datetime(2027, 1, 15, tzinfo=timezone.utc).timestamp())
built = "90d" # recipe frozen at build time
current = "90d" # unchanged recipe today
for _day in range(0, 7):
resolved = Util.resolve_locktime_against_tx(current, built, tx_locktime)
assert resolved == tx_locktime # no POSTPONE / drift
# Sanity: a naive forward-from-now re-parse would have drifted past it
# (the bug the anchor fixes).
drifted = int(
(
datetime.fromtimestamp(tx_locktime) + timedelta(days=1)
).timestamp()
)
assert drifted > tx_locktime
# ------------------------------------------------------------------ #
# _build_success_report: the "Building your will" row
# ------------------------------------------------------------------ #
def _make_report_dialog(new_locktimes, previous_min_locktime):
"""Fake ``self`` for _build_success_report (no Qt event loop).
``_sync_locktime_to_built_txs`` is stubbed (it is exercised separately), so
the test drives the AFTER-build locktimes and the captured BEFORE-build
locktime directly; the message helpers capture what would be written on the
"Building your will" row.
"""
captured = {}
def _set_building(status=None, row=None, color=None):
captured["build_status"] = status
fake = SimpleNamespace(
bal_window=SimpleNamespace(
willitems={
f"tx{i}": _fake_willitem(lt)
for i, lt in enumerate(new_locktimes)
},
wallet=SimpleNamespace(set_label=lambda *a, **k: None),
),
_previous_min_locktime=previous_min_locktime,
_sync_locktime_to_built_txs=lambda: None,
msg_set_building=_set_building,
msg_set_status=lambda *a, **k: None,
msg_alert=lambda e: "ALERT:{}".format(e),
msg_ok=lambda e=None: "OK",
)
return fake, captured
def test_build_report_shows_anticipated_warning():
"""When the rebuild moved the delivery one day earlier than the previous
will, the "Building your will" row shows a warning naming the NEW delivery
date instead of a plain "Ok"."""
previous = 1_800_000_000 # 2027-01-15 (UTC)
rebuilt = previous - 86_400 # anticipated one day earlier
fake, captured = _make_report_dialog([rebuilt], previous)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is True
assert fake._anticipated_locktime == rebuilt
assert captured["build_status"] == (
"ALERT:Anticipated - new delivery date: 2027-01-14"
)
def test_build_report_shows_ok_when_not_anticipated():
"""Without anticipation the row keeps the plain "Ok" and the state stays
clean."""
previous = 1_800_000_000
fake, captured = _make_report_dialog([previous], previous)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is False
assert fake._anticipated_locktime is None
assert captured["build_status"] == "OK"
def test_build_report_first_build_is_not_anticipated():
"""A first build (no previous will) is never flagged as anticipated."""
fake, captured = _make_report_dialog([1_800_000_000], None)
BalBuildWillDialog._build_success_report(fake)
assert fake._date_was_anticipated is False
assert fake._anticipated_locktime is None
assert captured["build_status"] == "OK"
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All sync_locktime_built_txs tests passed.")