feat(v0.4.7): report area 500px, heirs one-per-line, wizard line breaks, ALL-DUST guard
Owner-approved changes after testing v0.4.6, plus accumulated v0.4.x work, task-tracking notes, and an updated project HANDOFF document. The four v0.4.7 changes: 1. Report area (BalBuildWillDialog) opens 500px tall (min) up to 700px (max), then the scrollbar takes over. Previously it opened ~140px (too short). 2. Heirs are listed ONE per line again (green, bold) in _build_success_report, reverting the v0.4.6 single-line form. Heir names can be long and the report now scrolls, so compression is no longer needed. 3. Two wizard texts get an explicit line break: after "(or backup)" in the date hint and after "miner fees" in the fee note (widgets.py). 4. ALL-DUST guard: when EVERY heir's share is below the Bitcoin dust limit, the inheritance would pay nobody. Heirs.prepare_lists now raises HeirAmountIsDustException at the end (where all heirs across all locktimes are known with their final dust state), and dialogs.task_phase1 shows a clear RED message and stops without building/signing/checking. A mix of dust + valid heirs keeps building normally. The guard is intentionally in prepare_lists, NOT prepare_transactions (which only sees the lowest locktime and would false-positive). HeirAmountIsDustException is imported in common.py. Tests: 3 new tests in test_core_heirs_extra.py pin the dust behaviour (all-dust raises; mixed continues; multi-locktime continues). Full suite: 258 passed. ruff: no new errors. Version bumped 0.4.6 -> 0.4.7 (4 files). CHANGELOG #23. Also adds/updates HANDOFF.md so any future AI (Claude or another model) can resume the project with full context (rules, layout, build/test/lint, dust logic, git flow), and records the task-tracking notes in .agent_memory_tasks.md.
This commit is contained in:
@@ -81,6 +81,98 @@ def test_heirs_prepare_lists():
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ALL-DUST GUARD (prepare_lists) - owner request, v0.4.7
|
||||
#
|
||||
# The plugin must REFUSE to build an inheritance when EVERY heir's share is
|
||||
# below the Bitcoin dust limit (HeirAmountIsDustException), but must keep
|
||||
# building normally when at least one heir is valid - including the tricky
|
||||
# case where the valid heir lives on a DIFFERENT locktime than the dust one.
|
||||
# These three tests pin that behaviour down so it cannot silently regress.
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_prepare_lists_all_dust_raises():
|
||||
"""All heirs below the dust limit -> HeirAmountIsDustException.
|
||||
|
||||
Reproduces the owner's log: a very small wallet balance split between
|
||||
percentage heirs gives each one a tiny share (like 214/316/3 sat), all
|
||||
below the dust threshold. prepare_lists must then REFUSE to build the
|
||||
"empty" inheritance and raise the exception.
|
||||
|
||||
NOTE: we deliberately use *percentage* heirs with a *small* balance. With
|
||||
fixed amounts and a large balance the leftover funds are redistributed to
|
||||
the heirs (so they would no longer be dust) - that is a different, valid
|
||||
case and is covered by the other prepare_lists tests.
|
||||
"""
|
||||
from bal.core.heirs import HeirAmountIsDustException
|
||||
|
||||
wallet = MagicMock()
|
||||
wallet.dust_threshold.return_value = 500
|
||||
h = Heirs.__new__(Heirs)
|
||||
# Tiny balance (800 sat) split 40%/60% -> each share is far below 500.
|
||||
h.update({
|
||||
"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "40%", "30d"],
|
||||
"b": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "60%", "30d"],
|
||||
})
|
||||
raised = False
|
||||
try:
|
||||
h.prepare_lists(800, 100, wallet)
|
||||
except HeirAmountIsDustException:
|
||||
raised = True
|
||||
assert raised, "all-dust will must raise HeirAmountIsDustException"
|
||||
|
||||
|
||||
def test_prepare_lists_mixed_dust_continues():
|
||||
"""Some dust + at least one valid heir -> build continues normally.
|
||||
|
||||
The guard must NOT fire here: one heir's share is dust (a 1% slice of a
|
||||
small balance), the other is a valid fixed amount, so the inheritance is
|
||||
still feasible (unchanged behaviour).
|
||||
"""
|
||||
from bal.core.heirs import HeirAmountIsDustException
|
||||
|
||||
wallet = MagicMock()
|
||||
wallet.dust_threshold.return_value = 500
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update({
|
||||
"ok": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"],
|
||||
"dust": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", "1%", "30d"],
|
||||
})
|
||||
raised = False
|
||||
try:
|
||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||
except HeirAmountIsDustException:
|
||||
raised = True
|
||||
assert not raised, "a mix of dust + valid heirs must NOT be blocked"
|
||||
assert isinstance(result, dict) and len(result) > 0
|
||||
|
||||
|
||||
def test_prepare_lists_multi_locktime_continues():
|
||||
"""Dust heir and valid heir on DIFFERENT locktimes -> build continues.
|
||||
|
||||
This pins down the false-positive fix: prepare_transactions only ever sees
|
||||
the lowest locktime, so the dust check MUST live in prepare_lists (which
|
||||
sees ALL locktimes). A dust heir at the earlier date must not block a valid
|
||||
heir at the later date.
|
||||
"""
|
||||
from bal.core.heirs import HeirAmountIsDustException
|
||||
|
||||
wallet = MagicMock()
|
||||
wallet.dust_threshold.return_value = 500
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update({
|
||||
"early_dust": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "1%", "30d"],
|
||||
"late_valid": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 5000, "60d"],
|
||||
})
|
||||
raised = False
|
||||
try:
|
||||
result, _onlyfixed = h.prepare_lists(5200, 100, wallet)
|
||||
except HeirAmountIsDustException:
|
||||
raised = True
|
||||
assert not raised, "valid heir on a later locktime must NOT be blocked"
|
||||
assert isinstance(result, dict) and len(result) > 0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs static methods (pure but use Electrum constants)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
116
tests/test_group_f_heir_change_rebuild.py
Normal file
116
tests/test_group_f_heir_change_rebuild.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Tests for Group F (heir-change full rebuild, "Option A").
|
||||
|
||||
Context (bugs E/F/K): when an heir was deleted/changed and the rebuilt
|
||||
inheritance transaction happened to keep the SAME txid, ``Will.update_will``
|
||||
used to REUSE the old (already signed/COMPLETE) WillItem, copying only the new
|
||||
heirs onto it. The downstream ``have_to_sign`` check then saw the item as
|
||||
COMPLETE and reported "Nothing to do", so the new will was never signed or
|
||||
broadcast.
|
||||
|
||||
The fix reuses the old item ONLY when the real heirs are identical. These tests
|
||||
pin the behaviour of the helper ``Will._same_heirs`` that drives that decision.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_group_f_heir_change_rebuild.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.will import Will
|
||||
|
||||
|
||||
# Heir entry layout (see heirs.py): [0]=address, [1]=amount, [2]=locktime.
|
||||
# Extra trailing fields (e.g. real/dust amount) must NOT affect equality.
|
||||
def _heir(address, amount, locktime, *extra):
|
||||
return [address, amount, locktime, *extra]
|
||||
|
||||
|
||||
def test_same_heirs_identical():
|
||||
"""Identical real heirs -> True (old signed item may be reused)."""
|
||||
a = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
|
||||
b = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
|
||||
assert Will._same_heirs(a, b) is True
|
||||
|
||||
|
||||
def test_same_heirs_ignores_extra_fields():
|
||||
"""Derived/extra fields (real amount, dust flag) do not break equality."""
|
||||
a = {"alice": _heir("bc1qalice", "50%", "2026-12-01", 12345, "DUST")}
|
||||
b = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
|
||||
assert Will._same_heirs(a, b) is True
|
||||
|
||||
|
||||
def test_same_heirs_deleted_heir():
|
||||
"""Deleting one of two heirs -> changed -> False (must rebuild/re-sign)."""
|
||||
old = {
|
||||
"alice": _heir("bc1qalice", "50%", "2026-12-01"),
|
||||
"bob": _heir("bc1qbob", "50%", "2026-12-01"),
|
||||
}
|
||||
new = {
|
||||
# bob removed; alice is auto-scaled to 100% by the plugin.
|
||||
"alice": _heir("bc1qalice", "100%", "2026-12-01"),
|
||||
}
|
||||
assert Will._same_heirs(old, new) is False
|
||||
|
||||
|
||||
def test_same_heirs_added_heir():
|
||||
"""Adding a heir -> changed -> False."""
|
||||
old = {"alice": _heir("bc1qalice", "100%", "2026-12-01")}
|
||||
new = {
|
||||
"alice": _heir("bc1qalice", "50%", "2026-12-01"),
|
||||
"carol": _heir("bc1qcarol", "50%", "2026-12-01"),
|
||||
}
|
||||
assert Will._same_heirs(old, new) is False
|
||||
|
||||
|
||||
def test_same_heirs_changed_amount():
|
||||
"""Same heir name but different amount -> changed -> False."""
|
||||
old = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
|
||||
new = {"alice": _heir("bc1qalice", "70%", "2026-12-01")}
|
||||
assert Will._same_heirs(old, new) is False
|
||||
|
||||
|
||||
def test_same_heirs_changed_address():
|
||||
"""Same heir name but different destination address -> False."""
|
||||
old = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
|
||||
new = {"alice": _heir("bc1qOTHER", "50%", "2026-12-01")}
|
||||
assert Will._same_heirs(old, new) is False
|
||||
|
||||
|
||||
def test_same_heirs_changed_locktime():
|
||||
"""Same heir but different delivery locktime -> False."""
|
||||
old = {"alice": _heir("bc1qalice", "50%", "2026-12-01")}
|
||||
new = {"alice": _heir("bc1qalice", "50%", "2027-06-01")}
|
||||
assert Will._same_heirs(old, new) is False
|
||||
|
||||
|
||||
def test_same_heirs_ignores_willexecutor_pseudo_heirs():
|
||||
"""Reserved 'w!ll3x3c\"' pseudo-heirs are bookkeeping, not real heirs.
|
||||
|
||||
A difference only in the pseudo-heir entries must NOT be reported as a heir
|
||||
change (the will-executor is refreshed separately in update_will).
|
||||
"""
|
||||
old = {
|
||||
"alice": _heir("bc1qalice", "100%", "2026-12-01"),
|
||||
'w!ll3x3c"server1': _heir("bc1qwe1", "0", "0"),
|
||||
}
|
||||
new = {
|
||||
"alice": _heir("bc1qalice", "100%", "2026-12-01"),
|
||||
'w!ll3x3c"server2': _heir("bc1qwe2", "0", "0"),
|
||||
}
|
||||
assert Will._same_heirs(old, new) is True
|
||||
|
||||
|
||||
def test_same_heirs_empty():
|
||||
"""Two empty heir maps are equal; None is treated as empty."""
|
||||
assert Will._same_heirs({}, {}) is True
|
||||
assert Will._same_heirs(None, {}) is True
|
||||
assert Will._same_heirs(None, None) is True
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import pytest
|
||||
raise SystemExit(pytest.main([__file__, "-v"]))
|
||||
79
tests/test_group_g_basic_calendar.py
Normal file
79
tests/test_group_g_basic_calendar.py
Normal file
@@ -0,0 +1,79 @@
|
||||
"""
|
||||
Tests for the BASIC-mode calendar reminders.
|
||||
|
||||
Context: in BASIC mode the check-alive parameter is hidden and not managed by
|
||||
the user, so calendar reminders cannot be spread over the check-alive period as
|
||||
they are in ADVANCED mode. Instead the calendar uses three fixed reminders -
|
||||
30, 10 and 1 day before the inheritance delivery date - and drops any reminder
|
||||
that would fall in the past.
|
||||
|
||||
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
||||
drives that decision.
|
||||
|
||||
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
||||
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
||||
the other GUI tests.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
||||
python3 -m pytest tests/test_group_g_basic_calendar.py -q
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.gui.qt.widgets import (BASIC_REMINDER_OFFSETS,
|
||||
basic_reminder_offsets)
|
||||
|
||||
|
||||
def test_basic_offsets_all_future():
|
||||
"""A far delivery date keeps all three fixed reminders (30, 10, 1)."""
|
||||
assert basic_reminder_offsets(365) == [30, 10, 1]
|
||||
|
||||
|
||||
def test_basic_offsets_exactly_30_days():
|
||||
"""Exactly 30 days away: the 30-day reminder is still valid (<=)."""
|
||||
assert basic_reminder_offsets(30) == [30, 10, 1]
|
||||
|
||||
|
||||
def test_basic_offsets_drops_30_when_too_close():
|
||||
"""20 days away: the 30-day reminder is in the past and is dropped."""
|
||||
assert basic_reminder_offsets(20) == [10, 1]
|
||||
|
||||
|
||||
def test_basic_offsets_only_one_left():
|
||||
"""5 days away: only the 1-day reminder remains."""
|
||||
assert basic_reminder_offsets(5) == [1]
|
||||
|
||||
|
||||
def test_basic_offsets_empty_when_deadline_today():
|
||||
"""Delivery date less than a day away: no reminder fits."""
|
||||
assert basic_reminder_offsets(0) == []
|
||||
|
||||
|
||||
def test_basic_offsets_empty_when_negative():
|
||||
"""A past delivery date (negative days) yields no reminders."""
|
||||
assert basic_reminder_offsets(-10) == []
|
||||
|
||||
|
||||
def test_basic_offsets_are_a_subset_of_the_fixed_set():
|
||||
"""Whatever the horizon, results are always a subset of the fixed offsets."""
|
||||
for horizon in (-1, 0, 1, 9, 10, 11, 29, 30, 100):
|
||||
result = basic_reminder_offsets(horizon)
|
||||
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
|
||||
# Always sorted descending (earliest reminder first) and every offset >= 1.
|
||||
assert result == sorted(result, reverse=True)
|
||||
assert all(off >= 1 for off in result)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_basic_offsets_all_future()
|
||||
test_basic_offsets_exactly_30_days()
|
||||
test_basic_offsets_drops_30_when_too_close()
|
||||
test_basic_offsets_only_one_left()
|
||||
test_basic_offsets_empty_when_deadline_today()
|
||||
test_basic_offsets_empty_when_negative()
|
||||
test_basic_offsets_are_a_subset_of_the_fixed_set()
|
||||
print("all BASIC calendar reminder tests passed")
|
||||
Reference in New Issue
Block a user