BalWindow/plugin: history persistence, will import/merge, sig tracking, local-spender fixes

This commit is contained in:
2026-08-01 17:21:36 -04:00
parent 08394f4868
commit 30a5720ceb
18 changed files with 3139 additions and 153 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -65,7 +65,8 @@
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl": [],
"bcrt1qwhjyltkpkyqj2k8zd6zjq2uu49tpu03f4sm7j0": [],
"bcrt1qygktltj8sjkus96k0atzgr8srcy658axnpj82x": [],
"bcrt1qyjpmlr7qehqedzhkzsx6xrvr9ey6455yur2pqx": []
"bcrt1qyjpmlr7qehqedzhkzsx6xrvr9ey6455yur2pqx": [],
"bcrt1qyx3e4qwguyr70g9wdva4dgpp2pdpauj9dht5ga": []
},
"addresses": {
"change": [
@@ -80,7 +81,8 @@
"bcrt1q59nyxchtw4eajltn97sds6whdj4p2rg6uaygfq",
"bcrt1qw3ljqdscx64qk9lxke3evz8scukj4zf8l5mptt",
"bcrt1qrkwdul9mws0hu298hvtd5usce9haypwmt8zhts",
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl"
"bcrt1qw3m44vlmx08xzpnus22yfa8k5exnq9wk9uncrl",
"bcrt1qyx3e4qwguyr70g9wdva4dgpp2pdpauj9dht5ga"
],
"receiving": [
"bcrt1qkxnsj8xymkk4mahea5ry8lgpau0w5dqgs0ezxc",
@@ -121,22 +123,22 @@
"aaaa": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"34%",
"5y"
1813204800
],
"lucia": [
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
"32%",
"5y"
1813204800
],
"mario": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"35%",
"5y"
1813204800
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
"5y"
1813204800
]
},
"imported_channel_backups": {},
@@ -152,6 +154,7 @@
"xpub": "vpub5VpWCxVNP1qcP3aDcBMryrhaCECvFte8T3eT8BAUSGW4LuHoVEUNaKKPGsuN8H2MFTMmtZHnokVMB6c46MFiCzyYHwmsgAfr5B9DtFAEtjo"
},
"labels": {
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd": "BAL Inheritance transaction",
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "BAL Invalidate transaction",
"5833dd0e77cc9e22d981b92e71e5f5804deb5294dfa4dc666cfe4fa015afee8d": "BAL Inheritance transaction",
"5ca07e947509bea249d18591358c13a9c55e02b897390bab7ce0d2c6b2b394ec": "BAL Inheritance transaction",
@@ -161,6 +164,7 @@
"a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158": "BAL Inheritance transaction",
"a526a6c1a74951df5bf5845f64dc97c3c9410935e2d05f6622a8f4ba31ec6811": "BAL Inheritance transaction",
"b45102630bd6f7b8774dff3ed39b393097e7eb6ae4714716f6e925497db4d6d9": "BAL Inheritance transaction",
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519": "BAL Inheritance transaction",
"e89b2329d4fe2b9349739b54720404f22d6b49ce94c297ce793f5e4e1f51672a": "BAL Invalidate transaction",
"f54f6c6a4bf6820b428ca5f6b7200545dacfd5e671e001c524888e502d007eaa": "BAL Invalidate transaction"
},
@@ -282,7 +286,7 @@
"0": "7cb49f910a6db5d95436943456293aefbcccf0bbcc78b1dfe0adec430997fe07"
}
},
"stored_height": 2249,
"stored_height": 2336,
"submarine_swaps": {},
"transactions": {
"4c955222d31ed19318841b7f3dd0e95edd6198ebddc0dbea1c249c38c609aeac": "0200000000010108d37b8e201da3203b47c7cc33fdf21a55b9cf776fdf5d1a028cd250e953d9d20000000000fdffffff01ef56e44130000000160014f0df9afc9fdd8ec20c46eb6af56d3c4934063343024730440220625a4a9944af68bd2b4c4146ae9db95f052725b8ca4fc8e8fca541d1c863d37a022004a705b8974e5fea3707acd0d8cc5f9a9c898a508fd0ae348b81043c4a86ad0b0121021e753044de0b2d3894751fae6e62c0627d379752809e522a5d0d4e3689e4ba9fa9080000",
@@ -393,6 +397,91 @@
},
"wallet_type": "standard",
"will": {
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd": {
"ANTICIPATED": false,
"BROADCASTED": false,
"CHECKED": true,
"CHECK_FAIL": false,
"COMPLETE": true,
"CONFIRMED": false,
"ERROR": false,
"EXPIRED": false,
"EXPORTED": false,
"IMPORTED": false,
"INVALIDATED": false,
"MEMPOOL": false,
"PUSHED": true,
"PUSH_FAIL": false,
"REPLACED": false,
"RESTORED": false,
"UPDATED": false,
"VALID": true,
"_id": "11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd",
"baltx_fees": 1,
"change": null,
"description": "w!ll3x3c\"http://localhost:9133\"1813204800\nmario2\naaaa\nlucia\nmario",
"heirs": {
"aaaa": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"34%",
1813204800,
69771996601
],
"lucia": [
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
"32%",
1813204800,
65667761507
],
"mario": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"35%",
1813204800,
71824114148
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
1813204800,
40001,
40000
],
"w!ll3x3c\"http://localhost:9133\"1813204800": [
"bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
1000,
1813204800,
1000
]
},
"status": "New.Firmato.Pushed.Checked",
"time": 1785536273.0165532,
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402201ce57302608a734f3de1b0d3b0eca08efb78f0d886180e7adf4897bfe4b4a49302202120e6f4308cef294094fa347ae7369eb0b0bb9b1bb64c8f03f50b349f01d7e1012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7404f136c",
"willexecutor": {
"address": "bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
"balance": 90000,
"base_fee": 1000,
"broadcast_status": "Riuscito",
"chain": "regtest",
"count_win": 0,
"id": 66,
"info": "BAL devel willexecutor server",
"last_block": 0,
"last_update": 1785463888.38115,
"onion_url": null,
"points": 0,
"promo_code": null,
"selected": true,
"status": 200,
"tld": "localhost",
"txs": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402201ce57302608a734f3de1b0d3b0eca08efb78f0d886180e7adf4897bfe4b4a49302202120e6f4308cef294094fa347ae7369eb0b0bb9b1bb64c8f03f50b349f01d7e1012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7404f136c\n",
"txsids": [
"11a0107ec7ffb703ca32d1f558007dc64539eb9d8287e77f81fd7380f258dddd"
],
"unconfirmed_balance": 0,
"url": "http://localhost:9133",
"version": "0.3.2"
}
},
"6ad1383bb5d358c4b7bcebbf009d642e7a78b923cf3b764b196904c68ffa116d": {
"ANTICIPATED": false,
"BROADCASTED": false,
@@ -493,10 +582,10 @@
"MEMPOOL": false,
"PUSHED": true,
"PUSH_FAIL": false,
"REPLACED": false,
"REPLACED": true,
"RESTORED": false,
"UPDATED": false,
"VALID": true,
"VALID": false,
"_id": "a45935c22968fe56dfdc329420336682cebfce0691ed764ab5891199ed91a158",
"baltx_fees": 1,
"change": null,
@@ -534,7 +623,7 @@
1000
]
},
"status": "New.Firmato.Pushed.Checked",
"status": "New.Firmato.Pushed.Checked.Replaced",
"time": 1785449918.3222272,
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402200b71f679baef994767c58cea234f2a2baa83cc029f64dae4ff6fe451aaffc369022051b2a70fd5c33fcc175d6dca4b052b4a7bc168c761434139560bee66ed965b18012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b7c0cdd073",
"willexecutor": {
@@ -562,6 +651,91 @@
"url": "http://localhost:9133",
"version": "0.3.2"
}
},
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519": {
"ANTICIPATED": false,
"BROADCASTED": false,
"CHECKED": true,
"CHECK_FAIL": false,
"COMPLETE": true,
"CONFIRMED": false,
"ERROR": false,
"EXPIRED": false,
"EXPORTED": false,
"IMPORTED": false,
"INVALIDATED": false,
"MEMPOOL": false,
"PUSHED": true,
"PUSH_FAIL": false,
"REPLACED": true,
"RESTORED": false,
"UPDATED": false,
"VALID": false,
"_id": "dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519",
"baltx_fees": 1,
"change": null,
"description": "w!ll3x3c\"http://localhost:9133\"1817006400\nmario2\naaaa\nlucia\nmario",
"heirs": {
"aaaa": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"34%",
"1y",
69771996601
],
"lucia": [
"bcrt1q0cv672t0yhgf9u36pssgsglxtjq69tuakdzy2j",
"32%",
"1y",
65667761507
],
"mario": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
"35%",
"1y",
71824114148
],
"mario2": [
"bcrt1q3c0u8xtpc4rc0lwhkh8nukwqjkuhxnaurd45ju",
40000,
"1y",
40001,
40000
],
"w!ll3x3c\"http://localhost:9133\"1817006400": [
"bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
1000,
1817006400,
1000
]
},
"status": "New.Firmato.Pushed.Checked.Replaced",
"time": 1785529939.6807153,
"tx": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402203e830a47f218826e2d1e4b708fdf4dc4a8826ac5c8a37a79fec5fbf1c6aa287002205df204067685f8874211ad092b0dad17a725f82a51e16f5c52db778135ecfbab012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b740514d6c",
"willexecutor": {
"address": "bcrt1qrjwc0ww8xcvgum9zwmq858wndqguw7q27ek3sk",
"balance": 90000,
"base_fee": 1000,
"broadcast_status": "Riuscito",
"chain": "regtest",
"count_win": 0,
"id": 66,
"info": "BAL devel willexecutor server",
"last_block": 0,
"last_update": 1785463888.38115,
"onion_url": null,
"points": 0,
"promo_code": null,
"selected": true,
"status": 200,
"tld": "localhost",
"txs": "0200000000010107fe970943ecade0dfb178ccbbf0ccbcef3a295634943654d9b56d0a919fb47c0000000000fdffffff05e8030000000000001600141c9d87b9c736188e6ca276c07a1dd36811c7780a419c0000000000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc63851a4a0f0000001600147e19af296f25d092f23a0c208823e65c81a2af9db92dbc3e100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbce4010db9100000001600148e1fc39961c54787fdd7b5cf3e59c095b9734fbc0247304402203e830a47f218826e2d1e4b708fdf4dc4a8826ac5c8a37a79fec5fbf1c6aa287002205df204067685f8874211ad092b0dad17a725f82a51e16f5c52db778135ecfbab012102c5e7ef3fc3902156154cd1a2238e80bc15f1bb253f3c973b3b87f9ed6fe1c7b740514d6c\n",
"txsids": [
"dee1233628873d01de73cf0d8eabc19375f9a1713408ec9d5ef0661bd03a7519"
],
"unconfirmed_balance": 0,
"url": "http://localhost:9133",
"version": "0.3.2"
}
}
},
"winpos-qt": [

View File

@@ -10,12 +10,25 @@ Run:
import os
import sys
from binascii import unhexlify
from unittest.mock import MagicMock, patch
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from electrum.transaction import Transaction
from electrum import crypto
from electrum.address_synchronizer import TX_HEIGHT_FUTURE, TX_HEIGHT_LOCAL
from electrum.bitcoin import public_key_to_p2wpkh
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
Transaction,
TxOutpoint,
)
from bal.core.util import Util
from bal.core.will import Will, WillItem
_VALID_TX_HEX = (
@@ -171,6 +184,8 @@ def test_mempool_status_clears_valid():
def test_check_invalidated_invalidated():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
# The funding is really consumed by a broadcast tx -> the will is dead.
wallet.adb.get_spender.return_value = "ab" * 32
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
@@ -186,6 +201,9 @@ def test_check_invalidated_invalidated():
def test_check_will():
wallet = MagicMock()
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
# No broadcast tx spends the funding: the missing UTXO is only a local
# (history) artifact, so the will is not invalidated by it.
wallet.adb.get_spender.return_value = None
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100})
@@ -197,6 +215,141 @@ def test_check_will():
assert item.get_status("MEMPOOL") is True
# ------------------------------------------------------------------ #
# WillItem signature counts + PARTIALLY_SIGNED status
# ------------------------------------------------------------------ #
def _multisig_descriptor():
"""Return a 2-of-3 wsh multisig descriptor and its three pubkeys."""
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
return parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs))), pubs
def _make_multisig_ptx(nsigs, locktime=None):
"""A 2-of-3 wsh multisig PartialTransaction carrying ``nsigs`` signatures."""
desc, pubs = _multisig_descriptor()
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
txout = PartialTxOutput.from_address_and_value(addr, 50000)
ptx = PartialTransaction()
if locktime is not None:
ptx.locktime = locktime
ptx.add_inputs([txin])
ptx.add_outputs([txout])
return ptx
def _make_multisig_willitem(nsig):
"""A WillItem wrapping an unsigned 2-of-3 partial tx with ``nsig`` sigs.
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization (PSBT serialization drops the descriptor but keeps
the signatures), mirroring the real load-from-wallet flow.
"""
w = {"tx": _make_multisig_ptx(nsig), "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}
item = WillItem(w, _id="mswill")
item.tx.inputs()[0].script_descriptor = _multisig_descriptor()[0]
return item
def test_willitem_sigs_fields_roundtrip():
item = _make_multisig_willitem(1)
assert item.sigs_required == 0
assert item.sigs_have == 0
item.sigs_required = 2
item.sigs_have = 1
item.set_status("PARTIALLY_SIGNED", True)
d = item.to_dict()
assert d["sigs_required"] == 2
assert d["sigs_have"] == 1
assert d["PARTIALLY_SIGNED"] is True
item2 = WillItem(d, _id="mswill")
assert item2.sigs_required == 2
assert item2.sigs_have == 1
assert item2.get_status("PARTIALLY_SIGNED") is True
def test_willitem_legacy_dict_defaults_sig_fields():
# A will saved before the signature-tracking feature has no sig fields:
# they must default to 0 and the flag to False.
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": None, "status": "", "description": "",
"time": 0, "change": "", "baltx_fees": 100}, _id="legacy")
assert item.sigs_required == 0
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
def test_willitem_partial_signed_keeps_valid():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
assert "Partially Signed" in item.status
def test_willitem_complete_clears_partially_signed():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
assert item.get_status("COMPLETE") is True
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_partial():
item = _make_multisig_willitem(1)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 1
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is True
assert item.get_status("VALID") is True
def test_check_signatures_unsigned_not_partial():
item = _make_multisig_willitem(0)
Will.check_signatures({"mswill": item})
assert item.sigs_have == 0
assert item.sigs_required == 2
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_fully_signed_clears_flag():
item = _make_multisig_willitem(2)
item.set_status("PARTIALLY_SIGNED", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_complete_item_clears_flag():
item = _make_multisig_willitem(1)
item.set_status("PARTIALLY_SIGNED", True)
item.set_status("COMPLETE", True)
Will.check_signatures({"mswill": item})
assert item.get_status("PARTIALLY_SIGNED") is False
def test_check_signatures_single_sig_required():
# A single-signature (P2WPKH) will needs exactly 1 signature: 0 present is
# "New", not "partially signed".
pub = crypto.privkey_to_pubkey(bytes([7] * 32)).hex()
item = _make_multisig_willitem(0)
item.tx.inputs()[0].script_descriptor = parse_descriptor("wpkh({})".format(pub))
Will.check_signatures({"mswill": item})
assert item.sigs_required == 1
assert item.sigs_have == 0
assert item.get_status("PARTIALLY_SIGNED") is False
# ------------------------------------------------------------------ #
# WillItem.__init__ with wallet
# ------------------------------------------------------------------ #
@@ -218,6 +371,472 @@ def test_willitem_init_without_wallet():
assert item is not None
# ------------------------------------------------------------------ #
# Will.save_valid_transactions_to_history (history persistence)
# ------------------------------------------------------------------ #
class FakeTxMinedStatus:
def __init__(self, height):
self._height = height
def height(self):
return self._height
class FakeTxInfo:
def __init__(self, height):
self.tx_mined_status = FakeTxMinedStatus(height)
class FakeWallet:
"""Minimal stand-in for an Electrum wallet used by history persistence."""
def __init__(self, stored_txs=None, spenders=None, heights=None, outputs=None,
addresses=None):
self.adb = FakeADB(
stored_txs or {},
spenders=spenders,
heights=heights,
outputs=outputs,
)
self.db = self.adb.db
self.labels = {}
self.save_db_called = 0
self.addresses = list(addresses or [])
def set_label(self, txid, label):
if label is None:
self.labels.pop(txid, None)
else:
self.labels[txid] = label
def get_all_labels(self):
return dict(self.labels)
def save_db(self):
self.save_db_called += 1
def get_addresses(self):
return self.addresses
def get_label_for_txid(self, txid):
return self.labels.get(txid, "")
def get_tx_info(self, tx):
height = self.adb.heights.get(tx.txid(), TX_HEIGHT_LOCAL)
return FakeTxInfo(height)
def get_utxos(self):
utxos = []
for outs in self.adb.outputs.values():
for utxo in outs.values():
if utxo.spent_height is None:
utxos.append(utxo)
return utxos
class FakeADB:
def __init__(self, stored_txs, spenders=None, heights=None, outputs=None):
self.db = FakeDB(stored_txs)
self.added = []
self.removed = []
self.spenders = dict(spenders or {})
self.heights = dict(heights or {})
self.outputs = dict(outputs or {})
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
self.added.append((tx, allow_unrelated))
return True
def remove_transaction(self, txid):
self.removed.append(txid)
def get_spender(self, outpoint):
txid = self.spenders.get(outpoint)
if txid is None:
return None
height = self.heights.get(txid, TX_HEIGHT_LOCAL)
if height in (TX_HEIGHT_LOCAL, TX_HEIGHT_FUTURE):
return None
return txid
def get_tx_height(self, txid):
return FakeTxMinedStatus(self.heights.get(txid, TX_HEIGHT_LOCAL))
def get_addr_outputs(self, addr):
return self.outputs.get(addr, {})
class FakeDB:
def __init__(self, stored_txs):
self.stored = dict(stored_txs)
def get_transaction(self, txid):
return self.stored.get(txid)
def _make_simple_willitem(tx, valid=True, we_url=None):
"""A WillItem wrapping *tx* with an optional VALID status and executor."""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
return WillItem(w, _id="wid")
def _exec_label(template, url):
return template.replace("{willexecutor}", url)
def test_save_incomplete_valid_tx_to_history_adds_and_labels():
# A still-unsigned / partially-signed ("New") partial tx is stored in the
# local history, tagged with the decoded label.
tx = _make_multisig_ptx(1)
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert [t.txid() for t, _ in wallet.adb.added] == [tx.txid()]
txid = tx.txid()
assert wallet.labels[txid] == "BitcoinAfterLife inheritance transaction - https://we.example"
assert wallet.save_db_called == 1
def test_save_skips_complete_and_invalid_items():
# A fully-signed ("Complete") tx must NOT be stored: it is removed from the
# local history instead. Invalid items are never touched.
complete = _make_multisig_willitem(2)
complete.set_status("VALID", True)
invalid = _make_simple_willitem(Transaction(_VALID_TX_HEX), valid=False)
new = _make_simple_willitem(_make_multisig_ptx(1), valid=True)
wallet = FakeWallet()
Will.save_valid_transactions_to_history(
{"complete": complete, "invalid": invalid, "new": new},
wallet,
"BitcoinAfterLife inheritance transaction - {willexecutor}",
)
assert [t.txid() for t, _ in wallet.adb.added] == [new.tx.txid()]
assert len(wallet.labels) == 1
assert wallet.labels[new.tx.txid()] == (
"BitcoinAfterLife inheritance transaction - "
)
def test_save_combines_sigs_when_stored_partial():
# The tx to save is already present in the wallet as an incomplete partial
# PSBT: the signatures are combined into it instead of blindly overwriting.
# (Exercised on raw PartialTransactions; through WillItem a complete tx
# round-trips to a plain Transaction, which overwrites instead - see
# test_save_complete_tx_overwrites_stored_partial.)
our = _make_multisig_ptx(2)
stored_partial = _make_multisig_ptx(1)
wallet = FakeWallet(stored_txs={our.txid(): stored_partial})
Will._add_transaction_to_history(wallet, our, our.txid())
assert len(wallet.adb.added) == 1
saved, _ = wallet.adb.added[0]
# The combine path was taken (the stored partial was re-added, not our tx).
assert saved is stored_partial
assert saved.is_complete()
def test_save_removes_complete_item_from_history():
# A fully-signed item is removed from the local history: its matching
# entry (exact label) is deleted, and it is never re-added.
our = _make_multisig_ptx(2)
txid = our.txid()
label = "BitcoinAfterLife inheritance transaction - https://we.example"
item = _make_simple_willitem(our, valid=True, we_url="https://we.example")
wallet = FakeWallet(stored_txs={txid: our})
wallet.labels[txid] = label
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert wallet.adb.added == []
assert txid in wallet.adb.removed
assert txid not in wallet.labels
def test_save_cleanup_removes_stale_exact_label():
tx = _make_multisig_ptx(1)
txid = tx.txid()
stale_txid = "ab" * 32
other_txid = "cd" * 32
label = "BitcoinAfterLife inheritance transaction - https://we.example"
wallet = FakeWallet()
# Pre-existing wallet labels: one current, one stale (same label), one with
# a different executor URL that must be kept.
wallet.labels[txid] = label
wallet.labels[stale_txid] = label
wallet.labels[other_txid] = "BitcoinAfterLife inheritance transaction - https://other.example"
item = _make_simple_willitem(tx, valid=True, we_url="https://we.example")
Will.save_valid_transactions_to_history({"wid": item}, wallet, label)
assert stale_txid in wallet.adb.removed
assert other_txid not in wallet.adb.removed
assert txid not in wallet.adb.removed
# The stale tx's label is dropped with it.
assert stale_txid not in wallet.labels
assert other_txid in wallet.labels
assert wallet.labels[txid] == label
def test_save_no_wallet_or_no_adb_is_noop():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
Will.save_valid_transactions_to_history({"wid": item}, None, "LBL")
Will.save_valid_transactions_to_history({"wid": item}, object(), "LBL")
def test_save_never_raises_on_adb_failure():
tx = Transaction(_VALID_TX_HEX)
item = _make_simple_willitem(tx, valid=True)
class BoomWallet(FakeWallet):
class BoomADB:
def add_transaction(self, tx, *, allow_unrelated=False, is_new=True):
raise RuntimeError("boom")
def remove_transaction(self, txid):
raise RuntimeError("boom")
def __init__(self):
self.adb = self.BoomADB()
self.labels = {}
def get_all_labels(self):
return {"stale": "BitcoinAfterLife inheritance transaction - "}
wallet = BoomWallet()
Will.save_valid_transactions_to_history(
{"wid": item}, wallet, "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
# No exception propagates; a fresh fake works afterwards.
assert True
def test_check_will_does_not_save_to_history():
# History persistence is no longer triggered from check_will: it runs after
# the will is signed (see the GUI hooks). check_will must not touch it.
with patch.object(Will, "save_valid_transactions_to_history") as save_mock:
Will.check_will({}, [], None, 9999999999)
save_mock.assert_not_called()
def test_is_will_valid_calls_check_will_without_history_label():
with patch.object(Will, "check_will") as cw_mock:
Will.is_will_valid({}, 9999999999, 100, [])
assert len(cw_mock.call_args[0]) == 4
assert cw_mock.call_args[0] == ({}, [], False, 9999999999)
# ------------------------------------------------------------------ #
# Signature absorption + status fixes (local-history will tx)
# ------------------------------------------------------------------ #
def _make_willitem_keyed_by_txid(tx, valid=True, we_url=None):
"""A WillItem whose ``_id`` equals its txid (as in a real built will).
The script descriptor is re-attached after the WillItem round-trips the tx
through serialization, mirroring the real load-from-wallet flow.
"""
w = {"tx": tx, "heirs": {"a": ["addr", 100, "30d"]},
"willexecutor": {"url": we_url} if we_url else None, "status": "",
"description": "", "time": 0, "change": "", "baltx_fees": 100,
"VALID": valid}
item = WillItem(w, _id=tx.txid())
if isinstance(tx, PartialTransaction) and tx.inputs():
desc = getattr(tx.inputs()[0], "script_descriptor", None)
if desc is not None and isinstance(item.tx, PartialTransaction):
item.tx.inputs()[0].script_descriptor = desc
return item
def _make_local_wallet(tx, stored, funding):
"""A FakeWallet where *tx* is stored locally and consumes *funding*."""
return FakeWallet(
stored_txs={tx.txid(): stored},
spenders={funding: tx.txid()},
heights={tx.txid(): TX_HEIGHT_LOCAL},
)
def test_absorb_history_signatures_merges_and_completes():
# The wallet's stored local copy of the will tx carries more signatures
# than the in-memory item: they are merged in and the item becomes COMPLETE.
item = _make_multisig_willitem(1)
stored = _make_multisig_ptx(2)
assert stored.txid() == item.tx.txid()
wallet = FakeWallet(stored_txs={item._id: stored})
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is True
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
def test_absorb_history_signatures_noop_without_stored_copy():
# Nothing stored in the wallet: the in-memory item is left untouched.
item = _make_multisig_willitem(1)
wallet = FakeWallet()
Will._absorb_history_signatures({item._id: item}, wallet)
assert item.tx.is_complete() is False
assert item.get_status("COMPLETE") is False
def test_check_invalidated_keeps_valid_on_local_spend():
# The funding is missing from the wallet's UTXOs only because the will tx
# itself was saved into the local history: a wallet-local spender must not
# invalidate the will.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is False
assert item.get_status("VALID") is True
def test_check_invalidated_invalidates_on_real_spend():
# The funding is consumed by a broadcast transaction: the will is dead.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
ext_spender = "ab" * 32
wallet = FakeWallet(
spenders={funding: ext_spender},
heights={ext_spender: 100},
)
Will.check_invalidated(will, [], wallet)
assert item.get_status("INVALIDATED") is True
assert item.get_status("VALID") is False
def test_search_rai_local_artifact_keeps_valid():
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, tx, funding)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
assert item.get_status("CONFIRMED") is False
def test_search_rai_confirmed_on_broadcast_spender():
# The will tx is broadcast/confirmed: its own (real) spender marks it
# CONFIRMED, not INVALIDATED.
tx = _make_multisig_ptx(1)
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = FakeWallet(
stored_txs={tx.txid(): tx},
spenders={funding: tx.txid()},
heights={tx.txid(): 100},
)
Will.search_rai(Will.get_all_inputs(will, only_valid=True), [], will, wallet)
assert item.get_status("CONFIRMED") is True
assert item.get_status("INVALIDATED") is False
def test_check_will_merges_history_sigs_and_stays_valid():
# End-to-end: a will tx stored in the local history gained a signature.
# check_will must absorb it, not invalidate the will for the local spend.
now = 1700000000
locktime = 2000000000
tx = _make_multisig_ptx(1, locktime)
stored = _make_multisig_ptx(2, locktime)
assert stored.txid() == tx.txid()
item = _make_willitem_keyed_by_txid(tx)
will = {tx.txid(): item}
funding = tx.inputs()[0].prevout.to_str()
wallet = _make_local_wallet(tx, stored, funding)
Will.check_will(will, [], wallet, now)
assert item.get_status("COMPLETE") is True
assert item.get_status("VALID") is True
assert item.get_status("INVALIDATED") is False
# ------------------------------------------------------------------ #
# Util.get_available_utxos (UTXO-view restoration)
# ------------------------------------------------------------------ #
def _make_utxo(prevout_hex="22", idx=0, value=100000,
spent_txid=None, spent_height=None):
txin = PartialTxInput(
prevout=TxOutpoint(bytes.fromhex(prevout_hex) * 32, idx), script_sig=b""
)
txin._trusted_value_sats = value
txin.spent_txid = spent_txid
txin.spent_height = spent_height
return txin
_HISTORY_TEMPLATE = "BitcoinAfterLife inheritance transaction - {willexecutor}"
_HISTORY_LABEL = _HISTORY_TEMPLATE.replace("{willexecutor}", "https://we.example")
def _wallet_with_local_spend(locktime):
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=TX_HEIGHT_LOCAL)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=locktime)},
heights={spender: TX_HEIGHT_LOCAL},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
return wallet, utxo
def test_get_available_utxos_restores_future_bal_local_spend():
# A later-locktime BAL history tx locally spent the coin: it is restored.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert [u.prevout.to_str() for u in result] == [utxo.prevout.to_str()]
def test_get_available_utxos_does_not_restore_unlabeled_spend():
wallet, utxo = _wallet_with_local_spend(locktime=2000)
wallet.labels["ab" * 32] = "some other label"
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_not_later_locktime():
# The stored spender's locktime equals the will's locktime (same will):
# its spend is NOT ignored.
wallet, utxo = _wallet_with_local_spend(locktime=1000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_does_not_restore_confirmed_spend():
# A broadcast (confirmed) spender is never ignored.
addr = "bcrt1qexample"
spender = "ab" * 32
utxo = _make_utxo(spent_txid=spender, spent_height=100)
wallet = FakeWallet(
stored_txs={spender: _make_multisig_ptx(0, locktime=2000)},
heights={spender: 100},
outputs={addr: {utxo.prevout.to_str(): utxo}},
addresses=[addr],
)
wallet.labels[spender] = _HISTORY_LABEL
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, 1000)
assert result == []
def test_get_available_utxos_none_locktime_is_raw_view():
# No reference locktime: the raw wallet.get_utxos() view is returned, so a
# locally-spent coin stays hidden.
wallet, utxo = _wallet_with_local_spend(locktime=2000)
result = Util.get_available_utxos(wallet, _HISTORY_TEMPLATE, None)
assert result == []
assert Util.get_available_utxos(None, _HISTORY_TEMPLATE, 1000) == []
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -64,6 +64,47 @@ def test_editable_dates_can_be_enabled():
assert BalConfig(cfg, "bal_editable_dates", False).get() is True
# ------------------------------------------------------------------ #
# History persistence settings (SAVE_HISTORY / HISTORY_LABEL)
# ------------------------------------------------------------------ #
def test_save_history_defaults_on():
"""History persistence is opt-out: the flag defaults to ON."""
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
assert save_history.get() is True
def test_save_history_can_be_disabled_and_read_back():
cfg = FakeConfig()
save_history = BalConfig(cfg, "bal_save_history", True)
save_history.set(False)
assert BalConfig(cfg, "bal_save_history", True).get() is False
def test_history_label_default_template():
"""The default label contains the {willexecutor} variable."""
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
assert "{willexecutor}" in history_label.get()
def test_history_label_can_be_changed_and_read_back():
cfg = FakeConfig()
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
history_label.set("My custom label for {willexecutor}")
assert BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
).get() == "My custom label for {willexecutor}"
# ------------------------------------------------------------------ #
# C4b - Reset to defaults
# ------------------------------------------------------------------ #
@@ -82,9 +123,10 @@ def _reset_to_defaults(configs):
def test_reset_restores_all_dialog_settings():
"""C4b: Reset restores every dialog setting to its factory default.
The dialog exposes seven settings: the original six plus the Group C
"Editable dates" checkbox, which the Reset button must also restore (this
was a follow-up fix after the first test round).
The dialog exposes nine settings: the original six, the Group C
"Editable dates" checkbox, and the Group H "Save inheritance transactions
in history" checkbox + "History label" field, which the Reset button must
also restore.
"""
cfg = FakeConfig()
@@ -102,6 +144,10 @@ def test_reset_restores_all_dialog_settings():
"bal_event_description",
"BAL will execution of $wallet_name\r\n heirs list: \r\n$heirs_complete",
)
save_history = BalConfig(cfg, "bal_save_history", True)
history_label = BalConfig(
cfg, "bal_history_label", "BitcoinAfterLife inheritance transaction - {willexecutor}"
)
settings = [
hide_replaced,
hide_invalidated,
@@ -110,6 +156,8 @@ def test_reset_restores_all_dialog_settings():
calendar_app,
event_summary,
event_description,
save_history,
history_label,
]
# Mutate every setting away from its default.
@@ -120,11 +168,15 @@ def test_reset_restores_all_dialog_settings():
calendar_app.set("/custom/app")
event_summary.set("custom summary")
event_description.set("custom description")
save_history.set(False)
history_label.set("custom label")
# Sanity: the values really changed.
assert hide_replaced.get() is False
assert editable_dates.get() is True
assert calendar_app.get() == "/custom/app"
assert save_history.get() is False
assert history_label.get() == "custom label"
# Reset and verify each one is back to its declared default.
_reset_to_defaults(settings)
@@ -132,6 +184,11 @@ def test_reset_restores_all_dialog_settings():
assert s.get() == s.default
# In particular the "Editable dates" flag is back OFF.
assert editable_dates.get() is False
# And the history persistence flag is back ON with the default template.
assert save_history.get() is True
assert history_label.get() == (
"BitcoinAfterLife inheritance transaction - {willexecutor}"
)
def test_reset_does_not_touch_unrelated_settings():

View File

@@ -0,0 +1,216 @@
"""
Tests for the history-save hook in ``BalWindow``.
Verifies that every successful prepare (including the "Prepare" menu action)
persists the freshly prepared transactions into the wallet's local history,
while abort paths (which return ``None``) skip the save. Also verifies that
after any local-history change (saving or removing will transactions) the
wallet tabs are re-rendered through ``update_tabs`` (all tabs) plus
``update_status`` (status-bar balance), and that the rebuild path uses the same
full refresh.
Run:
source electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_prepare_will_history.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import Mock, patch
import bal.gui.qt.window as win_mod
from bal.core.util import Util
from bal.core.will import NotCompleteWillException, Will
from bal.gui.qt.window import BalWindow
# ------------------------------------------------------------------ #
# prepare_will -> _save_will_to_history
# ------------------------------------------------------------------ #
def test_prepare_will_saves_to_history_on_success():
win = object.__new__(BalWindow)
will = {"wid": object()}
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=will)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is will
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_called_once_with()
def test_prepare_will_skips_save_when_build_aborted():
win = object.__new__(BalWindow)
with (
patch.object(BalWindow, "build_inheritance_transaction", return_value=None)
as build_mock,
patch.object(BalWindow, "_save_will_to_history") as save_mock,
):
result = BalWindow.prepare_will(win)
assert result is None
build_mock.assert_called_once_with(ignore_duplicate=False, keep_original=False)
save_mock.assert_not_called()
# ------------------------------------------------------------------ #
# history refresh: _save_will_to_history -> update_tabs + update_status
# ------------------------------------------------------------------ #
class _Cfg:
def __init__(self, value):
self._value = value
def get(self):
return self._value
class _CfgBag:
def __init__(self, **kwargs):
for name, value in kwargs.items():
setattr(self, name, value)
class _Wallet:
def dust_threshold(self):
return 546
class _FakeWindow:
def __init__(self, *, with_update_tabs=True):
self.show_message = Mock()
self.update_status = Mock()
self.history_list = Mock()
self.history_list.update = Mock()
if with_update_tabs:
self.update_tabs = Mock()
class _FakeQTimer:
calls = []
@classmethod
def singleShot(cls, delay, callable_):
cls.calls.append((delay, callable_))
def _make_save_window(save_enabled):
win = object.__new__(BalWindow)
win.bal_plugin = _CfgBag(
SAVE_HISTORY=_Cfg(save_enabled), HISTORY_LABEL=_Cfg("LBL")
)
win.willitems = {"wid": object()}
win.wallet = object()
return win
def test_save_will_to_history_schedules_refresh_when_enabled():
win = _make_save_window(save_enabled=True)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_called_once_with(win.willitems, win.wallet, "LBL")
schedule_mock.assert_called_once_with()
def test_save_will_to_history_skips_save_and_refresh_when_disabled():
win = _make_save_window(save_enabled=False)
with (
patch.object(Will, "save_valid_transactions_to_history") as save_mock,
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
save_mock.assert_not_called()
schedule_mock.assert_not_called()
def test_save_will_to_history_schedules_refresh_even_on_error():
win = _make_save_window(save_enabled=True)
with (
patch.object(
Will,
"save_valid_transactions_to_history",
side_effect=RuntimeError("boom"),
),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow._save_will_to_history(win)
schedule_mock.assert_called_once_with()
def test_schedule_history_refresh_marshals_to_gui_thread():
win = object.__new__(BalWindow)
with patch.object(win_mod, "QTimer", _FakeQTimer):
_FakeQTimer.calls.clear()
BalWindow._schedule_history_refresh(win)
assert _FakeQTimer.calls == [(0, win._refresh_after_history_save)]
def test_refresh_after_history_save_calls_update_tabs_and_status():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=True)
BalWindow._refresh_after_history_save(win)
win.window.update_tabs.assert_called_once_with()
win.window.update_status.assert_called_once_with()
win.window.history_list.update.assert_not_called()
def test_refresh_after_history_save_falls_back_to_history_list():
win = object.__new__(BalWindow)
win.window = _FakeWindow(with_update_tabs=False)
BalWindow._refresh_after_history_save(win)
win.window.history_list.update.assert_called_once_with()
win.window.update_status.assert_called_once_with()
def test_rebuild_path_schedules_full_refresh():
win = object.__new__(BalWindow)
win.disable_plugin = False
win.heirs = {"h": object()}
win.willexecutors = {}
win.no_willexecutor = True
win.willitems = {}
win.will = {}
win.date_to_check = 1_800_000_000
win.will_settings = {"baltx_fees": 1, "locktime": "1 month"}
win.bal_plugin = _CfgBag(
MAX_WILLEXECUTOR_FEE=_Cfg(1),
SAVE_HISTORY=_Cfg(True),
HISTORY_LABEL=_Cfg("LBL"),
)
win.window = _FakeWindow()
win.window.wallet = _Wallet()
with (
patch.object(Util, "get_available_utxos", return_value=[]),
patch.object(Util, "parse_locktime_string", return_value=1_800_000_001),
patch.object(Will, "get_min_locktime", return_value=0),
patch.object(Will, "check_amounts"),
patch.object(BalWindow, "init_class_variables"),
patch.object(BalWindow, "build_will"),
patch.object(
BalWindow,
"check_will",
side_effect=[NotCompleteWillException(), None],
),
patch.object(BalWindow, "update_all"),
patch.object(BalWindow, "_schedule_history_refresh") as schedule_mock,
):
BalWindow.build_inheritance_transaction(win)
schedule_mock.assert_called_once_with()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All prepare-will history tests passed")

View File

@@ -11,13 +11,16 @@ import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.theme import status_color
from bal.gui.qt.theme import signature_suffix, status_color
# ------------------------------------------------------------------ #
# Helpers
# ------------------------------------------------------------------ #
class FakeWillItem:
sigs_required = 0
sigs_have = 0
def __init__(self, **status_flags):
self._status = dict(status_flags)
def get_status(self, name):
@@ -97,6 +100,54 @@ def test_color_check_fail_overrides_push_fail():
assert status_color(item) == "#e83845"
# ------------------------------------------------------------------ #
# PARTIALLY_SIGNED
# ------------------------------------------------------------------ #
def test_color_partially_signed():
assert status_color(FakeWillItem(PARTIALLY_SIGNED=True)) == "#ffb347"
def test_color_partially_signed_overridden_by_higher_priority():
item = FakeWillItem(PARTIALLY_SIGNED=True, PUSHED=True)
assert status_color(item) == "#73f3c8"
# ------------------------------------------------------------------ #
# signature_suffix
# ------------------------------------------------------------------ #
def test_signature_suffix_partial():
item = FakeWillItem(PARTIALLY_SIGNED=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == " (1/2)"
def test_signature_suffix_new():
item = FakeWillItem()
item.sigs_required = 2
item.sigs_have = 0
assert signature_suffix(item) == " (0/2)"
def test_signature_suffix_complete_empty():
item = FakeWillItem(COMPLETE=True)
item.sigs_required = 2
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_unknown_required_empty():
item = FakeWillItem()
item.sigs_have = 1
assert signature_suffix(item) == ""
def test_signature_suffix_missing_fields_empty():
assert signature_suffix(FakeWillItem()) == ""
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #

View File

@@ -0,0 +1,559 @@
"""
Tests for the "Import" (read-only will preview) flow.
Covers:
- ``BalWindow._load_will_file`` round-trip (file -> WillItems).
- ``import_will_into_details`` normalization + IMPORTED status.
- ``BalWindow.sign_transactions`` operating ONLY on the passed (imported)
will, never on the live wallet state.
- ``WillWidget`` honouring an explicit ``will`` argument.
- ``WillDetailDialog`` external-will mode (threshold + isolated buttons).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_import_will_details.py
"""
import sys
import tempfile
from types import MethodType, SimpleNamespace
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from PyQt6.QtWidgets import QApplication, QWidget
from bal.core.will import Will, WillItem
from bal.gui.qt import window as window_mod
_app = QApplication.instance() or QApplication(sys.argv)
# 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_willitem_dict(**overrides):
"""Return a minimal dict that can construct a WillItem."""
d = {
"tx": _VALID_TX_HEX,
"heirs": {},
"willexecutor": None,
"status": "",
"description": "",
"time": 0,
"change": "",
"baltx_fees": 100,
}
d.update(overrides)
return d
def _make_willitems(n, prefix="w"):
"""Create ``n`` WillItems with distinct txids/locktimes."""
willitems = {}
for i in range(n):
wid = f"{prefix}{i}"
wi = WillItem(_make_willitem_dict())
wi.tx.locktime = 1000 + i
wi._id = wid
willitems[wid] = wi
return willitems
# ------------------------------------------------------------------ #
# _load_will_file round-trip
# ------------------------------------------------------------------ #
def test_load_will_file_roundtrip():
from bal.gui.qt.common import write_json_file
src = _make_willitems(2)
data = {wid: wi.to_dict() for wid, wi in src.items()}
with tempfile.NamedTemporaryFile(
mode="w", suffix=".json", delete=False
) as f:
path = f.name
write_json_file(path, data)
try:
loaded = window_mod.BalWindow._load_will_file(None, path)
assert set(loaded) == {"w0", "w1"}
for wid, wi in loaded.items():
assert isinstance(wi, WillItem)
assert wi.heirs == {}
assert wi._id == wid
assert wi.tx is not None
finally:
import os
os.unlink(path)
# ------------------------------------------------------------------ #
# import_will_into_details normalization + IMPORTED status
# ------------------------------------------------------------------ #
def test_imported_will_is_normalized_and_marked_imported():
willitems = _make_willitems(1)
Will.normalize_will(willitems, None)
for wi in willitems.values():
wi.set_status("IMPORTED", True)
assert all(wi.get_status("IMPORTED") for wi in willitems.values())
assert all(wi.get_status("VALID") for wi in willitems.values())
# ------------------------------------------------------------------ #
# sign_transactions operates only on the passed (imported) will
# ------------------------------------------------------------------ #
def test_sign_transactions_external_only():
class FakeWallet:
def sign_transaction(self, tx, password, ignore_warnings=False):
# No-op: never marks the tx complete.
return None
live = _make_willitems(1, prefix="live")
wid_live = next(iter(live))
imported = _make_willitems(2, prefix="imp")
fake = SimpleNamespace(
willitems=live,
wallet=FakeWallet(),
waiting_dialog=SimpleNamespace(update=lambda msg: None),
)
result = window_mod.BalWindow.sign_transactions(fake, None, will=imported)
assert result is not None
assert set(result) == set(imported)
assert wid_live not in result
# The live will must be completely untouched by the external sign run.
assert live[wid_live].get_status("COMPLETE") is False
# ------------------------------------------------------------------ #
# WillWidget honours an explicit ``will`` argument
# ------------------------------------------------------------------ #
def test_will_widget_explicit_will():
from bal.gui.qt.widgets import WillWidget
live = _make_willitems(1, prefix="live")
imported = _make_willitems(2, prefix="imp")
fake_parent = SimpleNamespace(
decimal_point=8,
base_unit_name="BTC",
bal_window=SimpleNamespace(
willitems=live,
bal_plugin=SimpleNamespace(
_hide_replaced=False, _hide_invalidated=False
),
show_transaction=lambda *a, **k: None,
),
)
w = WillWidget(parent=fake_parent, will=imported)
assert w.will is imported
w2 = WillWidget(parent=fake_parent)
assert w2.will is live
# ------------------------------------------------------------------ #
# WillDetailDialog external-will mode
# ------------------------------------------------------------------ #
def _make_fake_bal_window(window_widget):
bal_plugin = SimpleNamespace(read_file=lambda path: b"")
return SimpleNamespace(
window=window_widget,
bal_plugin=bal_plugin,
wallet=SimpleNamespace(),
show_transaction=lambda *a, **k: None,
willitems=_make_willitems(1),
will_settings={"real_threshold": 9999},
)
def test_will_detail_dialog_external_threshold():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(2)
# locktimes 1000 and 1001 -> threshold must be the max (1001).
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.will is imported
assert dialog.threshold == 1001
dialog2 = WillDetailDialog(bal_window)
assert dialog2._external_will is False
assert dialog2.will is bal_window.willitems
assert dialog2.threshold == 9999
def test_will_detail_dialog_buttons_pass_will():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
imported = _make_willitems(1)
dialog = WillDetailDialog(bal_window, will=imported)
calls = []
bal_window.ask_password_and_sign_transactions = lambda **k: calls.append(
("sign", k.get("will"))
)
bal_window.broadcast_transactions = lambda **k: calls.append(
("broadcast", k.get("will"))
)
bal_window.export_will = lambda **k: calls.append(("export", k.get("will")))
bal_window.invalidate_will = lambda **k: calls.append(
("invalidate", k.get("will"))
)
dialog.ask_password_and_sign_transactions()
dialog.broadcast_transactions()
dialog.export_will()
dialog.invalidate_will()
assert len(calls) == 4
for action, will in calls:
assert will is imported, f"{action} did not receive the imported will"
# ------------------------------------------------------------------ #
# Merge flow (BalWindow.merge_will)
# ------------------------------------------------------------------ #
def _make_partial_tx(locktime=1000, signed=False):
"""A PartialTransaction derived from _VALID_TX_HEX.
When ``signed`` the input scriptSig of the raw tx is copied over, which
finalizes the (legacy P2PKH) input and makes ``is_complete()`` True.
"""
from electrum.transaction import PartialTransaction, Transaction
ptx = PartialTransaction.from_tx(Transaction(_VALID_TX_HEX))
ptx.locktime = locktime
if signed:
raw = Transaction(_VALID_TX_HEX)
ptx.inputs()[0].script_sig = raw.inputs()[0].script_sig
return ptx
def _make_willitem_with_tx(tx, key=None):
wi = WillItem(_make_willitem_dict())
wi.tx = tx
wi._id = key if key is not None else tx.txid()
return wi
def _make_merge_fake(willitems):
"""A BalWindow-like object with a wallet stub sufficient for the local
validity check that ``merge_will`` runs after merging.
"""
class FakeWallet:
def add_input_info(self, txin, **kwargs):
pass
def add_output_info(self, txout, **kwargs):
pass
def get_utxos(self):
return []
def get_tx_info(self, tx):
return SimpleNamespace(
tx_mined_status=SimpleNamespace(height=lambda: 0)
)
@property
def db(self):
return SimpleNamespace(get_transaction=lambda txid: None)
calls = []
fake = SimpleNamespace(
willitems=willitems,
will={},
wallet=FakeWallet(),
bal_window=None,
date_to_check=1700000000,
bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})"
)
),
update_all=lambda: calls.append("update_all"),
)
fake.save_willitems = MethodType(window_mod.BalWindow.save_willitems, fake)
return fake, calls
def test_merge_will_same_id_unsigned_live_substitutes_signed_imported():
# The realistic "signed on another machine, imported here" case: the live
# will holds an unsigned PSBT (txid() -> None), the imported one is signed
# (complete). The transaction must be substituted and COMPLETE set.
wid = "same_id"
live_tx = _make_partial_tx(locktime=1000, signed=False)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
imported[wid].set_status("CHECKED", True)
fake, calls = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# The live WillItem object is kept (never replaced) and the signed tx
# substituted in.
assert live[wid] is live_item
assert live_item.tx is imported_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
# Operational statuses were carried over.
assert live_item.get_status("PUSHED") is True
assert live_item.get_status("CHECKED") is True
# The will was saved and the GUI refreshed.
assert wid in fake.will
assert fake.will[wid]["COMPLETE"] is True
assert "update_all" in calls
def test_merge_will_same_id_both_signed_combines():
# Live tx is signed but the COMPLETE flag was not set yet; the imported
# signed tx with the same txid must be COMBINED into the live one (the live
# tx object is kept) rather than substituted.
from electrum.transaction import Transaction
wid = "same_id_combine"
imported_tx = _make_partial_tx(locktime=1000, signed=True)
txid = imported_tx.txid()
assert txid is not None
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# Same txid -> combine_with_other_psbt: live tx object is preserved.
assert live_item.tx is live_tx
assert live_item.tx.is_complete()
assert live_item.get_status("COMPLETE") is True
raw_sig = Transaction(_VALID_TX_HEX).inputs()[0].script_sig
assert live_item.tx.inputs()[0].script_sig == raw_sig
def test_merge_will_same_id_already_complete_never_touches_live_tx():
wid = "already_complete"
live_tx = _make_partial_tx(locktime=1000, signed=False)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("COMPLETE", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("COMPLETE", True)
imported[wid].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
# An already-signed live will is left untouched: no combine, no substitute.
assert live_item.tx is live_tx
assert live_item.tx.is_complete() is False
assert live_item.tx.inputs()[0].script_sig is None
assert live_item.get_status("COMPLETE") is True
assert live_item.get_status("PUSHED") is True
def test_merge_will_statuses_are_monotonic():
# Statuses that are True in the live item must never be cleared by a False
# value coming from the imported item.
wid = "monotonic"
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
live[wid].set_status("CONFIRMED", True)
imported_tx = _make_partial_tx(locktime=1000, signed=True)
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
imported[wid].set_status("MEMPOOL", True)
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("CONFIRMED") is True
assert live_item.get_status("MEMPOOL") is True
assert live_item.get_status("COMPLETE") is True
def test_merge_will_new_ids_added_wholesale():
live_tx = _make_partial_tx(locktime=1000, signed=True)
live = {live_tx.txid(): _make_willitem_with_tx(live_tx)}
imported_tx = _make_partial_tx(locktime=2000, signed=True)
new_id = imported_tx.txid()
imported = {new_id: _make_willitem_with_tx(imported_tx)}
imported[new_id].set_status("PUSHED", True)
fake, _ = _make_merge_fake(live)
window_mod.BalWindow.merge_will(fake, imported)
assert len(live) == 2
assert live[new_id] is imported[new_id]
assert live[new_id].get_status("PUSHED") is True
assert new_id in fake.will
def test_merge_will_from_file_invalid_file_raises():
from bal.gui.qt.common import FileImportFailed
def bad_load(path):
raise ValueError("bad file")
fake = SimpleNamespace(_load_will_file=bad_load, wallet=None)
try:
window_mod.BalWindow.merge_will_from_file(fake, "/nonexistent.json")
except FileImportFailed as e:
assert "bad file" in str(e)
else:
raise AssertionError("expected FileImportFailed")
def test_merge_will_partial_signatures_update_counts():
# A live unsigned 2-of-3 multisig will merged with an imported copy that
# carries 1 signature must end up PARTIALLY_SIGNED with the sig counts
# refreshed on the live item (check_signatures runs after the merge).
from binascii import unhexlify
from electrum import crypto
from electrum.descriptor import parse_descriptor
from electrum.transaction import (
PartialTransaction,
PartialTxInput,
PartialTxOutput,
Sighash,
TxOutpoint,
)
def make_multisig_tx(nsigs):
pubs = []
for seed in (1, 2, 3):
pubs.append(crypto.privkey_to_pubkey(bytes([seed] * 32)).hex())
desc = parse_descriptor("wsh(multi(2,{}))".format(",".join(pubs)))
txin = PartialTxInput(prevout=TxOutpoint(b"\x11" * 32, 0), script_sig=b"")
txin.script_descriptor = desc
txin._trusted_value_sats = 100000
txin.sighash = Sighash.ALL
sig = b"\x30\x44\x02\x20" + b"\x01" * 32 + b"\x02\x20" + b"\x02" * 32
for i in range(nsigs):
txin.sigs_ecdsa[unhexlify(pubs[i])] = sig
from electrum.bitcoin import public_key_to_p2wpkh
addr = public_key_to_p2wpkh(bytes.fromhex(pubs[0]))
ptx = PartialTransaction()
ptx.add_inputs([txin])
ptx.add_outputs([PartialTxOutput.from_address_and_value(addr, 50000)])
ptx.locktime = 1000
return ptx, desc
wid = "multisig_will"
live_tx, _ = make_multisig_tx(0)
imported_tx, _ = make_multisig_tx(1)
live = {wid: _make_willitem_with_tx(live_tx, key=wid)}
imported = {wid: _make_willitem_with_tx(imported_tx, key=wid)}
fake, _ = _make_merge_fake(live)
live_item = live[wid]
window_mod.BalWindow.merge_will(fake, imported)
assert live_item.get_status("PARTIALLY_SIGNED") is True
assert live_item.sigs_have == 1
assert live_item.sigs_required == 2
def test_will_detail_dialog_merge_switches_to_live():
from bal.gui.qt.dialogs import WillDetailDialog
window_widget = QWidget()
bal_window = _make_fake_bal_window(window_widget)
bal_window.window.config = SimpleNamespace()
bal_window.window.format_amount = lambda *a, **k: "1.0"
bal_window.window.base_unit = "BTC"
bal_window.window.format_fiat_and_units = lambda *a, **k: "1.0"
bal_window.window.fx = None
bal_window.window.format_fee_rate = lambda *a, **k: "1.0"
bal_window.window.get_decimal_point = lambda: 8
live = bal_window.willitems
imported = _make_willitems(2)
merged = []
bal_window.merge_will = lambda will: merged.append(will)
dialog = WillDetailDialog(bal_window, will=imported)
assert dialog._external_will is True
assert dialog.merge_button is not None
assert dialog.merge_button.isHidden() is False
dialog.merge_will()
assert merged == [imported]
assert dialog._external_will is False
assert dialog.will is live
assert dialog.threshold == bal_window.will_settings["real_threshold"]
assert dialog.merge_button.isHidden() is True
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All import-will-details tests passed")

View File

@@ -0,0 +1,136 @@
"""
Tests for the "Save inheritance transactions in history" settings dialog rows.
Covers:
- The history label line-edit is present and bound to the HISTORY_LABEL
config (its text is the configured label).
- The rows are hidden in BASIC mode and visible in ADVANCED mode (advanced
-only settings, mirroring the other advanced rows).
- The history label line-edit is disabled while the "Save inheritance
transactions in history" checkbox is off, and re-enabled when it is on.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
QT_QPA_PLATFORM=offscreen python3 tests/test_settings_history_dialog.py
"""
import sys
import tempfile
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from unittest.mock import patch
from PyQt6.QtWidgets import QApplication
_app = QApplication.instance() or QApplication(sys.argv)
from electrum.simple_config import SimpleConfig
import bal.gui.qt.plugin as plugin_mod
from bal.gui.qt.plugin import Plugin
from bal.gui.qt.widgets import BalCheckBox, BalLineEdit
DEFAULT_LABEL = "BitcoinAfterLife inheritance transaction - {willexecutor}"
def _isolated_config(**overrides):
"""An in-memory SimpleConfig that never touches the real Electrum config.
A fresh ``electrum_path`` temp dir keeps every write isolated, so running
the tests cannot pollute the user's config files.
"""
opts = {"electrum_path": tempfile.mkdtemp(prefix="bal_test_")}
opts.update(overrides)
return SimpleConfig(opts)
def _build_dialog(user_type, **config_overrides):
"""Build the plugin settings dialog for *user_type* and return it.
``settings_dialog`` ends with a blocking ``show_modal(d)`` call; we patch it
to capture the dialog and return immediately.
"""
cfg = _isolated_config(**config_overrides)
plugin = Plugin(None, cfg, "bal")
plugin.get_window_title = lambda s: s
plugin.read_file = lambda *a: b""
plugin.broadcast_transactions = lambda *a, **k: None
plugin.update_all = lambda *a, **k: None
plugin.USER_TYPE.set(user_type)
captured = []
def fake_show_modal(dlg):
captured.append(dlg)
return True
with patch.object(plugin_mod, "show_modal", side_effect=fake_show_modal):
plugin.settings_dialog(None, None)
assert captured, "settings_dialog did not build a dialog"
return plugin, captured[0]
def _history_label_edit(dialog):
edits = [
w for w in dialog.findChildren(BalLineEdit) if w.text() == DEFAULT_LABEL
]
assert len(edits) == 1, f"expected exactly one history label edit, got {len(edits)}"
return edits[0]
def test_history_label_row_hidden_in_basic_visible_in_advanced():
plugin, basic = _build_dialog("basic")
assert _history_label_edit(basic).isHidden() is True
basic.close()
plugin, advanced = _build_dialog("advanced")
edit = _history_label_edit(advanced)
assert edit.isHidden() is False
advanced.close()
def test_history_label_field_follows_checkbox():
# Default: SAVE_HISTORY is ON, so the label field starts enabled.
plugin, dialog = _build_dialog("advanced")
edit = _history_label_edit(dialog)
assert edit.isEnabled() is True
# Find the checkbox that controls the label field's enabled state: it must
# be the "Save inheritance transactions in history" checkbox (the only one
# whose off-state disables the label field).
toggler = None
for box in dialog.findChildren(BalCheckBox):
if not box.isChecked():
continue
box.setChecked(False)
if not edit.isEnabled():
toggler = box
break
assert toggler is not None, "no checkbox disables the history label field"
# Toggling it back on re-enables the field.
toggler.setChecked(True)
assert edit.isEnabled() is True
assert plugin.SAVE_HISTORY.get() is True
dialog.close()
def test_history_label_field_disabled_from_start_when_off():
plugin, dialog = _build_dialog(
"advanced", **{"bal_save_history": False}
)
assert _history_label_edit(dialog).isEnabled() is False
dialog.close()
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All settings-history dialog tests passed")