forked from bitcoinafterlife/bal-electrum-plugin
Merge pull request #4 from Bitcoin-after-life/feature/networking-parallelo
feat: parallel Will-Executor networking (anti-freeze) + timeout counters + GUI tooltips/order
This commit is contained in:
@@ -455,3 +455,64 @@ producono **lo stesso identico risultato** di prima.
|
||||
Verificato anche che il test **fallisce** senza il fix.
|
||||
|
||||
Confermato dall'utente: **"si ora funziona"**.
|
||||
|
||||
## 14. NUOVA FUNZIONE: invalidazione automatica al posticipo dell'eredità
|
||||
|
||||
### Problema
|
||||
Una transazione di eredità viene firmata con un **locktime fisso e immutabile**
|
||||
e inviata ai will-executor, che sono economicamente incentivati a trasmetterla
|
||||
(incassano le fee). Se l'utente, dopo aver firmato/inviato, **posticipa** la
|
||||
data di consegna (es. di un anno), la **vecchia** transazione gia firmata resta
|
||||
valida sui server dei will-executor. Poiche ha il locktime piu basso, un
|
||||
will-executor potrebbe trasmetterla appena scade, eseguendo l'eredita **in
|
||||
anticipo** rispetto alla nuova volonta dell'utente. La versione precedente
|
||||
**non gestiva** questo caso: il posticipo non produceva alcuna azione.
|
||||
|
||||
### Soluzione (Strategia B — invalidazione esplicita on-chain)
|
||||
Al posticipo di un'eredita **gia firmata e/o inviata** (stato `COMPLETE` o
|
||||
`PUSHED`), il plugin chiede di **invalidare on-chain** i fondi prima di
|
||||
ricostruire la nuova eredita. L'invalidazione spende gli stessi UTXO verso un
|
||||
nuovo indirizzo di change con `locktime = altezza corrente` (RBF), quindi e
|
||||
trasmettibile subito: una volta confermata, la vecchia transazione pre-firmata
|
||||
diventa **definitivamente inutilizzabile**, vincendo la corsa contro qualunque
|
||||
will-executor.
|
||||
|
||||
### Dettagli tecnici
|
||||
- **`core/will.py`**:
|
||||
- nuova eccezione `WillPostponedException` (sottoclasse di
|
||||
`NotCompleteWillException`);
|
||||
- `check_willexecutors_and_heirs`: il confronto del locktime non usa piu
|
||||
l'entry dell'erede memorizzata (`their[2]`), che viene aggiornata in memoria
|
||||
insieme al nuovo valore al momento del posticipo e quindi risulterebbe
|
||||
sempre uguale. Ora confronta il locktime richiesto con **`w.tx.locktime`**,
|
||||
cioe il locktime **congelato** nella transazione firmata (immutabile, e
|
||||
quello che i will-executor possiedono). Tre casi: invariato → coerente;
|
||||
nuovo > tx su will firmato/inviato → `WillPostponedException`; nuovo > tx su
|
||||
will mai inviato → semplice ricostruzione (nessuna fee on-chain).
|
||||
- **`gui/qt/dialogs.py`** (`BalBuildWillDialog.task_phase1`, il percorso reale
|
||||
usato da **Tools → Prepare**): aggiunto il ramo `except WillPostponedException`
|
||||
**prima** di `NotCompleteWillException`; si comporta come il caso "will
|
||||
scaduto" e ritorna `(None, tx)` per innescare firma + broadcast
|
||||
dell'invalidazione. L'utente preme di nuovo **Prepare** per ricostruire,
|
||||
rifirmare e reinviare la nuova eredita (due passi espliciti, per maggior
|
||||
controllo).
|
||||
- **`gui/qt/window.py`** (`build_inheritance_transaction`): aggiunto lo stesso
|
||||
ramo per completezza del percorso alternativo, con messaggio esplicativo.
|
||||
- **`gui/qt/common.py`**: `WillPostponedException` esportato.
|
||||
|
||||
### NUOVA COLONNA "Server" nella lista transazioni
|
||||
Per dare all'utente visibilita costante sullo stato online delle proprie
|
||||
transazioni di eredita, e stata aggiunta una colonna dedicata **"Server"** in
|
||||
`PreviewList` (`gui/qt/lists.py`), con etichetta sempre leggibile
|
||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`, `Not on server`,
|
||||
`Signed (not sent)`, `Not sent`) e **tooltip** con URL del will-executor e
|
||||
stato. Le funzioni `server_status_text()` e `server_status_tooltip()` sono in
|
||||
`gui/qt/theme.py` e riusano gli stessi flag di stato gia esistenti.
|
||||
|
||||
### Test
|
||||
- I 182 test ufficiali continuano a passare; smoke test ed external-zip test
|
||||
OK; `ruff` senza nuove segnalazioni reali.
|
||||
- Verificato sui dati reali del log dell'utente: il posticipo di un'eredita
|
||||
firmata ora rileva correttamente la condizione e avvia l'invalidazione.
|
||||
|
||||
Confermato dall'utente: **"mi pare che funziona"**.
|
||||
|
||||
42
README.md
42
README.md
@@ -68,6 +68,48 @@ Copy the `bal/` directory into your Electrum installation's
|
||||
`electrum/plugins/` directory, so that `electrum/plugins/bal/manifest.json`
|
||||
exists, then enable it from **Tools → Plugins**.
|
||||
|
||||
## Inheritance safety: anticipate / postpone
|
||||
|
||||
A will transaction is signed with a **fixed, immutable locktime** and then
|
||||
optionally sent to will-executor servers, which are economically incentivised
|
||||
to broadcast it (they collect fees). Because the locktime is baked into the
|
||||
signed transaction, simply changing the delivery time later is **not enough**:
|
||||
the old, already-signed transaction keeps living on the will-executors.
|
||||
|
||||
The plugin handles the two cases as follows (triggered when you press
|
||||
**Tools → Prepare**):
|
||||
|
||||
* **Anticipate** (new delivery time *earlier* than the signed locktime): the
|
||||
will is treated as expired and you are asked to **invalidate** the old
|
||||
transaction on-chain, then rebuild.
|
||||
* **Postpone** (new delivery time *later* than the signed locktime) on a will
|
||||
that was already **signed and/or pushed**: the previously committed coins
|
||||
must be invalidated on-chain **first**, otherwise a will-executor could
|
||||
broadcast the old (earlier-locktime) transaction and execute the inheritance
|
||||
*too early*. The plugin detects this by comparing the requested locktime with
|
||||
the locktime **frozen inside the signed transaction** (`tx.locktime`), and
|
||||
asks you to sign and broadcast an invalidation transaction. After it is
|
||||
broadcast, press **Prepare** again to rebuild, re-sign and re-send the new
|
||||
(postponed) inheritance. Postponing a will that was *never* signed/sent just
|
||||
rebuilds it (no on-chain fee).
|
||||
|
||||
## Transaction list: the "Server" column
|
||||
|
||||
The will transaction list shows a dedicated **Server** column so you always
|
||||
know whether each inheritance transaction is actually stored on the
|
||||
will-executor servers, independently of the row colour:
|
||||
|
||||
| Label | Meaning |
|
||||
| --- | --- |
|
||||
| `Confirmed on server` | the will-executor confirmed it stored the transaction |
|
||||
| `Sent (not checked)` | pushed to the will-executor, not yet re-checked |
|
||||
| `Send failed` / `Not on server` | push failed or the server no longer has it |
|
||||
| `Signed (not sent)` | signed locally, not sent to any will-executor |
|
||||
| `Not sent` | not signed/sent yet |
|
||||
|
||||
Hovering the cell shows a tooltip with the will-executor URL and the current
|
||||
state.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
253
REPORT_NETWORKING_PARALLELO.md
Normal file
253
REPORT_NETWORKING_PARALLELO.md
Normal file
@@ -0,0 +1,253 @@
|
||||
# Technical report — Parallel networking (Will-Executor anti-freeze) + UI feedback
|
||||
|
||||
**Audience:** external programmer / plugin maintainer
|
||||
**Author:** AI refactoring work (on GitHub `Bitcoin-after-life/test`)
|
||||
**Date:** 2026-06-15
|
||||
**Branch:** `feature/networking-parallelo` (Pull Request #4)
|
||||
**Private Gitea repo `kaibot/bal-plugin-ai`: NOT modified** (per explicit request; cloned read-only only to run the official tests).
|
||||
|
||||
---
|
||||
|
||||
## 1. Problem
|
||||
|
||||
When the plugin contacts the Will-Executor servers (pushing transactions,
|
||||
pinging/refreshing the inheritance, downloading the list, and **checking**
|
||||
transactions), it used to do it **sequentially**. If a server did not answer,
|
||||
the thread stayed blocked on the connection timeouts and, worse, on the
|
||||
**retries**:
|
||||
|
||||
- `send_request` retried up to **10 times** with `time.sleep(3)` on every
|
||||
timeout → roughly **~140 seconds per unreachable server**, summed one after
|
||||
another.
|
||||
|
||||
Consequences:
|
||||
- Noticeable already with a few servers; with **20 servers** it became
|
||||
unusable.
|
||||
- The user saw "Stay waiting — Not responding" with no idea what was happening.
|
||||
- A single dead server blocked the whole operation.
|
||||
|
||||
---
|
||||
|
||||
## 2. Solution (overview)
|
||||
|
||||
1. **Parallelism** with `ThreadPoolExecutor`: servers are contacted
|
||||
concurrently. Total time ≈ the **slowest** server, not the **sum**.
|
||||
2. **Fast-fail** for interactive operations (ping/info/download): no retry
|
||||
storm, a single short timeout and the server is marked "KO".
|
||||
3. **Aggressive timeouts + global deadline** for push/check: a short per-server
|
||||
retry budget is kept (a real transaction must survive a transient hiccup),
|
||||
but a wall-clock **global deadline** caps the whole batch so a dialog never
|
||||
freezes behind one unresponsive server.
|
||||
4. **Live feedback + reliable elapsed-time counter**: `on_each(...)` updates the
|
||||
dialog as results arrive, and `on_tick()` refreshes an elapsed-time counter
|
||||
(`Xs / DEADLINEs`) so the user always knows progress and the maximum wait.
|
||||
|
||||
### Why it is thread-safe
|
||||
`Network.send_http_on_proxy()` uses `asyncio.run_coroutine_threadsafe(coro,
|
||||
loop)` and then `coro.result()`: every call schedules its own coroutine on
|
||||
Electrum's shared asyncio loop and blocks **only its own worker thread**.
|
||||
Multiple concurrent calls are therefore safe → `ThreadPoolExecutor` gives true
|
||||
parallelism.
|
||||
|
||||
UI updates go through `BalWaitingDialog.update()` / the dialog's `pyqtSignal`,
|
||||
which marshals to the GUI thread automatically. Callbacks from worker threads
|
||||
can therefore update the dialog safely.
|
||||
|
||||
### Why the counter is driven from the calling thread (important)
|
||||
An earlier attempt refreshed the elapsed-time counter from a separate raw
|
||||
`threading.Thread` heartbeat that emitted the `pyqtSignal`. That proved
|
||||
**unreliable**: a `pyqtSignal` emitted from a raw (non-Qt) Python thread inside
|
||||
the wizard's `TaskThread` was not reliably marshalled and the dialog never
|
||||
repainted — the counter was invisible.
|
||||
|
||||
The fix: the parallel helpers accept an **`on_tick` callback that is invoked
|
||||
periodically from the CALLING thread** (the same thread that already drives
|
||||
`on_each` and successfully repaints). The helpers poll the futures in short
|
||||
slices (`concurrent.futures.wait(..., timeout=tick_interval)`) and call
|
||||
`on_tick()` between waits. No heartbeat thread is used anymore.
|
||||
|
||||
---
|
||||
|
||||
## 3. Modified files (all on GitHub `Bitcoin-after-life/test`, branch `feature/networking-parallelo`)
|
||||
|
||||
### 3.1 `bal/core/willexecutors.py`
|
||||
|
||||
**Networking constants** (module level, also exposed as `Willexecutors` class
|
||||
attributes for a single source of truth in the GUI):
|
||||
```python
|
||||
DEFAULT_TIMEOUT = 5 # interactive ops (ping/info/list)
|
||||
|
||||
PUSH_TIMEOUT = 8 # broadcast (pushtxs)
|
||||
PUSH_MAX_RETRIES = 2
|
||||
PUSH_RETRY_SLEEP = 1
|
||||
PUSH_GLOBAL_DEADLINE = 30 # wall-clock cap for the whole parallel push
|
||||
|
||||
CHECK_TIMEOUT = 8 # check (searchtx)
|
||||
CHECK_MAX_RETRIES = 1
|
||||
CHECK_RETRY_SLEEP = 1
|
||||
CHECK_GLOBAL_DEADLINE = 30 # wall-clock cap for the whole parallel check
|
||||
```
|
||||
Worst case per server is now ~26s (push) / ~17s (check) instead of ~140s, and
|
||||
the global deadline guarantees the dialog proceeds within 30s regardless.
|
||||
|
||||
**`send_request(...)`** — keyword-only retry controls:
|
||||
```python
|
||||
def send_request(method, url, data=None, *, timeout=10, handle_response=None,
|
||||
count_reply=0, max_retries=10, retry_sleep=3):
|
||||
```
|
||||
- Defaults unchanged → callers that need the historical behaviour are
|
||||
unaffected.
|
||||
- Interactive callers pass `max_retries=0` → fast-fail.
|
||||
|
||||
**`get_info_task(...)`** — fast-fail by default (`max_retries=0`); a
|
||||
timeout/empty response yields `status="KO"`.
|
||||
|
||||
**`check_transaction(...)`** — now accepts `timeout`/`max_retries`/`retry_sleep`
|
||||
(defaults from the `CHECK_*` constants) and forwards them to `send_request`,
|
||||
replacing the old ~140s default storm.
|
||||
|
||||
**NEW `ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8,
|
||||
timeout=DEFAULT_TIMEOUT, on_tick=None, tick_interval=1.0)`**
|
||||
- `ThreadPoolExecutor`; polls futures in slices and calls `on_tick()` from the
|
||||
calling thread; mutates `willexecutors` in place; invokes
|
||||
`on_each(url, we, ok)` as results arrive; a worker exception never blocks the
|
||||
others (defensive try/except).
|
||||
|
||||
**NEW `push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8,
|
||||
deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None, on_tick=None,
|
||||
tick_interval=1.0)`**
|
||||
- Parallel push only to entries that have a `"txs"` key; each server keeps its
|
||||
short retry budget.
|
||||
- `on_each(url, we, ok, exc)` per server; `on_timeout(url, we)` for servers
|
||||
still pending when the global deadline elapses; `on_tick()` for the counter.
|
||||
- Manual pool (no `with`) so `shutdown(wait=False, cancel_futures=True)` does
|
||||
not block on a hung worker once the deadline is reached.
|
||||
- Returns `{url: (ok, exc)}` for the servers that answered in time.
|
||||
|
||||
**NEW `check_transactions_parallel(items, *, on_each=None, max_workers=8,
|
||||
deadline=CHECK_GLOBAL_DEADLINE, on_timeout=None, on_tick=None,
|
||||
tick_interval=1.0)`**
|
||||
- Same design as the push helper but for the **Check** (searchtx) operation.
|
||||
- `items` is an iterable of `(wid, url)` pairs; `_check_one` calls
|
||||
`check_transaction`.
|
||||
- `on_each(wid, url, result_or_None, exc)`, `on_timeout(wid, url)`, `on_tick()`.
|
||||
- Returns `{wid: (result_or_None, exc)}`.
|
||||
|
||||
### 3.2 `bal/gui/qt/window.py`
|
||||
|
||||
- **`ping_willexecutors_task(self, wes)`** rewritten on `ping_servers_parallel`
|
||||
with live feedback and a counter `Ping Will-Executors: 2/3 (3s / 30s)` driven
|
||||
by `on_tick` from the calling thread.
|
||||
- **`push_transactions_to_willexecutors(self, force=False)`** rewritten on
|
||||
`push_transactions_parallel`; `on_each` does thread-safe book-keeping + UI
|
||||
update; "already present" servers are verified afterwards (original check
|
||||
logic intact).
|
||||
- **`check_transactions_task(self, will)`** rewritten on
|
||||
`check_transactions_parallel`; shows `Checking transactions: 2/5 (4s / 30s)`,
|
||||
reusing the original `set_check_willexecutor(...)` per-item logic inside
|
||||
`on_each` (and `set_check_willexecutor(None)` on `on_timeout`).
|
||||
- **`fetch_will_executors_list(...)`** fast-fail download
|
||||
(`timeout=10, max_retries=1, retry_sleep=1`); the download dialog shows
|
||||
`Downloading will-executors list... (Xs / 45s)`.
|
||||
|
||||
### 3.3 `bal/gui/qt/dialogs.py`
|
||||
|
||||
- **`BalBuildWillDialog.loop_push`** (the "Building Will" wizard broadcast step)
|
||||
rewritten on `push_transactions_parallel` with the `on_tick` counter
|
||||
`Broadcasting 2/3 (5s / 30s)`. The previous raw heartbeat thread was removed.
|
||||
|
||||
### 3.4 `bal/gui/qt/plugin.py` — status-bar icon (restored)
|
||||
|
||||
`create_status_bar` re-adds the BAL `StatusBarButton` (bottom-right of the
|
||||
Electrum status bar). It shows that the plugin is installed and opens the plugin
|
||||
settings on click; it also de-duplicates the button per window. (Comments in
|
||||
English.)
|
||||
|
||||
### 3.5 `bal/gui/qt/lists.py` and `bal/gui/qt/widgets.py` — GUI usability
|
||||
|
||||
- **Tooltips** (hover) on the Will toolbar icons, all in English:
|
||||
Wizard (`Wizard - Build your will`), Delivery time (truck), Check Alive
|
||||
(siren), Calendar, Check (refresh).
|
||||
- **Toolbar order** changed to:
|
||||
`Wizard | Delivery time | Check Alive | Calendar | Check`; layout margins
|
||||
tightened so everything fits the Will window.
|
||||
|
||||
### 3.6 `bal/core/util.py` — BUGFIX (pre-existing regression)
|
||||
|
||||
In `get_value_amount` (line 324) `Util.in_output(...)` (returns `bool`) had been
|
||||
used instead of `Util.din_output(...)` (returns the tuple
|
||||
`(same_amount, same_address)`), causing:
|
||||
```
|
||||
TypeError: cannot unpack non-iterable bool object
|
||||
```
|
||||
**Fixed** by restoring `din_output`. Found by running the official Gitea tests
|
||||
(`tests/test_core_util.py::test_get_value_amount`).
|
||||
|
||||
---
|
||||
|
||||
## 4. Verification (ruff + official tests)
|
||||
|
||||
### 4.1 ruff (lint / PEP8)
|
||||
- `ruff check` on the new code: **no new issues** introduced. The `F403/F405/
|
||||
F401` warnings come from the original `from .common import *` pattern;
|
||||
per-file counts are identical between HEAD and the working tree.
|
||||
- The new parallel functions add **0 `E501`** (line-length) issues; in
|
||||
`window.py` the count actually decreased after the rewrite.
|
||||
- `ruff check tests/parallel_ping_test.py` → no new issues.
|
||||
|
||||
### 4.2 Official tests from the Gitea repo `kaibot/bal-plugin-ai/tests`
|
||||
Run against the refactored code (with all the networking + UI changes):
|
||||
|
||||
| Suite | Result |
|
||||
|-------|--------|
|
||||
| `test_core_*` + `test_gui_*` (pytest) | **182 passed** |
|
||||
| `smoke_test.py` | OK |
|
||||
| `external_zip_test.py` | OK |
|
||||
| `windows_overflow_test.py` | OK |
|
||||
| `gui_fixes_test.py` | OK |
|
||||
| `parallel_ping_test.py` (new) | OK — parallel ping/push/check ~`0.50s` for 8 servers (sequential would be ~`4.00s`); global deadline enforced; `on_tick` fired from the calling thread; static checks that the dialogs use the parallel helpers + the `Xs / Ns` counter |
|
||||
|
||||
Commands (as per README):
|
||||
```bash
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 -m pytest tests/ -q
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 tests/smoke_test.py electrum.plugins.bal
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 tests/external_zip_test.py bal-electrum-plugin.zip
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 tests/parallel_ping_test.py bal
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. Integration notes / risks
|
||||
|
||||
- **No change to the server protocol**: only the *how* (parallel) and the
|
||||
*when* (retries/deadline) of the calls changed, not the payloads.
|
||||
- **Push transactions**: per-server retries are intentionally kept so a real
|
||||
transaction is not lost to a transient hiccup; only ping/info/download use
|
||||
fast-fail. The global deadline marks unanswered servers as failed (`on_timeout`)
|
||||
so the user can retry later.
|
||||
- **`max_workers=8`** is conservative; with many servers (e.g. 20) it can be
|
||||
raised, but 8 workers already collapse the total time to the slowest server.
|
||||
- **Thread/UI**: all UI updates from workers go through `pyqtSignal`-based
|
||||
dialog updates; the periodic counter is driven by `on_tick` from the calling
|
||||
thread. Do **not** reintroduce a raw heartbeat thread emitting signals — it
|
||||
does not repaint reliably.
|
||||
- **Compatibility**: signatures are backward compatible (new parameters are
|
||||
keyword-only with defaults that preserve the old behaviour).
|
||||
|
||||
---
|
||||
|
||||
## 6. How to test
|
||||
|
||||
1. Install `bal-electrum-plugin.zip` (Tools → Plugins → install from file).
|
||||
Fully close and reopen Electrum to avoid the cached zip import.
|
||||
2. Configure several Will-Executors, including **at least one unreachable**.
|
||||
3. Run push / ping / Check: each dialog shows per-server status plus a counter
|
||||
`N/total (Xs / 30s)` and **no longer freezes** on the dead server — within
|
||||
the global deadline the operation reports the dead server and proceeds.
|
||||
|
||||
The SHA-256 of the zip is printed by `build_zip.py` at the end of the build
|
||||
(use it to verify integrity).
|
||||
@@ -1,2 +1,23 @@
|
||||
# BalPlugin
|
||||
Bitcoin After Life Electrum Plugin
|
||||
|
||||
Free and decentralized Bitcoin inheritance support for Electrum: build
|
||||
time-locked "will" transactions that transfer your funds to your heirs if you
|
||||
stop refreshing them (dead-man's switch), optionally relayed by will-executor
|
||||
servers.
|
||||
|
||||
## Key behaviours
|
||||
|
||||
- **Anticipate / postpone safety**: changing the delivery time of an
|
||||
already-signed will is handled safely. Postponing a signed/sent will first
|
||||
asks you to invalidate the old transaction on-chain (so a will-executor can
|
||||
never broadcast the earlier-locktime transaction and execute the inheritance
|
||||
too early), then lets you rebuild and re-send the new one via
|
||||
**Tools → Prepare**.
|
||||
- **"Server" column**: the will transaction list shows whether each transaction
|
||||
is actually stored on the will-executor servers
|
||||
(`Confirmed on server`, `Sent (not checked)`, `Send failed`,
|
||||
`Not on server`, `Signed (not sent)`, `Not sent`), with a tooltip showing the
|
||||
will-executor URL.
|
||||
|
||||
See the top-level [`README.md`](../README.md) for installation and testing.
|
||||
|
||||
@@ -306,6 +306,42 @@ def get_change_output(wallet, in_amount, out_amount, fee):
|
||||
return out
|
||||
|
||||
|
||||
def _json_safe(value, _path="heirs", _depth=0):
|
||||
"""Return a JSON-serializable deep copy of *value*.
|
||||
|
||||
The wallet DB persists the heirs dict via ``json_db.put``, which calls
|
||||
``copy.deepcopy`` on the value. If any nested element is a live runtime
|
||||
object (e.g. one holding a ``threading.RLock``), deepcopy raises
|
||||
``TypeError: cannot pickle '_thread.RLock' object`` and the whole
|
||||
"Build will" task fails.
|
||||
|
||||
To make persistence robust we coerce the structure to plain
|
||||
JSON-compatible types (dict / list / str / int / float / bool / None).
|
||||
Anything else is converted to ``str(value)`` and logged with its path so
|
||||
the offending field can be identified, instead of crashing the task.
|
||||
"""
|
||||
# Primitive JSON scalars are kept as-is.
|
||||
if value is None or isinstance(value, (bool, int, float, str)):
|
||||
return value
|
||||
if isinstance(value, dict):
|
||||
return {
|
||||
str(k): _json_safe(v, "{}[{!r}]".format(_path, k), _depth + 1)
|
||||
for k, v in value.items()
|
||||
}
|
||||
if isinstance(value, (list, tuple)):
|
||||
return [
|
||||
_json_safe(v, "{}[{}]".format(_path, i), _depth + 1)
|
||||
for i, v in enumerate(value)
|
||||
]
|
||||
# Unexpected runtime object: do not let it reach deepcopy. Log where it
|
||||
# was found so the real source can be fixed, then store a safe string.
|
||||
_logger.error(
|
||||
"heirs.save: non-serializable value at {} (type={}); coercing to str. "
|
||||
"value={!r}".format(_path, type(value).__name__, value)
|
||||
)
|
||||
return str(value)
|
||||
|
||||
|
||||
class Heirs(dict, Logger):
|
||||
|
||||
def __init__(self, wallet):
|
||||
@@ -322,7 +358,11 @@ class Heirs(dict, Logger):
|
||||
invalidate_inheritance_transactions(wallet)
|
||||
|
||||
def save(self):
|
||||
self.db.put("heirs", dict(self))
|
||||
# Sanitise the heirs mapping before handing it to the wallet DB: this
|
||||
# guarantees only JSON-serializable values are stored and prevents the
|
||||
# "cannot pickle '_thread.RLock' object" failure that aborted the
|
||||
# Build-will task when a runtime object slipped into an heir value.
|
||||
self.db.put("heirs", _json_safe(dict(self)))
|
||||
|
||||
def import_file(self, path):
|
||||
data = read_json_file(path)
|
||||
|
||||
@@ -321,7 +321,7 @@ class Util:
|
||||
value_amount = 0
|
||||
|
||||
for outa in outputsa:
|
||||
same_amount, same_address = Util.in_output(outa, txb.outputs())
|
||||
same_amount, same_address = Util.din_output(outa, txb.outputs())
|
||||
if not (same_amount or same_address):
|
||||
return False
|
||||
if same_amount and same_address:
|
||||
|
||||
@@ -671,14 +671,41 @@ class Will:
|
||||
their = will[wid].heirs[wheir]
|
||||
if heir := heirs.get(wheir, None):
|
||||
|
||||
if (
|
||||
heir[0] == their[0]
|
||||
and heir[1] == their[1]
|
||||
and Util.parse_locktime_string(heir[2])
|
||||
>= Util.parse_locktime_string(their[2])
|
||||
):
|
||||
count = heirs_found.get(wheir, 0)
|
||||
heirs_found[wheir] = count + 1
|
||||
if heir[0] == their[0] and heir[1] == their[1]:
|
||||
# The requested (possibly new) locktime for this heir.
|
||||
new_locktime = Util.parse_locktime_string(heir[2])
|
||||
# IMPORTANT: compare against the locktime that is
|
||||
# actually frozen inside the already-signed Bitcoin
|
||||
# transaction (w.tx.locktime), NOT against their[2].
|
||||
# their[2] is the heir entry stored in the will item,
|
||||
# which is updated in memory together with the new
|
||||
# heirs dict when the user postpones, so it would
|
||||
# always equal new_locktime and the postpone would go
|
||||
# undetected. w.tx.locktime is immutable once signed
|
||||
# and is exactly what the will-executors hold.
|
||||
tx_locktime = int(w.tx.locktime)
|
||||
if new_locktime == tx_locktime:
|
||||
# Unchanged: this heir is still coherent.
|
||||
count = heirs_found.get(wheir, 0)
|
||||
heirs_found[wheir] = count + 1
|
||||
elif new_locktime > tx_locktime and (
|
||||
w.get_status("COMPLETE") or w.get_status("PUSHED")
|
||||
):
|
||||
# POSTPONE of an already signed/sent will: the
|
||||
# old pre-signed tx must be invalidated on-chain
|
||||
# first, otherwise a will-executor could
|
||||
# broadcast the earlier-locktime tx and execute
|
||||
# the inheritance too early.
|
||||
raise WillPostponedException(
|
||||
f"{wheir}: locktime postponed "
|
||||
f"{tx_locktime}->{new_locktime} "
|
||||
f"on a signed/sent will"
|
||||
)
|
||||
# new_locktime < tx_locktime (anticipate) is left to
|
||||
# check_will_expired -> WillExpiredException.
|
||||
# new_locktime > tx_locktime on a will that was never
|
||||
# signed/sent falls through here -> a plain rebuild via
|
||||
# HeirNotFoundException (no on-chain fee needed).
|
||||
else:
|
||||
_logger.debug(
|
||||
f"heir not present transaction is not valid:{wheir} {wid}, {w}"
|
||||
@@ -912,6 +939,21 @@ class HeirNotFoundException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
class WillPostponedException(NotCompleteWillException):
|
||||
"""An already signed/sent will is being postponed.
|
||||
|
||||
When a will that has already been signed (``COMPLETE``) and/or pushed to
|
||||
will-executors (``PUSHED``) gets its locktime moved to a LATER date, the
|
||||
previously committed coins must be invalidated on-chain BEFORE rebuilding
|
||||
the new inheritance. Otherwise a will-executor could broadcast the old
|
||||
(earlier-locktime) transaction and execute the inheritance too early to
|
||||
collect the fees. Invalidating spends the same UTXOs now, permanently
|
||||
voiding the old pre-signed transaction.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class WillexecutorChangeException(NotCompleteWillException):
|
||||
pass
|
||||
|
||||
|
||||
@@ -25,7 +25,36 @@ from electrum.network import Network
|
||||
|
||||
from .plugin_base import BalPlugin
|
||||
|
||||
# Per-request timeout (seconds) for interactive operations (ping / info /
|
||||
# list download). These fail fast (no retries) so a dead server does not
|
||||
# block the UI.
|
||||
DEFAULT_TIMEOUT = 5
|
||||
|
||||
# Broadcast (pushtxs) timeouts. Broadcasting a will is important, so we keep a
|
||||
# couple of quick retries to survive a transient hiccup -- but far from the old
|
||||
# 10s x 10 retries + 30s sleeps (~140s) that froze the wizard on a dead server.
|
||||
# Worst case per server is now ~ PUSH_TIMEOUT * (1 + PUSH_MAX_RETRIES)
|
||||
# + PUSH_RETRY_SLEEP * PUSH_MAX_RETRIES = 8 * 3 + 1 * 2 = ~26s, and the wizard
|
||||
# also enforces a global deadline on top of this (see push_transactions_parallel).
|
||||
PUSH_TIMEOUT = 8
|
||||
PUSH_MAX_RETRIES = 2
|
||||
PUSH_RETRY_SLEEP = 1
|
||||
|
||||
# Global wall-clock deadline (seconds) for the whole parallel broadcast. Once
|
||||
# it elapses we stop waiting for the still-pending servers, mark them as
|
||||
# "Timeout" and let the wizard proceed instead of appearing stuck.
|
||||
PUSH_GLOBAL_DEADLINE = 30
|
||||
|
||||
# Check (searchtx) timeouts. Used when the user presses "Check" to verify that
|
||||
# each will-executor still holds the transaction. Like the broadcast path, the
|
||||
# old defaults (10s x 10 retries + 30s sleeps ~= 140s per server) froze the
|
||||
# "checking transaction" dialog on a single dead server. Fail fast with one
|
||||
# quick retry, and cap the whole batch with a global deadline.
|
||||
CHECK_TIMEOUT = 8
|
||||
CHECK_MAX_RETRIES = 1
|
||||
CHECK_RETRY_SLEEP = 1
|
||||
CHECK_GLOBAL_DEADLINE = 30
|
||||
|
||||
_logger = get_logger(__name__)
|
||||
|
||||
|
||||
@@ -34,6 +63,20 @@ chainname = BalPlugin.chainname
|
||||
|
||||
class Willexecutors:
|
||||
|
||||
# Expose the networking constants as class attributes so the GUI layer can
|
||||
# reference them (e.g. to show the "Xs / DEADLINEs" countdown) without
|
||||
# importing module-level names. Single source of truth: the module
|
||||
# constants defined above.
|
||||
DEFAULT_TIMEOUT = DEFAULT_TIMEOUT
|
||||
PUSH_TIMEOUT = PUSH_TIMEOUT
|
||||
PUSH_MAX_RETRIES = PUSH_MAX_RETRIES
|
||||
PUSH_RETRY_SLEEP = PUSH_RETRY_SLEEP
|
||||
PUSH_GLOBAL_DEADLINE = PUSH_GLOBAL_DEADLINE
|
||||
CHECK_TIMEOUT = CHECK_TIMEOUT
|
||||
CHECK_MAX_RETRIES = CHECK_MAX_RETRIES
|
||||
CHECK_RETRY_SLEEP = CHECK_RETRY_SLEEP
|
||||
CHECK_GLOBAL_DEADLINE = CHECK_GLOBAL_DEADLINE
|
||||
|
||||
@staticmethod
|
||||
def save(bal_plugin, willexecutors):
|
||||
_logger.debug(f"save {willexecutors},{chainname}")
|
||||
@@ -145,8 +188,21 @@ class Willexecutors:
|
||||
|
||||
@staticmethod
|
||||
def send_request(
|
||||
method, url, data=None, *, timeout=10, handle_response=None, count_reply=0
|
||||
method, url, data=None, *, timeout=10, handle_response=None, count_reply=0,
|
||||
max_retries=10, retry_sleep=3,
|
||||
):
|
||||
"""Send an HTTP request to a will-executor server.
|
||||
|
||||
``max_retries`` / ``retry_sleep`` control the timeout-retry behaviour:
|
||||
|
||||
* For *critical* operations (pushing inheritance transactions) the
|
||||
historical default of up to 10 retries with a 3s back-off is kept, so
|
||||
a transient network hiccup does not lose a transaction.
|
||||
* For *interactive* operations (ping / info / list download) callers
|
||||
should pass ``max_retries=0`` so a dead server fails fast (one short
|
||||
timeout) instead of blocking the UI for minutes. See
|
||||
:meth:`ping_servers_parallel`.
|
||||
"""
|
||||
network = Network.get_instance()
|
||||
if not network:
|
||||
raise Exception("You are offline.")
|
||||
@@ -178,9 +234,12 @@ class Willexecutors:
|
||||
else:
|
||||
raise Exception(f"unexpected {method=!r}")
|
||||
except TimeoutError:
|
||||
if count_reply < 10:
|
||||
_logger.debug(f"timeout({count_reply}) error: retry in 3 sec...")
|
||||
time.sleep(3)
|
||||
if count_reply < max_retries:
|
||||
_logger.debug(
|
||||
f"timeout({count_reply}) error: retry in {retry_sleep} sec..."
|
||||
)
|
||||
if retry_sleep:
|
||||
time.sleep(retry_sleep)
|
||||
return Willexecutors.send_request(
|
||||
method,
|
||||
url,
|
||||
@@ -188,6 +247,8 @@ class Willexecutors:
|
||||
timeout=timeout,
|
||||
handle_response=handle_response,
|
||||
count_reply=count_reply + 1,
|
||||
max_retries=max_retries,
|
||||
retry_sleep=retry_sleep,
|
||||
)
|
||||
else:
|
||||
_logger.debug(f"Too many timeouts: {count_reply}")
|
||||
@@ -223,7 +284,15 @@ class Willexecutors:
|
||||
pass
|
||||
|
||||
@staticmethod
|
||||
def push_transactions_to_willexecutor(willexecutor):
|
||||
def push_transactions_to_willexecutor(
|
||||
willexecutor, *, timeout=PUSH_TIMEOUT, max_retries=PUSH_MAX_RETRIES,
|
||||
retry_sleep=PUSH_RETRY_SLEEP,
|
||||
):
|
||||
# ``timeout`` / ``max_retries`` / ``retry_sleep`` are forwarded to
|
||||
# send_request so the broadcast fails fast on a dead/slow server instead
|
||||
# of hanging for ~140s (the old default was 10s timeout x 10 retries +
|
||||
# 30s of sleeps). A small number of quick retries still protects
|
||||
# against a transient hiccup without freezing the wizard.
|
||||
out = True
|
||||
try:
|
||||
_logger.debug(f"{willexecutor['url']}: {willexecutor['txs']}")
|
||||
@@ -231,6 +300,9 @@ class Willexecutors:
|
||||
"post",
|
||||
willexecutor["url"] + "/" + chainname + "/pushtxs",
|
||||
data=willexecutor["txs"].encode("ascii"),
|
||||
timeout=timeout,
|
||||
max_retries=max_retries,
|
||||
retry_sleep=retry_sleep,
|
||||
):
|
||||
willexecutor["broadcast_status"] = _("Success")
|
||||
_logger.debug(f"pushed: {w}")
|
||||
@@ -254,18 +326,28 @@ class Willexecutors:
|
||||
Willexecutors.get_info_task(url, we)
|
||||
|
||||
@staticmethod
|
||||
def get_info_task(url, willexecutor):
|
||||
def get_info_task(url, willexecutor, *, timeout=DEFAULT_TIMEOUT,
|
||||
max_retries=0, retry_sleep=0):
|
||||
w = None
|
||||
try:
|
||||
_logger.info("GETINFO_WILLEXECUTOR")
|
||||
_logger.debug(url)
|
||||
w = Willexecutors.send_request("get", url + "/" + chainname + "/info")
|
||||
# Fast-fail by default (max_retries=0): a dead server returns after a
|
||||
# single short timeout instead of retrying 10x with sleeps, which
|
||||
# used to freeze the UI for minutes per unreachable server.
|
||||
w = Willexecutors.send_request(
|
||||
"get", url + "/" + chainname + "/info",
|
||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||
)
|
||||
if isinstance(w, dict):
|
||||
willexecutor["url"] = url
|
||||
willexecutor["status"] = 200
|
||||
willexecutor["base_fee"] = w["base_fee"]
|
||||
willexecutor["address"] = w["address"]
|
||||
willexecutor["info"] = w["info"]
|
||||
else:
|
||||
# No dict reply (timeout / empty) -> mark as unreachable.
|
||||
willexecutor["status"] = "KO"
|
||||
_logger.debug(f"response_data {w}")
|
||||
except Exception as e:
|
||||
_logger.error(f"error {e} contacting {url}: {w}")
|
||||
@@ -274,6 +356,319 @@ class Willexecutors:
|
||||
willexecutor["last_update"] = datetime.now().timestamp()
|
||||
return willexecutor
|
||||
|
||||
@staticmethod
|
||||
def ping_servers_parallel(willexecutors, *, on_each=None, max_workers=8,
|
||||
timeout=DEFAULT_TIMEOUT, on_tick=None,
|
||||
tick_interval=1.0):
|
||||
"""Ping every will-executor concurrently and report results as they
|
||||
arrive.
|
||||
|
||||
Network requests run in a thread pool: each ``send_http_on_proxy`` call
|
||||
schedules its coroutine on Electrum's shared asyncio loop and blocks
|
||||
only its *own* worker thread, so the total wall-clock time is roughly
|
||||
that of the slowest server rather than the *sum* of all of them. A
|
||||
single dead server can no longer stall the whole batch.
|
||||
|
||||
Args:
|
||||
willexecutors: ``{url: we_dict}`` mapping (mutated in place with the
|
||||
ping result, exactly like the old sequential ``ping_servers``).
|
||||
on_each: optional ``callback(url, we_dict, ok: bool)`` invoked from a
|
||||
worker thread each time a server answers (or fails), so the GUI
|
||||
can update its list live. Must be thread-safe / marshalled to
|
||||
the GUI thread by the caller.
|
||||
max_workers: maximum number of concurrent pings.
|
||||
timeout: per-request timeout in seconds (fast-fail, no retries).
|
||||
|
||||
on_tick: optional ``callback()`` invoked periodically (every
|
||||
``tick_interval`` seconds) **from the calling thread** while
|
||||
waiting for servers, so a Qt caller can refresh an elapsed-time
|
||||
counter from the same thread that drives ``on_each``.
|
||||
|
||||
Returns:
|
||||
The same ``willexecutors`` mapping, updated in place.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from concurrent.futures import FIRST_COMPLETED
|
||||
|
||||
items = list(willexecutors.items())
|
||||
if not items:
|
||||
return willexecutors
|
||||
|
||||
def _ping_one(url, we):
|
||||
we = Willexecutors.get_info_task(
|
||||
url, we, timeout=timeout, max_retries=0, retry_sleep=0
|
||||
)
|
||||
ok = we.get("status") == 200
|
||||
return url, we, ok
|
||||
|
||||
def _fire_tick():
|
||||
if on_tick is not None:
|
||||
try:
|
||||
on_tick()
|
||||
except Exception as cb_err:
|
||||
_logger.error(f"ping on_tick callback error: {cb_err}")
|
||||
|
||||
workers = max(1, min(max_workers, len(items)))
|
||||
# Manual pool (no ``with``) so we can poll futures in short slices and
|
||||
# drive ``on_tick`` from THIS thread between waits (reliable Qt repaint).
|
||||
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-ping")
|
||||
futures = {pool.submit(_ping_one, url, we) for url, we in items}
|
||||
try:
|
||||
pending = set(futures)
|
||||
while pending:
|
||||
done, pending = wait(
|
||||
pending, timeout=tick_interval, return_when=FIRST_COMPLETED
|
||||
)
|
||||
for fut in done:
|
||||
try:
|
||||
url, we, ok = fut.result()
|
||||
except Exception as e: # defensive: one server never crashes all
|
||||
_logger.error(f"ping_servers_parallel worker error: {e}")
|
||||
continue
|
||||
willexecutors[url] = we
|
||||
if on_each is not None:
|
||||
try:
|
||||
on_each(url, we, ok)
|
||||
except Exception as cb_err:
|
||||
_logger.error(f"ping on_each callback error: {cb_err}")
|
||||
# Drive the elapsed-time counter from the calling thread.
|
||||
_fire_tick()
|
||||
finally:
|
||||
try:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
pool.shutdown(wait=False)
|
||||
return willexecutors
|
||||
|
||||
@staticmethod
|
||||
def push_transactions_parallel(willexecutors, *, on_each=None, max_workers=8,
|
||||
deadline=PUSH_GLOBAL_DEADLINE, on_timeout=None,
|
||||
on_tick=None, tick_interval=1.0):
|
||||
"""Push transactions to multiple will-executors concurrently.
|
||||
|
||||
Like :meth:`ping_servers_parallel` but for the ``pushtxs`` operation.
|
||||
Each server keeps a short retry behaviour
|
||||
(:meth:`push_transactions_to_willexecutor`) so a real transaction is not
|
||||
lost to a transient hiccup, but servers are contacted in parallel and
|
||||
results are reported via ``on_each(url, we_dict, ok, exc)`` as they
|
||||
complete.
|
||||
|
||||
A global wall-clock ``deadline`` (seconds) caps the whole operation: if
|
||||
some servers are still pending when it elapses, we stop waiting, mark
|
||||
them via ``on_timeout(url, we_dict)`` and return, so the caller (the
|
||||
wizard) is never stuck behind one unresponsive server. Pass
|
||||
``deadline=None`` to wait indefinitely (old behaviour).
|
||||
|
||||
``on_tick()`` is invoked periodically (every ``tick_interval`` seconds)
|
||||
**from the calling thread** while waiting for workers. This lets a Qt
|
||||
caller refresh an elapsed-time counter from the same thread that drives
|
||||
``on_each`` (so its pyqtSignal repaints reliably), instead of relying on
|
||||
a separate heartbeat thread whose signal emissions are not marshalled.
|
||||
|
||||
Returns ``{url: (ok, exception_or_None)}`` for the servers that
|
||||
answered in time (timed-out servers are reported via ``on_timeout``).
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from concurrent.futures import FIRST_COMPLETED
|
||||
|
||||
targets = [(url, we) for url, we in willexecutors.items() if "txs" in we]
|
||||
results = {}
|
||||
if not targets:
|
||||
return results
|
||||
|
||||
def _push_one(url, we):
|
||||
try:
|
||||
ok = Willexecutors.push_transactions_to_willexecutor(we)
|
||||
return url, we, ok, None
|
||||
except Willexecutors.AlreadyPresentException as ape:
|
||||
return url, we, False, ape
|
||||
except Exception as e:
|
||||
return url, we, False, e
|
||||
|
||||
def _fire_tick():
|
||||
if on_tick is not None:
|
||||
try:
|
||||
on_tick()
|
||||
except Exception as cb_err:
|
||||
_logger.error(f"push on_tick callback error: {cb_err}")
|
||||
|
||||
workers = max(1, min(max_workers, len(targets)))
|
||||
# NOTE: we do not use ``with ThreadPoolExecutor(...)`` here because its
|
||||
# __exit__ calls shutdown(wait=True), which would block on a hung worker
|
||||
# and defeat the whole point of the global deadline. We shut the pool
|
||||
# down without waiting once the deadline elapses; the daemon worker(s)
|
||||
# stuck on a dead socket will be torn down when their request finally
|
||||
# times out (PUSH_TIMEOUT), without holding up the wizard.
|
||||
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-push")
|
||||
fut_to_url = {pool.submit(_push_one, url, we): (url, we)
|
||||
for url, we in targets}
|
||||
start = time.time()
|
||||
try:
|
||||
# Poll the futures in short slices so we can call ``on_tick`` from
|
||||
# THIS thread between waits. ``wait(..., timeout=tick_interval)``
|
||||
# returns as soon as a future completes OR the slice elapses,
|
||||
# whichever comes first, so the counter advances ~once per second
|
||||
# while the parallel push runs.
|
||||
pending = set(fut_to_url.keys())
|
||||
while pending:
|
||||
if deadline is not None and (time.time() - start) >= deadline:
|
||||
break
|
||||
slice_timeout = tick_interval
|
||||
if deadline is not None:
|
||||
remaining = deadline - (time.time() - start)
|
||||
slice_timeout = max(0.0, min(tick_interval, remaining))
|
||||
done, pending = wait(
|
||||
pending, timeout=slice_timeout, return_when=FIRST_COMPLETED
|
||||
)
|
||||
for fut in done:
|
||||
try:
|
||||
url, we, ok, exc = fut.result()
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
f"push_transactions_parallel worker error: {e}"
|
||||
)
|
||||
continue
|
||||
results[url] = (ok, exc)
|
||||
if on_each is not None:
|
||||
try:
|
||||
on_each(url, we, ok, exc)
|
||||
except Exception as cb_err:
|
||||
_logger.error(f"push on_each callback error: {cb_err}")
|
||||
# Drive the elapsed-time counter from the calling thread.
|
||||
_fire_tick()
|
||||
# Any server still pending here hit the global deadline.
|
||||
if pending:
|
||||
elapsed = time.time() - start
|
||||
_logger.warning(
|
||||
f"push global deadline ({deadline}s) reached after "
|
||||
f"{elapsed:.1f}s; {len(pending)} server(s) "
|
||||
f"did not answer in time"
|
||||
)
|
||||
for fut in pending:
|
||||
url, we = fut_to_url[fut]
|
||||
if url in results:
|
||||
continue
|
||||
if on_timeout is not None:
|
||||
try:
|
||||
on_timeout(url, we)
|
||||
except Exception as cb_err:
|
||||
_logger.error(
|
||||
f"push on_timeout callback error: {cb_err}"
|
||||
)
|
||||
finally:
|
||||
# Do not block on still-running workers (Python 3.9+: cancel queued).
|
||||
try:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
pool.shutdown(wait=False)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def check_transactions_parallel(items, *, on_each=None, max_workers=8,
|
||||
deadline=CHECK_GLOBAL_DEADLINE,
|
||||
on_timeout=None, on_tick=None,
|
||||
tick_interval=1.0):
|
||||
"""Check (searchtx) several will-executors concurrently.
|
||||
|
||||
Same design as :meth:`push_transactions_parallel`, but for the "Check"
|
||||
operation: it verifies that each will-executor still holds its
|
||||
transaction. ``items`` is an iterable of ``(wid, url)`` pairs (one per
|
||||
will-item that has a will-executor).
|
||||
|
||||
Each server is contacted in parallel with a short fail-fast retry
|
||||
(:meth:`check_transaction`), results are reported via
|
||||
``on_each(wid, url, result_or_None, exc)`` as they arrive, ``on_tick()``
|
||||
is called periodically from the calling thread to refresh a counter, and
|
||||
a global ``deadline`` guarantees the dialog never freezes behind one
|
||||
unresponsive server (pending servers are reported via
|
||||
``on_timeout(wid, url)``).
|
||||
|
||||
Returns ``{wid: (result_or_None, exception_or_None)}`` for the servers
|
||||
that answered in time.
|
||||
"""
|
||||
from concurrent.futures import ThreadPoolExecutor, wait
|
||||
from concurrent.futures import FIRST_COMPLETED
|
||||
|
||||
targets = [(wid, url) for wid, url in items if url]
|
||||
results = {}
|
||||
if not targets:
|
||||
return results
|
||||
|
||||
def _check_one(wid, url):
|
||||
try:
|
||||
res = Willexecutors.check_transaction(wid, url)
|
||||
return wid, url, res, None
|
||||
except Exception as e:
|
||||
return wid, url, None, e
|
||||
|
||||
def _fire_tick():
|
||||
if on_tick is not None:
|
||||
try:
|
||||
on_tick()
|
||||
except Exception as cb_err:
|
||||
_logger.error(f"check on_tick callback error: {cb_err}")
|
||||
|
||||
workers = max(1, min(max_workers, len(targets)))
|
||||
# Manual pool (no ``with``): we must not block on a hung worker when the
|
||||
# global deadline elapses (see push_transactions_parallel for details).
|
||||
pool = ThreadPoolExecutor(max_workers=workers, thread_name_prefix="bal-check")
|
||||
fut_to_target = {pool.submit(_check_one, wid, url): (wid, url)
|
||||
for wid, url in targets}
|
||||
start = time.time()
|
||||
try:
|
||||
pending = set(fut_to_target.keys())
|
||||
while pending:
|
||||
if deadline is not None and (time.time() - start) >= deadline:
|
||||
break
|
||||
slice_timeout = tick_interval
|
||||
if deadline is not None:
|
||||
remaining = deadline - (time.time() - start)
|
||||
slice_timeout = max(0.0, min(tick_interval, remaining))
|
||||
done, pending = wait(
|
||||
pending, timeout=slice_timeout, return_when=FIRST_COMPLETED
|
||||
)
|
||||
for fut in done:
|
||||
try:
|
||||
wid, url, res, exc = fut.result()
|
||||
except Exception as e:
|
||||
_logger.error(
|
||||
f"check_transactions_parallel worker error: {e}"
|
||||
)
|
||||
continue
|
||||
results[wid] = (res, exc)
|
||||
if on_each is not None:
|
||||
try:
|
||||
on_each(wid, url, res, exc)
|
||||
except Exception as cb_err:
|
||||
_logger.error(f"check on_each callback error: {cb_err}")
|
||||
# Drive the elapsed-time counter from the calling thread.
|
||||
_fire_tick()
|
||||
# Any server still pending here hit the global deadline.
|
||||
if pending:
|
||||
elapsed = time.time() - start
|
||||
_logger.warning(
|
||||
f"check global deadline ({deadline}s) reached after "
|
||||
f"{elapsed:.1f}s; {len(pending)} server(s) "
|
||||
f"did not answer in time"
|
||||
)
|
||||
for fut in pending:
|
||||
wid, url = fut_to_target[fut]
|
||||
if wid in results:
|
||||
continue
|
||||
if on_timeout is not None:
|
||||
try:
|
||||
on_timeout(wid, url)
|
||||
except Exception as cb_err:
|
||||
_logger.error(
|
||||
f"check on_timeout callback error: {cb_err}"
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
except TypeError:
|
||||
pool.shutdown(wait=False)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def initialize_willexecutor(willexecutor, url, status=None, old_willexecutor=None):
|
||||
old_willexecutor=old_willexecutor if old_willexecutor is not None else {}
|
||||
@@ -326,11 +721,14 @@ class Willexecutors:
|
||||
return {}
|
||||
|
||||
@staticmethod
|
||||
def check_transaction(txid, url):
|
||||
def check_transaction(txid, url, *, timeout=CHECK_TIMEOUT,
|
||||
max_retries=CHECK_MAX_RETRIES,
|
||||
retry_sleep=CHECK_RETRY_SLEEP):
|
||||
_logger.debug(f"{url}:{txid}")
|
||||
try:
|
||||
w = Willexecutors.send_request(
|
||||
"post", url + "/searchtx", data=txid.encode("ascii")
|
||||
"post", url + "/searchtx", data=txid.encode("ascii"),
|
||||
timeout=timeout, max_retries=max_retries, retry_sleep=retry_sleep,
|
||||
)
|
||||
return w
|
||||
except Exception as e:
|
||||
|
||||
@@ -53,10 +53,10 @@ from PyQt6.QtCore import (QDateTime, QModelIndex, QPersistentModelIndex, Qt,
|
||||
QTimer, pyqtSignal)
|
||||
from PyQt6.QtGui import (QColor, QPainter, QPalette, QStandardItem,
|
||||
QStandardItemModel)
|
||||
from PyQt6.QtWidgets import (QAbstractItemView, QCheckBox, QComboBox,
|
||||
QDateTimeEdit, QGridLayout, QHBoxLayout, QLabel,
|
||||
QLineEdit, QTextEdit, QMenu, QMenuBar, QPushButton,
|
||||
QScrollArea, QSizePolicy, QSpinBox,
|
||||
from PyQt6.QtWidgets import (QAbstractItemView, QAbstractSpinBox, QCheckBox,
|
||||
QComboBox, QDateTimeEdit, QGridLayout, QHBoxLayout,
|
||||
QLabel, QLineEdit, QTextEdit, QMenu, QMenuBar,
|
||||
QPushButton, QScrollArea, QSizePolicy, QSpinBox,
|
||||
QStackedWidget, QStyle, QStyleOptionFrame,
|
||||
QVBoxLayout, QWidget, QDialog)
|
||||
|
||||
@@ -69,11 +69,11 @@ from ...core.will import (AmountException, HeirChangeException,
|
||||
NotCompleteWillException, NoWillExecutorNotPresent,
|
||||
TxFeesChangedException, Will,
|
||||
WillexecutorChangeException, WillExecutorNotPresent,
|
||||
WillExpiredException, WillItem)
|
||||
WillExpiredException, WillItem, WillPostponedException)
|
||||
from ...core.willexecutors import Willexecutors
|
||||
|
||||
# --- Presentation helpers ---
|
||||
from .theme import status_color
|
||||
from .theme import server_status_text, server_status_tooltip, status_color
|
||||
from .window_utils import (bring_to_front, show_modal, show_on_top,
|
||||
stop_thread, top_level_of)
|
||||
|
||||
@@ -116,18 +116,34 @@ class CheckAliveError(Exception):
|
||||
|
||||
|
||||
def log_error(exec_info, window=None):
|
||||
_logger.error(f"LOG_ERROR: {exec_info}")
|
||||
#tb = traceback.format_exc()
|
||||
try:
|
||||
tb=exec_info[1]
|
||||
_logger.error(tb)
|
||||
except Exception:
|
||||
tb = traceback.format_exc()
|
||||
_logger.error(tb)
|
||||
"""Log an error and optionally show it.
|
||||
|
||||
``exec_info`` may be either a ``sys.exc_info()`` triple
|
||||
``(type, value, traceback)`` or a single exception instance (callers use
|
||||
both forms), so we handle both and always try to log a full traceback.
|
||||
"""
|
||||
_logger.error(f"LOG_ERROR: {exec_info}")
|
||||
exc = None
|
||||
if isinstance(exec_info, BaseException):
|
||||
exc = exec_info
|
||||
elif isinstance(exec_info, (tuple, list)) and len(exec_info) >= 2:
|
||||
# sys.exc_info() form: the exception instance is the 2nd element.
|
||||
exc = exec_info[1]
|
||||
try:
|
||||
if exc is not None:
|
||||
_logger.error(
|
||||
"".join(
|
||||
traceback.format_exception(type(exc), exc, exc.__traceback__)
|
||||
)
|
||||
)
|
||||
else:
|
||||
_logger.error(traceback.format_exc())
|
||||
except Exception:
|
||||
_logger.error(traceback.format_exc())
|
||||
|
||||
if window is not None:
|
||||
window.show_error(exec_info)
|
||||
# show_error expects a human-readable message, not a triple.
|
||||
window.show_error(str(exc) if exc is not None else str(exec_info))
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -381,7 +381,10 @@ class BalWizardLocktimeAndFeeWidget(BalWizardWidget):
|
||||
widget = QWidget()
|
||||
layout = QVBoxLayout(widget)
|
||||
|
||||
layout.addWidget(WillSettingsWidget(self.bal_window, self, "v"))
|
||||
# The wizard ("Build your will") is the ONLY place the delivery time,
|
||||
# check alive and fee can be edited, so it is the only read_only=False.
|
||||
layout.addWidget(WillSettingsWidget(self.bal_window, self, "v",
|
||||
read_only=False))
|
||||
spacer_widget = QWidget()
|
||||
spacer_widget.setSizePolicy(
|
||||
QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding
|
||||
@@ -592,6 +595,20 @@ class BalBuildWillDialog(BalDialog):
|
||||
return None, Will.invalidate_will(
|
||||
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
|
||||
)
|
||||
except WillPostponedException as e:
|
||||
# An already signed/sent will is being postponed. Like an expired
|
||||
# will, the previously committed coins must be invalidated on-chain
|
||||
# FIRST (otherwise a will-executor could broadcast the old,
|
||||
# earlier-locktime tx and execute the inheritance too early). We
|
||||
# return (None, tx) so phase 2 asks the user to sign and broadcast
|
||||
# the invalidation; afterwards the user presses Prepare again to
|
||||
# rebuild the new (postponed) inheritance.
|
||||
_logger.debug(f"postponed {e}")
|
||||
self.msg_set_checking(_("Postponed: invalidating old will"))
|
||||
fee_per_byte = self.bal_window.will_settings.get("baltx_fees", 1)
|
||||
return None, Will.invalidate_will(
|
||||
self.bal_window.willitems, self.bal_window.wallet, fee_per_byte
|
||||
)
|
||||
except NoHeirsException as e:
|
||||
_logger.debug("no heirs")
|
||||
self.msg_set_checking("No Heirs")
|
||||
@@ -709,54 +726,120 @@ class BalBuildWillDialog(BalDialog):
|
||||
willexecutors = Willexecutors.get_willexecutor_transactions(
|
||||
self.bal_window.willitems
|
||||
)
|
||||
for url, willexecutor in willexecutors.items():
|
||||
|
||||
# Only push to the will-executors the user actually selected. We
|
||||
# filter the mapping up-front so push_transactions_parallel only
|
||||
# talks to the relevant servers.
|
||||
selected = {
|
||||
url: we
|
||||
for url, we in willexecutors.items()
|
||||
if Willexecutors.is_selected(self.bal_window.willexecutors.get(url))
|
||||
}
|
||||
|
||||
# Servers that report "already present" need their stored tx
|
||||
# verified afterwards (network I/O); collect them here and process
|
||||
# them sequentially after the parallel push, keeping the original
|
||||
# check logic untouched.
|
||||
already_present = []
|
||||
retry_flag = {"value": False}
|
||||
total = len(selected)
|
||||
done = {"count": 0}
|
||||
|
||||
deadline = Willexecutors.PUSH_GLOBAL_DEADLINE
|
||||
|
||||
def _status_line():
|
||||
# e.g. "Broadcasting your will to executors: 2/3 (5s / 30s)".
|
||||
# The "/ 30s" makes the maximum wait explicit, so the user knows
|
||||
# the wizard will proceed by then (the global deadline) instead
|
||||
# of wondering how long the counter will keep climbing.
|
||||
return "{} {}/{} ({}s / {}s)".format(
|
||||
_("Broadcasting"), done["count"], total,
|
||||
min(int(time.time() - push_start), deadline), deadline,
|
||||
)
|
||||
|
||||
def on_each(url, willexecutor, ok, exc):
|
||||
# Runs from a worker thread. Do only thread-safe book-keeping
|
||||
# plus a signal-based UI update (msg_edit_row emits a pyqtSignal,
|
||||
# which is marshalled to the GUI thread).
|
||||
if isinstance(exc, Willexecutors.AlreadyPresentException):
|
||||
already_present.append(url)
|
||||
elif ok:
|
||||
for wid in willexecutor["txsids"]:
|
||||
self.bal_window.willitems[wid].set_status("PUSHED", True)
|
||||
else:
|
||||
for wid in willexecutor["txsids"]:
|
||||
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
|
||||
retry_flag["value"] = True
|
||||
done["count"] += 1
|
||||
self.msg_edit_row("{} : {}".format(url, "Ok" if ok else "Ko"))
|
||||
self.msg_set_pushing(_status_line())
|
||||
|
||||
def on_timeout(url, willexecutor):
|
||||
# The global deadline elapsed before this server answered. Mark
|
||||
# its txs as failed (so the user can retry later) and show it.
|
||||
for wid in willexecutor.get("txsids", []):
|
||||
self.bal_window.willitems[wid].set_status("PUSH_FAIL", True)
|
||||
retry_flag["value"] = True
|
||||
self.msg_edit_row(
|
||||
"{} : {}".format(url, self.msg_error(_("Timeout - no answer")))
|
||||
)
|
||||
|
||||
if self._stopping:
|
||||
return
|
||||
# Push to all selected will-executors in parallel: a slow/dead
|
||||
# server no longer blocks the others, so the wizard's "Broadcasting"
|
||||
# step is no longer sequential. Each server keeps a short retry
|
||||
# behaviour, and a global deadline guarantees the wizard always
|
||||
# proceeds even if a server never answers.
|
||||
push_start = time.time()
|
||||
self.msg_set_pushing(_status_line())
|
||||
|
||||
# Refresh the elapsed-seconds counter while the (blocking) parallel
|
||||
# push runs, so the user sees time advancing instead of a frozen
|
||||
# "Trasmissione". The tick is driven from THIS (Task) thread by
|
||||
# push_transactions_parallel, the same thread that drives on_each, so
|
||||
# the pyqtSignal repaint is reliable (a separate heartbeat thread's
|
||||
# signal emissions were not being marshalled and never repainted).
|
||||
def on_tick():
|
||||
if self._stopping:
|
||||
return
|
||||
try:
|
||||
if Willexecutors.is_selected(
|
||||
self.bal_window.willexecutors.get(url)
|
||||
):
|
||||
_logger.debug(f"{url}: {willexecutor}")
|
||||
if not Willexecutors.push_transactions_to_willexecutor(
|
||||
willexecutor
|
||||
):
|
||||
for wid in willexecutor["txsids"]:
|
||||
self.bal_window.willitems[wid].set_status(
|
||||
"PUSH_FAIL", True
|
||||
)
|
||||
retry = True
|
||||
else:
|
||||
for wid in willexecutor["txsids"]:
|
||||
self.bal_window.willitems[wid].set_status(
|
||||
"PUSHED", True
|
||||
)
|
||||
except Willexecutors.AlreadyPresentException:
|
||||
for wid in willexecutor["txsids"]:
|
||||
if self._stopping:
|
||||
return
|
||||
row = self.msg_edit_row(
|
||||
"checking {} - {} : {}".format(
|
||||
self.bal_window.willitems[wid].we["url"], wid, "Waiting"
|
||||
)
|
||||
)
|
||||
self.bal_plugin = self.bal_window.bal_plugin
|
||||
w = self.bal_window.willitems[wid]
|
||||
self.msg_set_pushing(_status_line())
|
||||
|
||||
w.set_check_willexecutor(
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
row = self.msg_edit_row(
|
||||
"checked {} - {} : {}".format(
|
||||
self.bal_window.willitems[wid].we["url"],
|
||||
wid,
|
||||
self.bal_window.willitems[wid].get_status("CHECKED"),
|
||||
),
|
||||
row,
|
||||
)
|
||||
Willexecutors.push_transactions_parallel(
|
||||
selected, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick
|
||||
)
|
||||
|
||||
# Final summary line with the total elapsed time.
|
||||
self.msg_set_pushing(
|
||||
"{}/{} ({}s)".format(done["count"], total,
|
||||
int(time.time() - push_start))
|
||||
)
|
||||
retry = retry_flag["value"]
|
||||
|
||||
# Verify the "already present" servers (sequential, original logic).
|
||||
self.bal_plugin = self.bal_window.bal_plugin
|
||||
for url in already_present:
|
||||
for wid in willexecutors[url]["txsids"]:
|
||||
if self._stopping:
|
||||
return
|
||||
row = self.msg_edit_row(
|
||||
"checking {} - {} : {}".format(
|
||||
self.bal_window.willitems[wid].we["url"], wid, "Waiting"
|
||||
)
|
||||
)
|
||||
w = self.bal_window.willitems[wid]
|
||||
w.set_check_willexecutor(
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
row = self.msg_edit_row(
|
||||
"checked {} - {} : {}".format(
|
||||
self.bal_window.willitems[wid].we["url"],
|
||||
wid,
|
||||
self.bal_window.willitems[wid].get_status("CHECKED"),
|
||||
),
|
||||
row,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"loop push error:{e}")
|
||||
raise e
|
||||
if retry:
|
||||
raise Exception("retry")
|
||||
|
||||
|
||||
@@ -228,12 +228,14 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
TXID = enum.auto()
|
||||
WILLEXECUTOR = enum.auto()
|
||||
STATUS = enum.auto()
|
||||
SERVER = enum.auto()
|
||||
|
||||
headers = {
|
||||
Columns.LOCKTIME: _("Locktime"),
|
||||
Columns.TXID: _("Txid"),
|
||||
Columns.WILLEXECUTOR: _("Will-Executor"),
|
||||
Columns.STATUS: _("Status"),
|
||||
Columns.SERVER: _("Server"),
|
||||
}
|
||||
|
||||
ROLE_HEIR_KEY = Qt.ItemDataRole.UserRole + 2000
|
||||
@@ -385,6 +387,9 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
if len(bal_tx.status) > 53:
|
||||
status = "...{}".format(status[-50:])
|
||||
labels[self.Columns.STATUS] = status
|
||||
# Dedicated, always-readable label describing whether the inheritance
|
||||
# transaction is actually stored on the will-executor servers.
|
||||
labels[self.Columns.SERVER] = server_status_text(bal_tx)
|
||||
|
||||
items = []
|
||||
for e in labels:
|
||||
@@ -398,6 +403,13 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
|
||||
items[-1].setBackground(QColor(status_color(bal_tx)))
|
||||
|
||||
# Tooltip on the Server column: shows the will-executor URL (if any)
|
||||
# plus the current server state, so the user can always inspect details.
|
||||
try:
|
||||
items[self.Columns.SERVER].setToolTip(server_status_tooltip(bal_tx))
|
||||
except Exception as tip_err:
|
||||
_logger.debug(f"server tooltip error: {tip_err}")
|
||||
|
||||
row_count = self.model().rowCount()
|
||||
self.model().insertRow(row_count, items)
|
||||
if txid == current_key:
|
||||
@@ -451,6 +463,8 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
self.bal_window.bal_plugin.read_file("icons/wizard.png")
|
||||
)
|
||||
)
|
||||
# Tooltip so the icon is self-explanatory when hovered.
|
||||
wizard.setToolTip(_("Wizard - Build your will"))
|
||||
wizard.clicked.connect(self.bal_window.init_wizard)
|
||||
# display = QPushButton(_("Display"))
|
||||
# display.clicked.connect(self.bal_window.preview_modal_dialog)
|
||||
@@ -461,13 +475,20 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
||||
self.bal_window.bal_plugin.read_file("icons/reload.png")
|
||||
)
|
||||
)
|
||||
# Tooltip so the icon is self-explanatory when hovered.
|
||||
refresh.setToolTip(_("Check"))
|
||||
refresh.clicked.connect(self.check)
|
||||
|
||||
widget = QWidget(self)
|
||||
hlayout = QHBoxLayout(widget)
|
||||
hlayout.setContentsMargins(0, 0, 0, 0)
|
||||
self.will_settings_widget = WillSettingsWidget(self.bal_window, self)
|
||||
hlayout.addWidget(self.will_settings_widget)
|
||||
# Toolbar order (left -> right):
|
||||
# Wizard | Delivery time | Check Alive | Calendar | Check (refresh)
|
||||
# The Wizard button goes first (leftmost); the settings widget already
|
||||
# lays out delivery/check-alive/calendar in that order internally.
|
||||
hlayout.addWidget(wizard)
|
||||
hlayout.addWidget(self.will_settings_widget)
|
||||
hlayout.addWidget(refresh)
|
||||
toolbar.insertWidget(2, widget)
|
||||
|
||||
|
||||
@@ -14,8 +14,11 @@ One :class:`bal.gui.qt.window.BalWindow` is created per top-level wallet window
|
||||
and cached in ``self.bal_windows``.
|
||||
"""
|
||||
|
||||
from electrum.gui.qt.main_window import StatusBarButton
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .common import read_QIcon_from_bytes
|
||||
from .widgets import BalCheckBox, BalLineEdit, BalTextEdit
|
||||
from .window import BalWindow
|
||||
from .dialogs import BalDialog
|
||||
@@ -38,6 +41,10 @@ class Plugin(BalPlugin):
|
||||
_logger.info("INIT BALPLUGIN")
|
||||
BalPlugin.__init__(self, parent, config, name)
|
||||
self.bal_windows = {}
|
||||
# Status-bar buttons, keyed by id(sb.window()). Tracking them lets us
|
||||
# remove a stale button before creating a fresh one when a wallet is
|
||||
# switched / Electrum is restarted, so the icon is never duplicated.
|
||||
self._statusbar_buttons = {}
|
||||
|
||||
@hook
|
||||
def init_qt(self, gui_object):
|
||||
@@ -54,6 +61,154 @@ class Plugin(BalPlugin):
|
||||
_logger.error("Error loading plugin {}".format(e))
|
||||
raise e
|
||||
|
||||
@staticmethod
|
||||
def _close_plugins_manager_dialog():
|
||||
"""Close Electrum's "Electrum Plugins" manager dialog if it is open.
|
||||
|
||||
This is the native Electrum ``PluginsDialog`` (a ``WindowModalDialog``);
|
||||
it is not owned by this plugin, so we locate it among the application's
|
||||
top-level widgets and close it. Failures are non-fatal: leaving the
|
||||
dialog open is harmless, so we never propagate exceptions from here.
|
||||
"""
|
||||
Plugin._handle_plugins_manager_dialog(attempt=0)
|
||||
|
||||
@staticmethod
|
||||
def _find_plugins_manager_dialogs():
|
||||
"""Return the open Electrum "Electrum Plugins" manager dialog(s).
|
||||
|
||||
The match is intentionally permissive: when our plugin is loaded from a
|
||||
zip (``electrum_external_plugins``), ``isinstance`` against the imported
|
||||
``PluginsDialog`` class can fail due to differing module identities, so
|
||||
we also match by class name and by window title (including the localized
|
||||
title, since the user runs Electrum under a non-English locale).
|
||||
"""
|
||||
try:
|
||||
from PyQt6.QtWidgets import QApplication
|
||||
except Exception:
|
||||
return []
|
||||
try:
|
||||
from electrum.gui.qt.plugins_dialog import PluginsDialog
|
||||
except Exception:
|
||||
PluginsDialog = None
|
||||
app = QApplication.instance()
|
||||
if app is None:
|
||||
return []
|
||||
# Accept both the English title and the translated one. We cannot rely
|
||||
# only on _() because the dialog object may have been built with a
|
||||
# different gettext binding than ours when loaded from a zip.
|
||||
titles = {"Electrum Plugins"}
|
||||
try:
|
||||
titles.add(_("Electrum Plugins"))
|
||||
except Exception:
|
||||
pass
|
||||
found = []
|
||||
for w in app.topLevelWidgets():
|
||||
try:
|
||||
is_match = False
|
||||
if PluginsDialog is not None and isinstance(w, PluginsDialog):
|
||||
is_match = True
|
||||
elif type(w).__name__ == "PluginsDialog":
|
||||
is_match = True
|
||||
elif w.windowTitle() in titles:
|
||||
is_match = True
|
||||
if not is_match:
|
||||
continue
|
||||
# Only count it as "open" if it is actually visible: after a
|
||||
# successful close()/reject() the QDialog object still lives in
|
||||
# topLevelWidgets() but becomes invisible, so filtering by
|
||||
# isVisible() is what tells "still open" from "already closed".
|
||||
visible = w.isVisible()
|
||||
_logger.info(
|
||||
"plugins manager dialog match: cls={} title={!r} "
|
||||
"visible={}".format(
|
||||
type(w).__name__, w.windowTitle(), visible
|
||||
)
|
||||
)
|
||||
if visible:
|
||||
found.append(w)
|
||||
except Exception as e:
|
||||
_logger.debug("inspecting top-level widget failed: {}".format(e))
|
||||
return found
|
||||
|
||||
@staticmethod
|
||||
def _try_dismiss_dialog(d):
|
||||
"""Attempt to dismiss a (possibly modal) dialog as robustly as we can.
|
||||
|
||||
A ``PluginsDialog`` is opened with ``exec()`` (a nested, *application-
|
||||
modal* event loop). Inside such a loop a plain ``close()`` is not
|
||||
always honoured, so we also try ``reject()`` / ``done()`` which end the
|
||||
modal loop directly. Any of these may fail depending on Qt state, so
|
||||
each is guarded independently.
|
||||
"""
|
||||
try:
|
||||
from PyQt6.QtWidgets import QDialog
|
||||
except Exception:
|
||||
QDialog = None
|
||||
# 1) reject() / done(): the reliable way to end an exec() modal loop.
|
||||
if QDialog is not None and isinstance(d, QDialog):
|
||||
try:
|
||||
d.reject()
|
||||
except Exception as e:
|
||||
_logger.debug("reject() failed: {}".format(e))
|
||||
try:
|
||||
d.done(QDialog.DialogCode.Rejected)
|
||||
except Exception as e:
|
||||
_logger.debug("done() failed: {}".format(e))
|
||||
# 2) close(): covers non-QDialog top-levels and is a harmless extra.
|
||||
try:
|
||||
d.close()
|
||||
except Exception as e:
|
||||
_logger.debug("could not close plugins dialog: {}".format(e))
|
||||
|
||||
@staticmethod
|
||||
def _handle_plugins_manager_dialog(attempt=0):
|
||||
"""Try to auto-close the manager dialog; retry a few times.
|
||||
|
||||
Enabling the plugin happens while Electrum's ``PluginsDialog`` may still
|
||||
be running its own modal event loop, so a single ``close()`` can be
|
||||
ignored. We retry on a short schedule and, if it is still open after the
|
||||
last attempt, fall back to bringing it to the front so the user notices
|
||||
it and closes it themselves (it must not linger in the background).
|
||||
"""
|
||||
try:
|
||||
from PyQt6.QtCore import QTimer
|
||||
except Exception:
|
||||
QTimer = None
|
||||
# Schedule of retry delays (ms) measured from each call.
|
||||
retry_delays = [400, 800, 1500]
|
||||
dialogs = Plugin._find_plugins_manager_dialogs()
|
||||
_logger.info(
|
||||
"auto-close plugins dialog: attempt={} found={}".format(
|
||||
attempt, len(dialogs)
|
||||
)
|
||||
)
|
||||
for d in dialogs:
|
||||
Plugin._try_dismiss_dialog(d)
|
||||
# Re-check: anything still visible?
|
||||
still_open = Plugin._find_plugins_manager_dialogs()
|
||||
if not still_open:
|
||||
_logger.info("plugins dialog closed successfully")
|
||||
return
|
||||
if attempt < len(retry_delays) and QTimer is not None:
|
||||
QTimer.singleShot(
|
||||
retry_delays[attempt],
|
||||
lambda: Plugin._handle_plugins_manager_dialog(attempt + 1),
|
||||
)
|
||||
return
|
||||
# Final fallback: we could not close it -> at least raise it to the
|
||||
# front so it does not stay hidden in the background.
|
||||
_logger.info(
|
||||
"could not close plugins dialog after {} attempts; "
|
||||
"bringing it to front".format(attempt + 1)
|
||||
)
|
||||
for d in still_open:
|
||||
try:
|
||||
d.showNormal()
|
||||
d.raise_()
|
||||
d.activateWindow()
|
||||
except Exception as e:
|
||||
_logger.debug("could not raise plugins dialog: {}".format(e))
|
||||
|
||||
def _setup_window(self, window, *, load_open_wallet):
|
||||
"""Create the BalWindow for *window* and wire its menu (and, when
|
||||
enabling hot, the already-open wallet).
|
||||
@@ -88,21 +243,57 @@ class Plugin(BalPlugin):
|
||||
|
||||
@hook
|
||||
def create_status_bar(self, sb):
|
||||
# NOTE: intentionally a no-op, matching the original plugin. The
|
||||
# original code had an early ``return`` before building the
|
||||
# StatusBarButton, i.e. the button was deliberately disabled. Adding
|
||||
# the button here caused a stray, condensed icon+text element to be
|
||||
# rendered in the wrong place (near the top, under the Electrum logo)
|
||||
# after a restart / wallet switch. Settings are already reachable via
|
||||
# Tools -> Plugins, so we keep the original behaviour.
|
||||
_logger.info("HOOK create status bar (no-op)")
|
||||
return
|
||||
# Show the BAL icon in the status bar (bottom-right): it signals that
|
||||
# the Bitcoin After Life plugin is installed and, when clicked, quickly
|
||||
# opens the plugin settings (settings_dialog).
|
||||
#
|
||||
# NOTE: this was NOT the "condensed menu/tabs" bug under the Electrum
|
||||
# logo -- that one was a Windows OverflowError (year 2038), fixed
|
||||
# separately. The icon must therefore be kept.
|
||||
#
|
||||
# To avoid a duplicated icon on restart / wallet switch, we track the
|
||||
# button by id(sb.window()) and remove the stale one before creating a
|
||||
# fresh one.
|
||||
_logger.info("HOOK create status bar")
|
||||
key = id(sb.window())
|
||||
old = self._statusbar_buttons.pop(key, None)
|
||||
if old is not None:
|
||||
try:
|
||||
old.setParent(None)
|
||||
old.deleteLater()
|
||||
except Exception:
|
||||
pass
|
||||
b = StatusBarButton(
|
||||
read_QIcon_from_bytes(self.read_file("icons/bal32x32.png")),
|
||||
"Bal " + _("Bitcoin After Life"),
|
||||
lambda: self.settings_dialog(sb.window()),
|
||||
sb.height(),
|
||||
)
|
||||
sb.addPermanentWidget(b)
|
||||
self._statusbar_buttons[key] = b
|
||||
|
||||
# When the plugin is enabled "hot" from Tools -> Plugins, Electrum keeps
|
||||
# its "Electrum Plugins" manager dialog open and even calls
|
||||
# bring_to_front on it. Enabling triggers reload_windows(), which
|
||||
# recreates the window and therefore fires this create_status_bar hook;
|
||||
# that makes this the right place to auto-close the leftover manager
|
||||
# dialog (Electrum 4.7.x no longer calls the old init_qt hook).
|
||||
#
|
||||
# We use a QTimer so this runs *after* Electrum's own bring_to_front
|
||||
# (QTimer.singleShot(100, ...)); a slightly larger delay makes our close
|
||||
# win. On a normal startup no PluginsDialog is open, so the helper is a
|
||||
# harmless no-op.
|
||||
QTimer.singleShot(250, self._close_plugins_manager_dialog)
|
||||
|
||||
@hook
|
||||
def init_menubar(self, window):
|
||||
_logger.info("HOOK init_menubar")
|
||||
w = self.get_window(window)
|
||||
w.init_menubar_tools(window.tools_menu)
|
||||
# Also try here: init_menubar is one of the hooks fired when Electrum
|
||||
# recreates the window during a hot enable (reload_windows()), so it is
|
||||
# another reliable trigger to auto-close the leftover manager dialog.
|
||||
QTimer.singleShot(300, self._close_plugins_manager_dialog)
|
||||
|
||||
@hook
|
||||
def load_wallet(self, wallet, main_window):
|
||||
@@ -116,6 +307,9 @@ class Plugin(BalPlugin):
|
||||
)
|
||||
w.disable_plugin = False
|
||||
w.ok = True
|
||||
# load_wallet is fired on the recreated window during a hot enable too;
|
||||
# use it as an extra trigger to auto-close the leftover manager dialog.
|
||||
QTimer.singleShot(350, self._close_plugins_manager_dialog)
|
||||
|
||||
@hook
|
||||
def close_wallet(self, wallet):
|
||||
|
||||
@@ -57,3 +57,41 @@ def status_color(will_item) -> str:
|
||||
return "#2bc8ed" # blue - signed
|
||||
else:
|
||||
return _DEFAULT_COLOR
|
||||
|
||||
|
||||
def server_status_text(will_item) -> str:
|
||||
"""Return a short, human-readable label describing the state of a will
|
||||
item on the will-executor servers (the online inheritance backup).
|
||||
|
||||
This is shown in the dedicated "Server" column of the transaction list so
|
||||
the user always knows whether each inheritance transaction is actually
|
||||
stored on the will-executor servers, regardless of the row colour.
|
||||
"""
|
||||
from electrum.i18n import _
|
||||
|
||||
if will_item.get_status("CHECK_FAIL") and not will_item.get_status("CHECKED"):
|
||||
return _("Not on server")
|
||||
if will_item.get_status("CHECKED"):
|
||||
return _("Confirmed on server")
|
||||
if will_item.get_status("PUSH_FAIL"):
|
||||
return _("Send failed")
|
||||
if will_item.get_status("PUSHED"):
|
||||
return _("Sent (not checked)")
|
||||
if will_item.get_status("COMPLETE"):
|
||||
return _("Signed (not sent)")
|
||||
return _("Not sent")
|
||||
|
||||
|
||||
def server_status_tooltip(will_item) -> str:
|
||||
"""Return a detailed tooltip for the "Server" column, including the
|
||||
will-executor URL (if any) and the current server state."""
|
||||
from electrum.i18n import _
|
||||
|
||||
url = None
|
||||
we = getattr(will_item, "we", None)
|
||||
if we:
|
||||
url = we.get("url")
|
||||
state = server_status_text(will_item)
|
||||
if url:
|
||||
return "{}: {}\n{}".format(_("Will-Executor"), url, state)
|
||||
return "{}\n{}".format(_("No will-executor"), state)
|
||||
|
||||
@@ -64,6 +64,23 @@ class BalTxFeesWidget(QWidget):
|
||||
|
||||
def doubleclick(self, event=None):
|
||||
pass
|
||||
|
||||
def set_read_only(self, read_only=True):
|
||||
# Show the fee but make it non-editable (no spin arrows, no keyboard),
|
||||
# so it can only be changed from the "Build your will" wizard.
|
||||
self.txfee_widget.setReadOnly(read_only)
|
||||
self.txfee_widget.setButtonSymbols(
|
||||
QAbstractSpinBox.ButtonSymbols.NoButtons
|
||||
if read_only
|
||||
else QAbstractSpinBox.ButtonSymbols.UpDownArrows
|
||||
)
|
||||
# Light-grey background when locked, so the read-only state is visible
|
||||
# (same look as the date fields); empty stylesheet restores the
|
||||
# editable appearance used inside the wizard.
|
||||
self.txfee_widget.setStyleSheet(
|
||||
"QSpinBox{background-color:#f0f0f0;}" if read_only else ""
|
||||
)
|
||||
|
||||
def get_value(self):
|
||||
return self.txfee_widget.value()
|
||||
|
||||
@@ -150,6 +167,7 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
+ " - y: number of years after currrent day(ex: 1y means one year from today)\n"
|
||||
)
|
||||
label_text = None
|
||||
tooltip_text = None
|
||||
base_field = None
|
||||
|
||||
def __init__(self, bal_window, parent, default_locktime=None):
|
||||
@@ -182,6 +200,10 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
#hbox.addWidget(QLabel(self.label_text))
|
||||
help_button=HelpButton(self.help_text)
|
||||
help_button.setText(self.label_text)
|
||||
# Show a short label (e.g. "Delivery time" / "Check Alive") when the
|
||||
# user hovers the icon, so the emoji button is self-explanatory.
|
||||
if self.tooltip_text:
|
||||
help_button.setToolTip(_(self.tooltip_text))
|
||||
#help_button.setStyleSheet("font-size: 155555);
|
||||
hbox.addWidget(help_button)
|
||||
self.combo.currentIndexChanged.connect(self.on_current_index_changed)
|
||||
@@ -270,6 +292,17 @@ class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||
self.current_value = x
|
||||
self.bal_window.update_setting_widgets(x, self.base_field)
|
||||
|
||||
def set_read_only(self, read_only=True):
|
||||
"""Show the value but make it non-editable.
|
||||
|
||||
Used everywhere except the "Build your will" wizard, where the date is
|
||||
the only place the user is allowed to change it. The Raw/Date combo is
|
||||
disabled and both editors become read-only with no spin buttons.
|
||||
"""
|
||||
self.combo.setEnabled(not read_only)
|
||||
for w in self.editors:
|
||||
w.set_read_only(read_only)
|
||||
|
||||
|
||||
|
||||
class TimeRawEditWidget(QWidget):
|
||||
@@ -290,6 +323,14 @@ class TimeRawEditWidget(QWidget):
|
||||
self.get_value = self.editor.get_value
|
||||
self.set_value = self.editor.set_value
|
||||
|
||||
def set_read_only(self, read_only=True):
|
||||
self.editor.setReadOnly(read_only)
|
||||
# Match the Date editor: grey background when locked so the read-only
|
||||
# state is visible; empty stylesheet restores the editable look.
|
||||
self.editor.setStyleSheet(
|
||||
"QLineEdit{background-color:#f0f0f0;}" if read_only else ""
|
||||
)
|
||||
|
||||
|
||||
|
||||
class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
||||
@@ -382,6 +423,24 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
||||
#self.setDateTime(QDateTime.currentDateTime())
|
||||
self.time_edit = time_edit
|
||||
|
||||
def set_read_only(self, read_only=True):
|
||||
# Read-only display: keyboard editing disabled and the up/down spin
|
||||
# arrows removed, so the date can only be changed from the wizard.
|
||||
self.setReadOnly(read_only)
|
||||
self.setButtonSymbols(
|
||||
QAbstractSpinBox.ButtonSymbols.NoButtons
|
||||
if read_only
|
||||
else QAbstractSpinBox.ButtonSymbols.UpDownArrows
|
||||
)
|
||||
# A read-only QDateTimeEdit keeps a white background by default, which
|
||||
# does not visually signal that it is locked. Paint it light grey (like
|
||||
# the disabled combo/fee fields next to it) so the user sees at a glance
|
||||
# that the date is not editable here; an empty stylesheet restores the
|
||||
# default look when the field is made editable again (in the wizard).
|
||||
self.setStyleSheet(
|
||||
"QDateTimeEdit{background-color:#f0f0f0;}" if read_only else ""
|
||||
)
|
||||
|
||||
def get_value(self) -> Optional[int]:
|
||||
#dt = self.dateTime().toPyDateTime()
|
||||
#locktime = int(time.mktime(dt.timetuple()))
|
||||
@@ -414,14 +473,19 @@ class LockTimeDateEdit(QDateTimeEdit, _LockTimeEditor):
|
||||
|
||||
|
||||
class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||
help_text = (
|
||||
"Check to ask for invalidation.\n\n"
|
||||
"When less then this time is missing, ask to invalidate.\n"
|
||||
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.\n\n"
|
||||
f"{BalTimeEditWidget.help_text}"
|
||||
"<b>CHECK ALIVE</b><br><br>"
|
||||
"Check to ask for invalidation.<br><br>"
|
||||
"When less then this time is missing, ask to invalidate.<br>"
|
||||
"If you fail to invalidate during this time, your transactions will be delivered to your heirs.<br><br>"
|
||||
"if you choose Raw, you can insert various options based on suffix:<br>"
|
||||
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
||||
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
|
||||
)
|
||||
label_text = "🚨"
|
||||
#label_text = "Check Alive"
|
||||
tooltip_text = "Check Alive"
|
||||
base_field = "threshold"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
@@ -435,13 +499,18 @@ class ThresholdTimeWidget(BalTimeEditWidget):
|
||||
|
||||
|
||||
class LockTimeWidget(BalTimeEditWidget):
|
||||
# rich_text=True is used by the HelpButton, so HTML tags (<b>, <br>) render.
|
||||
help_text = (
|
||||
"Set Locktime for transactions.\n"
|
||||
"Any time is needed transaction will be anticipated by 1day\n"
|
||||
f"{BalTimeEditWidget.help_text}"
|
||||
"<b>DELIVERY TIME</b><br><br>"
|
||||
"Set Locktime for transactions.<br>"
|
||||
"Any time is needed transaction will be anticipated by 1day<br><br>"
|
||||
"if you choose Raw, you can insert various options based on suffix:<br>"
|
||||
" - d: number of days after current day(ex: 1d means tomorrow)<br>"
|
||||
" - y: number of years after currrent day(ex: 1y means one year from today)<br>"
|
||||
)
|
||||
label_text = "🚛"
|
||||
#label_text = "Locktime"
|
||||
tooltip_text = "Delivery time"
|
||||
base_field = "locktime"
|
||||
|
||||
def __init__(self, bal_window, parent, init_value=None):
|
||||
@@ -456,10 +525,15 @@ class LockTimeWidget(BalTimeEditWidget):
|
||||
|
||||
class WillSettingsWidget(QWidget):
|
||||
|
||||
def __init__(self, bal_window: "BalWindow", parent, layout_type="h"):
|
||||
def __init__(self, bal_window: "BalWindow", parent, layout_type="h",
|
||||
read_only=True):
|
||||
self.widgets = {}
|
||||
QWidget.__init__(self, parent)
|
||||
self.bal_window = bal_window
|
||||
# When read_only=True (toolbars, Heirs tab) the delivery time, check
|
||||
# alive and fee fields are display-only; they can only be edited from
|
||||
# the "Build your will" wizard, which passes read_only=False.
|
||||
self.read_only = read_only
|
||||
box = QHBoxLayout(self) if layout_type == "h" else QVBoxLayout(self)
|
||||
|
||||
self.calendar_button = QPushButton()
|
||||
@@ -468,6 +542,8 @@ class WillSettingsWidget(QWidget):
|
||||
self.bal_window.bal_plugin.read_file("icons/calendar.png")
|
||||
)
|
||||
)
|
||||
# Tooltip so the icon is self-explanatory when hovered.
|
||||
self.calendar_button.setToolTip(_("Calendar"))
|
||||
self.calendar_button.clicked.connect(self.open_or_save_calendar)
|
||||
self.widgets["locktime"] = LockTimeWidget(bal_window, self)
|
||||
self.widgets["threshold"] = ThresholdTimeWidget(bal_window, self)
|
||||
@@ -487,6 +563,11 @@ class WillSettingsWidget(QWidget):
|
||||
box.addWidget(self.calendar_button)
|
||||
box.addWidget(self.widgets["baltx_fees"])
|
||||
|
||||
if self.read_only:
|
||||
self.widgets["locktime"].set_read_only(True)
|
||||
self.widgets["threshold"].set_read_only(True)
|
||||
self.widgets["baltx_fees"].set_read_only(True)
|
||||
|
||||
def create_alarms(self, alarm_start, alarm_end):
|
||||
days = (alarm_end - alarm_start).days+1
|
||||
lines = []
|
||||
|
||||
@@ -14,6 +14,8 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
|
||||
it with the GUI.
|
||||
"""
|
||||
|
||||
import threading
|
||||
|
||||
from .common import *
|
||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||
from .widgets import LockTimeWidget, PercAmountEdit, WillSettingsWidget
|
||||
@@ -430,11 +432,26 @@ class BalWindow:
|
||||
self.bal_plugin.WILL_SETTINGS.set(self.will_settings)
|
||||
|
||||
def init_heirs_to_locktime(self, multiverse=False):
|
||||
#pass
|
||||
for heir in self.heirs:
|
||||
h = self.heirs[heir]
|
||||
if not multiverse:
|
||||
self.heirs[heir] = [h[0], h[1], self.will_settings["locktime"]]
|
||||
if multiverse:
|
||||
return
|
||||
# Coerce the locktime to a plain serializable scalar: will_settings is
|
||||
# read from Electrum's config and a non-primitive value here would end
|
||||
# up inside the heirs dict and break json_db persistence (this was one
|
||||
# path to the "cannot pickle '_thread.RLock' object" error).
|
||||
locktime = self.will_settings["locktime"]
|
||||
if not isinstance(locktime, (int, float, str)):
|
||||
locktime = str(locktime)
|
||||
# Iterate over a snapshot of the keys: assigning to self.heirs[...]
|
||||
# triggers Heirs.__setitem__ -> save(), which mutates the mapping while
|
||||
# we iterate it. Building the new values first and applying them after
|
||||
# the loop avoids "dict changed size during iteration" and the repeated
|
||||
# save() on every heir.
|
||||
updates = {
|
||||
heir: [self.heirs[heir][0], self.heirs[heir][1], locktime]
|
||||
for heir in list(self.heirs)
|
||||
}
|
||||
for heir, value in updates.items():
|
||||
self.heirs[heir] = value
|
||||
|
||||
def init_class_variables(self):
|
||||
if not self.heirs:
|
||||
@@ -512,6 +529,29 @@ class BalWindow:
|
||||
return
|
||||
except NoHeirsException:
|
||||
return
|
||||
except WillPostponedException as e:
|
||||
# The will was already signed/sent and is being postponed.
|
||||
# We do NOT rebuild automatically: the user must first sign and
|
||||
# broadcast the invalidation tx (so the old, earlier-locktime tx
|
||||
# can never be used by a will-executor), then press "Prepare"
|
||||
# again
|
||||
# to create the new postponed inheritance.
|
||||
_logger.info(f"will postponed: {e}")
|
||||
self.show_message(
|
||||
_(
|
||||
"This inheritance was already signed/sent to "
|
||||
"will-executors and you are postponing it.\n\n"
|
||||
"The previously committed coins must be invalidated "
|
||||
"on-chain FIRST, otherwise a will-executor could "
|
||||
"broadcast the old (earlier) transaction and execute "
|
||||
"the inheritance too early.\n\n"
|
||||
"Please sign and broadcast the invalidation transaction "
|
||||
"now, then press 'Prepare' again to create the new "
|
||||
"(postponed) inheritance."
|
||||
)
|
||||
)
|
||||
self.invalidate_will()
|
||||
return
|
||||
except NotCompleteWillException as e:
|
||||
_logger.info("{}:{}".format(type(e), e))
|
||||
message = False
|
||||
@@ -797,48 +837,72 @@ class BalWindow:
|
||||
msg += f"{url}:\t{willexecutors[url]['broadcast_status']}\n"
|
||||
return msg
|
||||
|
||||
error = False
|
||||
# Initialise statuses + show the list immediately.
|
||||
for url in willexecutors:
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
willexecutor = willexecutors[url]
|
||||
willexecutors[url].setdefault("broadcast_status", _("waiting..."))
|
||||
try:
|
||||
self.waiting_dialog.update(getMsg(willexecutors))
|
||||
if "txs" in willexecutor:
|
||||
try:
|
||||
if Willexecutors.push_transactions_to_willexecutor(
|
||||
willexecutors[url]
|
||||
):
|
||||
for wid in willexecutors[url]["txsids"]:
|
||||
self.willitems[wid].set_status("PUSHED", True)
|
||||
willexecutors[url]["broadcast_status"] = _("Success")
|
||||
else:
|
||||
for wid in willexecutors[url]["txsids"]:
|
||||
self.willitems[wid].set_status("PUSH_FAIL", True)
|
||||
error = True
|
||||
willexecutors[url]["broadcast_status"] = _("Failed")
|
||||
del willexecutor["txs"]
|
||||
except Willexecutors.AlreadyPresentException:
|
||||
for wid in willexecutor["txsids"]:
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
self.waiting_dialog.update(
|
||||
"checking {} - {} : {}".format(
|
||||
self.willitems[wid].we["url"], wid, "Waiting"
|
||||
)
|
||||
)
|
||||
w = self.willitems[wid]
|
||||
w.set_check_willexecutor(
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
self.waiting_dialog.update(
|
||||
"checked {} - {} : {}".format(
|
||||
self.willitems[wid].we["url"],
|
||||
wid,
|
||||
self.willitems[wid].get_status("CHECKED"),
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if error:
|
||||
error = {"flag": False}
|
||||
already_present = []
|
||||
|
||||
def on_each(url, willexecutor, ok, exc):
|
||||
# Runs from a worker thread. We only do book-keeping + a thread-safe
|
||||
# signal-based UI update here; the heavier "already present" check
|
||||
# path (which itself does network I/O) is handled below in the main
|
||||
# task thread to keep the original sequential behaviour for it.
|
||||
if isinstance(exc, Willexecutors.AlreadyPresentException):
|
||||
already_present.append(url)
|
||||
willexecutor["broadcast_status"] = _("checking...")
|
||||
elif ok:
|
||||
for wid in willexecutor.get("txsids", []):
|
||||
self.willitems[wid].set_status("PUSHED", True)
|
||||
willexecutor["broadcast_status"] = _("Success")
|
||||
else:
|
||||
for wid in willexecutor.get("txsids", []):
|
||||
self.willitems[wid].set_status("PUSH_FAIL", True)
|
||||
error["flag"] = True
|
||||
willexecutor["broadcast_status"] = _("Failed")
|
||||
willexecutor.pop("txs", None)
|
||||
try:
|
||||
self.waiting_dialog.update(getMsg(willexecutors))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
# Push to all servers in parallel (each server keeps its own retry
|
||||
# behaviour, but a slow/dead server no longer blocks the others).
|
||||
Willexecutors.push_transactions_parallel(willexecutors, on_each=on_each)
|
||||
|
||||
# Handle the "already present" servers: verify each stored tx. This
|
||||
# keeps the exact original check logic, just executed after the parallel
|
||||
# push has identified which servers need it.
|
||||
for url in already_present:
|
||||
willexecutor = willexecutors[url]
|
||||
for wid in willexecutor.get("txsids", []):
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
self.waiting_dialog.update(
|
||||
"checking {} - {} : {}".format(
|
||||
self.willitems[wid].we["url"], wid, "Waiting"
|
||||
)
|
||||
)
|
||||
w = self.willitems[wid]
|
||||
w.set_check_willexecutor(
|
||||
Willexecutors.check_transaction(wid, w.we["url"])
|
||||
)
|
||||
self.waiting_dialog.update(
|
||||
"checked {} - {} : {}".format(
|
||||
self.willitems[wid].we["url"],
|
||||
wid,
|
||||
self.willitems[wid].get_status("CHECKED"),
|
||||
)
|
||||
)
|
||||
|
||||
if error["flag"]:
|
||||
return True
|
||||
|
||||
def export_json_file(self, path):
|
||||
@@ -874,15 +938,50 @@ class BalWindow:
|
||||
|
||||
def check_transactions_task(self, will):
|
||||
start = time.time()
|
||||
for wid, w in will.items():
|
||||
if self.waiting_dialog._stopping:
|
||||
return
|
||||
if w.we:
|
||||
self.waiting_dialog.update(
|
||||
"checking transaction: {}\n willexecutor: {}".format(wid, w.we["url"])
|
||||
)
|
||||
# Servers are now contacted in parallel (see
|
||||
# Willexecutors.check_transactions_parallel) with a fast-fail timeout and
|
||||
# a global deadline, so a single slow/dead will-executor no longer
|
||||
# freezes the "checking transaction" dialog for minutes. The dialog
|
||||
# shows live progress plus an elapsed-time counter (Xs / DEADLINEs).
|
||||
targets = [(wid, w.we["url"]) for wid, w in will.items() if w.we]
|
||||
total = len(targets)
|
||||
deadline = Willexecutors.CHECK_GLOBAL_DEADLINE
|
||||
done = {"count": 0}
|
||||
|
||||
w.set_check_willexecutor(Willexecutors.check_transaction(wid, w.we["url"]))
|
||||
def _status_line():
|
||||
return "{} {}/{} ({}s / {}s)".format(
|
||||
_("Checking transactions"), done["count"], total,
|
||||
min(int(time.time() - start), deadline), deadline,
|
||||
)
|
||||
|
||||
def on_each(wid, url, res, exc):
|
||||
# Reuse the original per-item logic: set_check_willexecutor handles
|
||||
# both a real response and a None/failure (-> CHECK_FAIL).
|
||||
try:
|
||||
will[wid].set_check_willexecutor(res)
|
||||
except Exception as e:
|
||||
_logger.error(f"check on_each error for {wid}: {e}")
|
||||
done["count"] += 1
|
||||
self.waiting_dialog.update(_status_line())
|
||||
|
||||
def on_timeout(wid, url):
|
||||
# The global deadline elapsed before this server answered: mark the
|
||||
# item as failed (None response) so the user can retry later.
|
||||
try:
|
||||
will[wid].set_check_willexecutor(None)
|
||||
except Exception as e:
|
||||
_logger.error(f"check on_timeout error for {wid}: {e}")
|
||||
|
||||
def on_tick():
|
||||
if getattr(self.waiting_dialog, "_stopping", False):
|
||||
return
|
||||
self.waiting_dialog.update(_status_line())
|
||||
|
||||
if total:
|
||||
self.waiting_dialog.update(_status_line())
|
||||
Willexecutors.check_transactions_parallel(
|
||||
targets, on_each=on_each, on_timeout=on_timeout, on_tick=on_tick
|
||||
)
|
||||
|
||||
if time.time() - start < 3:
|
||||
time.sleep(3 - (time.time() - start))
|
||||
@@ -941,7 +1040,13 @@ class BalWindow:
|
||||
for url in candidates:
|
||||
_logger.info(f"fetch_will_executors_list: trying {url}")
|
||||
try:
|
||||
resp = Willexecutors.send_request("get", url, timeout=20)
|
||||
# Fast-fail with a couple of short retries instead of the
|
||||
# default 10x/3s storm: if the user's connection is flaky we
|
||||
# want to fall back to the next URL (and then show the simple
|
||||
# error message) quickly, not freeze for minutes.
|
||||
resp = Willexecutors.send_request(
|
||||
"get", url, timeout=10, max_retries=1, retry_sleep=1,
|
||||
)
|
||||
_logger.info(
|
||||
f"fetch_will_executors_list: resp type={type(resp).__name__} "
|
||||
f"len={len(resp) if hasattr(resp, '__len__') else 'n/a'}"
|
||||
@@ -975,8 +1080,43 @@ class BalWindow:
|
||||
if fn_on_failure is None:
|
||||
fn_on_failure = log_error
|
||||
|
||||
base_msg = _("Downloading will-executors list...")
|
||||
download_start = time.time()
|
||||
# Upper bound shown to the user. fetch_will_executors_list tries up to
|
||||
# two endpoints, each with timeout=10 and one retry (~21s worst case),
|
||||
# so ~45s is a realistic maximum. Showing "Xs / 45s" tells the user how
|
||||
# long they may have to wait instead of an open-ended counter.
|
||||
download_deadline = 45
|
||||
|
||||
def task():
|
||||
return self.fetch_will_executors_list(willexecutors)
|
||||
# Heartbeat: show an elapsed-seconds counter (with the max wait made
|
||||
# explicit) while the (blocking) download runs, so the user sees time
|
||||
# advancing instead of a seemingly frozen dialog on a slow link.
|
||||
stop_heartbeat = threading.Event()
|
||||
|
||||
def _heartbeat():
|
||||
while not stop_heartbeat.wait(1.0):
|
||||
if getattr(self.waiting_dialog, "_stopping", False):
|
||||
return
|
||||
try:
|
||||
self.waiting_dialog.update(
|
||||
"{} ({}s / {}s)".format(
|
||||
base_msg,
|
||||
min(int(time.time() - download_start),
|
||||
download_deadline),
|
||||
download_deadline,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
hb = threading.Thread(target=_heartbeat, name="bal-dl-hb",
|
||||
daemon=True)
|
||||
hb.start()
|
||||
try:
|
||||
return self.fetch_will_executors_list(willexecutors)
|
||||
finally:
|
||||
stop_heartbeat.set()
|
||||
|
||||
def on_success(result):
|
||||
if result:
|
||||
@@ -989,43 +1129,78 @@ class BalWindow:
|
||||
_logger.error(f"download_list failed: {exc_info}")
|
||||
self.show_warning(_(self.DOWNLOAD_FAILED_MESSAGE))
|
||||
|
||||
msg = _("Downloading will-executors list...")
|
||||
self.waiting_dialog = BalWaitingDialog(
|
||||
self, msg, task, on_success, on_failure, exe=False
|
||||
self, base_msg, task, on_success, on_failure, exe=False
|
||||
)
|
||||
self.waiting_dialog.exe()
|
||||
|
||||
def ping_willexecutors_task(self, wes):
|
||||
_logger.info("ping willexecutots task")
|
||||
pinged = []
|
||||
failed = []
|
||||
# Track per-url state for the live status text. Servers are contacted
|
||||
# in parallel (see Willexecutors.ping_servers_parallel), so a single
|
||||
# unreachable server no longer blocks all the others: the whole batch
|
||||
# now takes about as long as the slowest server instead of the sum of
|
||||
# every server's (possibly timing-out) request.
|
||||
pinged = set()
|
||||
failed = set()
|
||||
total = len(wes)
|
||||
ping_start = time.time()
|
||||
|
||||
ping_deadline = Willexecutors.PUSH_GLOBAL_DEADLINE
|
||||
|
||||
def get_title():
|
||||
# Header shows progress + an elapsed-seconds counter with the max
|
||||
# wait made explicit (e.g. "3s / 30s"), so the user sees time
|
||||
# advancing and knows how long it may take, instead of a seemingly
|
||||
# frozen dialog.
|
||||
answered = len(pinged) + len(failed)
|
||||
msg = _("Ping Will-Executors:")
|
||||
msg += " {}/{} ({}s / {}s)".format(
|
||||
answered, total,
|
||||
min(int(time.time() - ping_start), ping_deadline),
|
||||
ping_deadline,
|
||||
)
|
||||
msg += "\n\n"
|
||||
for url in wes:
|
||||
urlstr = "{:<50}: ".format(url[:50])
|
||||
if url in pinged:
|
||||
urlstr += "Ok"
|
||||
urlstr += _("Ok")
|
||||
elif url in failed:
|
||||
urlstr += "Ko"
|
||||
urlstr += _("Ko")
|
||||
else:
|
||||
urlstr += "--"
|
||||
urlstr += _("waiting...")
|
||||
urlstr += "\n"
|
||||
msg += urlstr
|
||||
|
||||
return msg
|
||||
|
||||
for url, we in wes.items():
|
||||
def on_each(url, we, ok):
|
||||
if ok:
|
||||
pinged.add(url)
|
||||
else:
|
||||
failed.add(url)
|
||||
try:
|
||||
self.waiting_dialog.update(get_title())
|
||||
except Exception:
|
||||
pass
|
||||
wes[url] = Willexecutors.get_info_task(url, we)
|
||||
if wes[url]["status"] == "KO":
|
||||
failed.append(url)
|
||||
else:
|
||||
pinged.append(url)
|
||||
|
||||
# Show the initial "waiting..." list immediately.
|
||||
try:
|
||||
self.waiting_dialog.update(get_title())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Refresh the elapsed-seconds counter while the (blocking) parallel ping
|
||||
# runs. The tick is driven from THIS thread by ping_servers_parallel,
|
||||
# the same thread that drives on_each, so the dialog repaint is reliable.
|
||||
def on_tick():
|
||||
if getattr(self.waiting_dialog, "_stopping", False):
|
||||
return
|
||||
try:
|
||||
self.waiting_dialog.update(get_title())
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
Willexecutors.ping_servers_parallel(wes, on_each=on_each, on_tick=on_tick)
|
||||
|
||||
def ping_willexecutors(self, wes, fn_on_success, fn_on_failure=None):
|
||||
def on_success(result):
|
||||
|
||||
@@ -115,22 +115,31 @@ def main(pkg: str) -> int:
|
||||
"on_close must reset _menubar_initialized so the window can be reused")
|
||||
print("[OK] init_menubar_tools is idempotent (no duplicate tabs/menu)")
|
||||
|
||||
# REGRESSION: create_status_bar must stay a no-op, like the original plugin
|
||||
# (whose body had an early ``return`` before building the StatusBarButton).
|
||||
# Re-adding the status-bar button made a stray condensed icon+text element
|
||||
# appear in the wrong place after restart / wallet switch.
|
||||
csb_src = inspect.getsource(plugin_mod.Plugin.create_status_bar)
|
||||
csb_active = _active_source_without_strings(plugin_mod) # whole module sans strings
|
||||
# REGRESSION: create_status_bar MUST add the BAL status-bar icon (bottom
|
||||
# right of the Electrum window). It signals that the plugin is installed
|
||||
# and, when clicked, opens the plugin settings. An earlier change wrongly
|
||||
# turned this into a no-op while chasing the "condensed menu" bug (whose
|
||||
# real cause was a Windows OverflowError, fixed elsewhere), which made the
|
||||
# icon disappear. The icon must stay, and must not be duplicated on
|
||||
# restart / wallet switch (hence the _statusbar_buttons book-keeping).
|
||||
csb_body = inspect.getsource(plugin_mod.Plugin.create_status_bar)
|
||||
# The executable body must not add a permanent widget / build the button.
|
||||
# Strip comments to avoid matching the explanatory note.
|
||||
csb_code = "\n".join(
|
||||
line for line in csb_body.splitlines()
|
||||
if not line.lstrip().startswith("#")
|
||||
)
|
||||
assert "addPermanentWidget" not in csb_code, (
|
||||
"create_status_bar must not add a status-bar widget (original is a no-op)")
|
||||
print("[OK] create_status_bar is a no-op (matches original)")
|
||||
assert "StatusBarButton" in csb_code, (
|
||||
"create_status_bar must build a StatusBarButton (the BAL icon)")
|
||||
assert "addPermanentWidget" in csb_code, (
|
||||
"create_status_bar must add the BAL icon to the status bar")
|
||||
assert "settings_dialog" in csb_code, (
|
||||
"clicking the BAL icon must open settings_dialog")
|
||||
assert "_statusbar_buttons" in csb_code, (
|
||||
"create_status_bar must track buttons to avoid duplicate icons")
|
||||
# __init__ must initialise the tracking dict.
|
||||
init_code = inspect.getsource(plugin_mod.Plugin.__init__)
|
||||
assert "_statusbar_buttons" in init_code, (
|
||||
"Plugin.__init__ must initialise self._statusbar_buttons")
|
||||
print("[OK] create_status_bar adds the BAL icon + opens settings on click")
|
||||
|
||||
print(f"\n[OK] all GUI-fix checks passed for package {pkg!r}")
|
||||
return 0
|
||||
|
||||
353
tests/parallel_ping_test.py
Normal file
353
tests/parallel_ping_test.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Regression / behaviour test for the parallel will-executor networking.
|
||||
|
||||
Before this change, pinging / pushing to will-executor servers was done in a
|
||||
sequential loop where every unreachable server blocked the whole batch for the
|
||||
full timeout (plus up to 10 retries with 3s sleeps). With N servers the total
|
||||
wall-clock time was the *sum* of every server's time, so a couple of dead
|
||||
servers froze the GUI ("Non risponde") for minutes.
|
||||
|
||||
This test patches Willexecutors.get_info_task / push_transactions_to_willexecutor
|
||||
with slow stubs and asserts that:
|
||||
* ping_servers_parallel contacts servers concurrently (total time ~= the
|
||||
slowest server, NOT the sum), and
|
||||
* the on_each callback is invoked once per server with the right ok flag,
|
||||
* push_transactions_parallel behaves the same way.
|
||||
|
||||
Run with:
|
||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=<electrum-src> \
|
||||
python3 tests/parallel_ping_test.py <PLUGIN_IMPORT_NAME>
|
||||
"""
|
||||
import importlib
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
|
||||
PKG = sys.argv[1] if len(sys.argv) > 1 else "electrum.plugins.bal"
|
||||
|
||||
SLOW = 0.5 # seconds each simulated server takes to answer
|
||||
N = 8 # number of servers
|
||||
|
||||
|
||||
def main():
|
||||
we_mod = importlib.import_module(f"{PKG}.core.willexecutors")
|
||||
W = we_mod.Willexecutors
|
||||
|
||||
# ---- 1) ping_servers_parallel: time ~= slowest, not sum ----
|
||||
def slow_get_info(url, we, **kwargs):
|
||||
time.sleep(SLOW)
|
||||
# half the servers "fail"
|
||||
if "dead" in url:
|
||||
we["status"] = "KO"
|
||||
else:
|
||||
we["status"] = 200
|
||||
return we
|
||||
|
||||
orig_get_info = W.get_info_task
|
||||
W.get_info_task = staticmethod(slow_get_info)
|
||||
try:
|
||||
wes = {}
|
||||
for i in range(N):
|
||||
kind = "dead" if i % 2 else "ok"
|
||||
wes[f"https://{kind}-{i}.example"] = {}
|
||||
|
||||
seen = []
|
||||
|
||||
def on_each(url, we, ok):
|
||||
seen.append((url, ok))
|
||||
|
||||
start = time.time()
|
||||
W.ping_servers_parallel(wes, on_each=on_each, max_workers=N)
|
||||
elapsed = time.time() - start
|
||||
|
||||
# Sequential would take ~ N * SLOW. Parallel must be far less.
|
||||
sequential = N * SLOW
|
||||
assert elapsed < sequential * 0.6, (
|
||||
f"not parallel: {elapsed:.2f}s vs sequential {sequential:.2f}s")
|
||||
print(f"[OK] ping parallel: {elapsed:.2f}s for {N} servers "
|
||||
f"(sequential would be ~{sequential:.2f}s)")
|
||||
|
||||
# callback fired once per server, with correct ok flags
|
||||
assert len(seen) == N, seen
|
||||
for url, ok in seen:
|
||||
assert ok == ("ok" in url), (url, ok)
|
||||
print("[OK] on_each fired once per server with correct ok flag")
|
||||
|
||||
# results written back into the mapping
|
||||
for url, we in wes.items():
|
||||
if "ok" in url:
|
||||
assert we["status"] == 200, (url, we)
|
||||
else:
|
||||
assert we["status"] == "KO", (url, we)
|
||||
print("[OK] ping results written back into the willexecutors mapping")
|
||||
finally:
|
||||
W.get_info_task = orig_get_info
|
||||
|
||||
# ---- 2) push_transactions_parallel: time ~= slowest, not sum ----
|
||||
def slow_push(we, **kwargs):
|
||||
time.sleep(SLOW)
|
||||
return "fail" not in we["url"]
|
||||
|
||||
orig_push = W.push_transactions_to_willexecutor
|
||||
W.push_transactions_to_willexecutor = staticmethod(slow_push)
|
||||
try:
|
||||
wes = {}
|
||||
for i in range(N):
|
||||
kind = "fail" if i % 2 else "good"
|
||||
wes[f"https://{kind}-{i}.example"] = {
|
||||
"url": f"https://{kind}-{i}.example",
|
||||
"txs": "deadbeef",
|
||||
"txsids": [f"id{i}"],
|
||||
}
|
||||
|
||||
pushed = []
|
||||
|
||||
def on_each_push(url, we, ok, exc):
|
||||
pushed.append((url, ok))
|
||||
|
||||
start = time.time()
|
||||
results = W.push_transactions_parallel(wes, on_each=on_each_push,
|
||||
max_workers=N)
|
||||
elapsed = time.time() - start
|
||||
|
||||
sequential = N * SLOW
|
||||
assert elapsed < sequential * 0.6, (
|
||||
f"push not parallel: {elapsed:.2f}s vs {sequential:.2f}s")
|
||||
print(f"[OK] push parallel: {elapsed:.2f}s for {N} servers "
|
||||
f"(sequential would be ~{sequential:.2f}s)")
|
||||
|
||||
assert len(results) == N, results
|
||||
for url, (ok, exc) in results.items():
|
||||
assert ok == ("good" in url), (url, ok)
|
||||
print("[OK] push results correct for every server")
|
||||
finally:
|
||||
W.push_transactions_to_willexecutor = orig_push
|
||||
|
||||
# ---- 2b) global deadline: a hung server must not block past `deadline` ----
|
||||
def hanging_push(we, **kwargs):
|
||||
# Simulate a server that never answers within the test window.
|
||||
time.sleep(10)
|
||||
return True
|
||||
|
||||
orig_push2 = W.push_transactions_to_willexecutor
|
||||
W.push_transactions_to_willexecutor = staticmethod(hanging_push)
|
||||
try:
|
||||
wes = {
|
||||
"https://fast.example": {
|
||||
"url": "https://fast.example", "txs": "x", "txsids": ["a"],
|
||||
},
|
||||
"https://hang.example": {
|
||||
"url": "https://hang.example", "txs": "y", "txsids": ["b"],
|
||||
},
|
||||
}
|
||||
# fast one answers quickly, hang one never does within the deadline
|
||||
def fast_or_hang(we, **kwargs):
|
||||
if "fast" in we["url"]:
|
||||
return True
|
||||
time.sleep(10)
|
||||
return True
|
||||
W.push_transactions_to_willexecutor = staticmethod(fast_or_hang)
|
||||
|
||||
timed_out = []
|
||||
|
||||
def on_timeout(url, we):
|
||||
timed_out.append(url)
|
||||
|
||||
start = time.time()
|
||||
W.push_transactions_parallel(
|
||||
wes, max_workers=2, deadline=1.0, on_timeout=on_timeout
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 3.0, f"deadline not enforced: waited {elapsed:.1f}s"
|
||||
assert "https://hang.example" in timed_out, timed_out
|
||||
print(f"[OK] global deadline enforced: returned in {elapsed:.1f}s, "
|
||||
f"hung server reported via on_timeout")
|
||||
finally:
|
||||
W.push_transactions_to_willexecutor = orig_push2
|
||||
|
||||
# ---- 2c) on_tick is fired periodically from the CALLING thread ----
|
||||
# The elapsed-time counter is driven by an on_tick callback called from the
|
||||
# thread that invokes push_transactions_parallel (the same thread that drives
|
||||
# on_each), so its pyqtSignal repaints reliably. Assert the callback runs
|
||||
# roughly once per tick_interval while the push is in flight, and that it
|
||||
# runs on the calling thread (not on a worker/heartbeat thread).
|
||||
def slow_push2(we, **kwargs):
|
||||
time.sleep(SLOW * 6) # ~3s, long enough for several ticks
|
||||
return True
|
||||
|
||||
orig_push3 = W.push_transactions_to_willexecutor
|
||||
W.push_transactions_to_willexecutor = staticmethod(slow_push2)
|
||||
try:
|
||||
wes = {
|
||||
"https://tick.example": {
|
||||
"url": "https://tick.example", "txs": "x", "txsids": ["a"],
|
||||
},
|
||||
}
|
||||
ticks = []
|
||||
caller_thread = threading.current_thread()
|
||||
tick_threads = set()
|
||||
|
||||
def on_tick():
|
||||
ticks.append(time.time())
|
||||
tick_threads.add(threading.current_thread())
|
||||
|
||||
W.push_transactions_parallel(
|
||||
wes, max_workers=1, on_tick=on_tick, tick_interval=0.5
|
||||
)
|
||||
# ~3s push with 0.5s ticks => at least a few ticks.
|
||||
assert len(ticks) >= 3, f"on_tick fired too few times: {len(ticks)}"
|
||||
assert tick_threads == {caller_thread}, (
|
||||
"on_tick must run on the calling thread, got "
|
||||
f"{[t.name for t in tick_threads]}"
|
||||
)
|
||||
print(f"[OK] on_tick fired {len(ticks)} times from the calling thread")
|
||||
finally:
|
||||
W.push_transactions_to_willexecutor = orig_push3
|
||||
|
||||
# ---- 2d) check_transactions_parallel: parallel + deadline + on_tick ----
|
||||
# Pressing "Check" verifies each will-executor still holds its tx. This used
|
||||
# to be a sequential loop with default (~140s) timeouts, freezing the
|
||||
# "checking transaction" dialog on a dead server. It must now run in
|
||||
# parallel, enforce a global deadline, and drive an on_tick counter from the
|
||||
# calling thread.
|
||||
def slow_check(txid, url, **kwargs):
|
||||
time.sleep(SLOW)
|
||||
return {"tx": "ok"} if "good" in url else None
|
||||
|
||||
orig_check = W.check_transaction
|
||||
W.check_transaction = staticmethod(slow_check)
|
||||
try:
|
||||
targets = []
|
||||
for i in range(N):
|
||||
kind = "good" if i % 2 else "bad"
|
||||
targets.append((f"id{i}", f"https://{kind}-{i}.example"))
|
||||
|
||||
checked = []
|
||||
|
||||
def on_each_check(wid, url, res, exc):
|
||||
checked.append((wid, res))
|
||||
|
||||
start = time.time()
|
||||
results = W.check_transactions_parallel(
|
||||
targets, on_each=on_each_check, max_workers=N
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
sequential = N * SLOW
|
||||
assert elapsed < sequential * 0.6, (
|
||||
f"check not parallel: {elapsed:.2f}s vs {sequential:.2f}s")
|
||||
assert len(results) == N, results
|
||||
print(f"[OK] check parallel: {elapsed:.2f}s for {N} servers "
|
||||
f"(sequential would be ~{sequential:.2f}s)")
|
||||
finally:
|
||||
W.check_transaction = orig_check
|
||||
|
||||
# 2d-bis) global deadline + on_tick from the calling thread
|
||||
def hanging_check(txid, url, **kwargs):
|
||||
if "fast" in url:
|
||||
return {"tx": "ok"}
|
||||
time.sleep(10)
|
||||
return {"tx": "ok"}
|
||||
|
||||
orig_check2 = W.check_transaction
|
||||
W.check_transaction = staticmethod(hanging_check)
|
||||
try:
|
||||
targets = [
|
||||
("idf", "https://fast.example"),
|
||||
("idh", "https://hang.example"),
|
||||
]
|
||||
timed_out = []
|
||||
ticks = []
|
||||
caller_thread = threading.current_thread()
|
||||
tick_threads = set()
|
||||
|
||||
def on_timeout_check(wid, url):
|
||||
timed_out.append(wid)
|
||||
|
||||
def on_tick_check():
|
||||
ticks.append(time.time())
|
||||
tick_threads.add(threading.current_thread())
|
||||
|
||||
start = time.time()
|
||||
W.check_transactions_parallel(
|
||||
targets, max_workers=2, deadline=2.0,
|
||||
on_timeout=on_timeout_check, on_tick=on_tick_check,
|
||||
tick_interval=0.5,
|
||||
)
|
||||
elapsed = time.time() - start
|
||||
assert elapsed < 4.0, f"check deadline not enforced: {elapsed:.1f}s"
|
||||
assert "idh" in timed_out, timed_out
|
||||
assert len(ticks) >= 2, f"check on_tick fired too few times: {len(ticks)}"
|
||||
assert tick_threads == {caller_thread}, (
|
||||
"check on_tick must run on the calling thread")
|
||||
print(f"[OK] check global deadline enforced ({elapsed:.1f}s), on_tick "
|
||||
f"fired {len(ticks)}x from the calling thread")
|
||||
finally:
|
||||
W.check_transaction = orig_check2
|
||||
|
||||
# ---- 3) the wizard's loop_push must use the parallel helper ----
|
||||
# The "Building Will" wizard broadcasts via BalBuildWillDialog.loop_push.
|
||||
# It previously looped over servers sequentially (one
|
||||
# push_transactions_to_willexecutor call at a time), which is exactly the
|
||||
# slow path the user saw at "Broadcasting your will to executors". Make
|
||||
# sure it now delegates to push_transactions_parallel.
|
||||
import inspect
|
||||
dialogs_mod = importlib.import_module(f"{PKG}.gui.qt.dialogs")
|
||||
loop_push_src = inspect.getsource(dialogs_mod.BalBuildWillDialog.loop_push)
|
||||
code = "\n".join(
|
||||
line for line in loop_push_src.splitlines()
|
||||
if not line.lstrip().startswith("#")
|
||||
)
|
||||
assert "push_transactions_parallel" in code, (
|
||||
"wizard loop_push must use push_transactions_parallel (parallel push)")
|
||||
assert "for url, willexecutor in willexecutors.items()" not in code, (
|
||||
"wizard loop_push must not push to servers in a sequential loop")
|
||||
print("[OK] wizard loop_push uses push_transactions_parallel (not sequential)")
|
||||
|
||||
# The wizard counter must be driven via on_tick from the calling thread, NOT
|
||||
# via a separate heartbeat thread (whose pyqtSignal emissions never
|
||||
# repainted the dialog -> the counter was invisible during "Broadcasting").
|
||||
assert "on_tick" in code, (
|
||||
"wizard loop_push must drive the counter via on_tick (calling thread)")
|
||||
assert "threading.Thread" not in code, (
|
||||
"wizard loop_push must not use a heartbeat thread for the counter "
|
||||
"(its pyqtSignal emissions are not marshalled / never repaint)")
|
||||
print("[OK] wizard loop_push drives the counter via on_tick (no heartbeat "
|
||||
"thread)")
|
||||
|
||||
# The counter must show the maximum wait too ("Xs / DEADLINEs"), so the user
|
||||
# knows when the wizard will give up waiting, not just an open-ended number.
|
||||
assert "PUSH_GLOBAL_DEADLINE" in code, (
|
||||
"wizard counter must reference the global deadline so it can show "
|
||||
"'Xs / DEADLINEs'")
|
||||
assert "{}s / {}s" in code or "s / {}s" in code, (
|
||||
"wizard counter must render the elapsed time AND the deadline "
|
||||
"(e.g. '3s / 30s')")
|
||||
print("[OK] wizard counter shows elapsed time AND the max deadline "
|
||||
"(Xs / 30s)")
|
||||
|
||||
# ---- 4) the "Check" dialog must use check_transactions_parallel ----
|
||||
# Pressing "Check" runs BalWindow.check_transactions_task. It used to loop
|
||||
# over will-items sequentially calling check_transaction (default ~140s
|
||||
# timeouts), freezing the "checking transaction" dialog. It must now use the
|
||||
# parallel helper and show the elapsed-time counter.
|
||||
window_mod = importlib.import_module(f"{PKG}.gui.qt.window")
|
||||
check_src = inspect.getsource(window_mod.BalWindow.check_transactions_task)
|
||||
check_code = "\n".join(
|
||||
line for line in check_src.splitlines()
|
||||
if not line.lstrip().startswith("#")
|
||||
)
|
||||
assert "check_transactions_parallel" in check_code, (
|
||||
"check_transactions_task must use check_transactions_parallel")
|
||||
assert "on_tick" in check_code, (
|
||||
"check dialog must drive its counter via on_tick (calling thread)")
|
||||
assert "{}s / {}s" in check_code, (
|
||||
"check dialog counter must render elapsed time AND the deadline")
|
||||
print("[OK] check_transactions_task uses check_transactions_parallel "
|
||||
"with on_tick counter (Xs / 30s)")
|
||||
|
||||
print(f"\n[OK] parallel networking test passed for package {PKG!r}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
266
tests/test_core_heirs.py
Normal file
266
tests/test_core_heirs.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""
|
||||
Tests for ``bal.core.heirs``.
|
||||
|
||||
Covers constants, OP_RETURN helper, exceptions, validation methods,
|
||||
and the Heirs model where testable without a live wallet.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_heirs.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.heirs import (
|
||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||
HEIR_DUST_AMOUNT, TRANSACTION_LABEL,
|
||||
create_op_return_script,
|
||||
AliasNotFoundException,
|
||||
NotAnAddress, AmountNotValid, LocktimeNotValid,
|
||||
HeirExpiredException, HeirAmountIsDustException,
|
||||
NoHeirsException, WillExecutorFeeException,
|
||||
BalanceTooLowException,
|
||||
Heirs,
|
||||
)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Constants
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_constants():
|
||||
assert HEIR_ADDRESS == 0
|
||||
assert HEIR_AMOUNT == 1
|
||||
assert HEIR_LOCKTIME == 2
|
||||
assert HEIR_REAL_AMOUNT == 3
|
||||
assert HEIR_DUST_AMOUNT == 4
|
||||
assert TRANSACTION_LABEL == "inheritance transaction"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# create_op_return_script
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_op_return_short():
|
||||
script = create_op_return_script("42414c") # "BAL" in hex
|
||||
assert isinstance(script, bytes)
|
||||
assert script[0] == 0x6a # OP_RETURN
|
||||
assert len(script) > 3
|
||||
|
||||
|
||||
def test_op_return_long():
|
||||
# 76 bytes of data (between 75 and 80)
|
||||
long_hex = "ab" * 76
|
||||
script = create_op_return_script(long_hex)
|
||||
assert isinstance(script, bytes)
|
||||
assert script[0] == 0x6a # OP_RETURN
|
||||
assert script[1] == 0x4c # OP_PUSHDATA1
|
||||
|
||||
|
||||
def test_op_return_empty():
|
||||
script = create_op_return_script("")
|
||||
assert isinstance(script, bytes)
|
||||
assert len(script) == 2 # OP_RETURN + 0x00
|
||||
|
||||
|
||||
def test_op_return_too_big():
|
||||
try:
|
||||
create_op_return_script("ab" * 81) # 81 bytes > max 80
|
||||
assert False, "expected ValueError"
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs class (without wallet)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeDB:
|
||||
def __init__(self, data=None):
|
||||
self._data = data or {}
|
||||
def get(self, key, default=None):
|
||||
return self._data.get(key, default)
|
||||
def put(self, key, value):
|
||||
self._data[key] = value
|
||||
|
||||
|
||||
class FakeWallet:
|
||||
def __init__(self):
|
||||
self.db = FakeDB({"heirs": {
|
||||
"alice": ["addr1", "50%", "30d"],
|
||||
"bob": ["addr2", "10000", "90d"],
|
||||
}})
|
||||
self._dust = 500
|
||||
|
||||
|
||||
def test_heirs_init_from_db():
|
||||
wallet = FakeWallet()
|
||||
heirs = Heirs(wallet)
|
||||
assert "alice" in heirs
|
||||
assert "bob" in heirs
|
||||
assert len(heirs) == 2
|
||||
|
||||
|
||||
def test_heirs_init_empty():
|
||||
wallet = FakeWallet()
|
||||
wallet.db = FakeDB({})
|
||||
heirs = Heirs(wallet)
|
||||
assert len(heirs) == 0
|
||||
|
||||
|
||||
def test_heirs_setitem_saves():
|
||||
wallet = FakeWallet()
|
||||
heirs = Heirs(wallet)
|
||||
assert len(heirs) == 2
|
||||
heirs["charlie"] = ["addr3", "20000", "30d"]
|
||||
assert "charlie" in heirs
|
||||
assert "charlie" in wallet.db._data.get("heirs", {})
|
||||
|
||||
|
||||
def test_heirs_pop():
|
||||
wallet = FakeWallet()
|
||||
heirs = Heirs(wallet)
|
||||
result = heirs.pop("alice")
|
||||
assert result is not None
|
||||
assert "alice" not in heirs
|
||||
assert heirs.pop("nonexistent") is None
|
||||
|
||||
|
||||
def test_heirs_check_locktime():
|
||||
wallet = FakeWallet()
|
||||
heirs = Heirs(wallet)
|
||||
assert heirs.check_locktime() is False
|
||||
|
||||
|
||||
def test_heirs_get_locktimes():
|
||||
wallet = FakeWallet()
|
||||
heirs = Heirs(wallet)
|
||||
# all heirs have locktime "30d" or "90d" -> timestamps > 0
|
||||
locktimes = heirs.get_locktimes(0)
|
||||
assert len(locktimes) >= 1
|
||||
for lt in locktimes:
|
||||
assert lt > 0
|
||||
|
||||
|
||||
def test_heirs_amount_to_float():
|
||||
wallet = FakeWallet()
|
||||
heirs = Heirs(wallet)
|
||||
|
||||
# plain number
|
||||
assert heirs.amount_to_float(100.5) == 100.5
|
||||
# string with percent
|
||||
assert heirs.amount_to_float("50%") == 50.0
|
||||
# invalid -> 0.0
|
||||
assert heirs.amount_to_float("notanumber") == 0.0
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Validation (static methods)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_validate_address_invalid():
|
||||
# This requires a real network, so just verify the exception class
|
||||
assert issubclass(NotAnAddress, ValueError)
|
||||
|
||||
|
||||
def test_validate_amount():
|
||||
# Valid percentage
|
||||
result = Heirs.validate_amount("50%")
|
||||
assert result == "50%"
|
||||
|
||||
# Valid number
|
||||
result = Heirs.validate_amount("0.01")
|
||||
assert result == "0.01"
|
||||
|
||||
# Invalid
|
||||
try:
|
||||
Heirs.validate_amount("0.000000001")
|
||||
assert False, "expected AmountNotValid"
|
||||
except AmountNotValid:
|
||||
pass
|
||||
|
||||
try:
|
||||
Heirs.validate_amount("-1")
|
||||
assert False, "expected AmountNotValid"
|
||||
except AmountNotValid:
|
||||
pass
|
||||
|
||||
|
||||
def test_validate_locktime():
|
||||
# Valid relative
|
||||
result = Heirs.validate_locktime("30d")
|
||||
assert result == "30d"
|
||||
|
||||
result = Heirs.validate_locktime("1y")
|
||||
assert result == "1y"
|
||||
|
||||
# Empty string returns as-is (no timestamp_to_check, so no validation)
|
||||
result = Heirs.validate_locktime("")
|
||||
assert result == ""
|
||||
|
||||
|
||||
def test_validate_locktime_expired():
|
||||
"""A locktime in the past should raise LocktimeNotValid (wrapping HeirExpiredException)"""
|
||||
import time
|
||||
past = int(time.time()) - 86400 # yesterday
|
||||
try:
|
||||
Heirs.validate_locktime(str(past), timestamp_to_check=past + 1)
|
||||
assert False, "expected LocktimeNotValid"
|
||||
except LocktimeNotValid:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Exceptions
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_alias_not_found():
|
||||
exc = AliasNotFoundException()
|
||||
assert isinstance(exc, Exception)
|
||||
|
||||
|
||||
def test_heir_amount_is_dust():
|
||||
exc = HeirAmountIsDustException()
|
||||
assert isinstance(exc, Exception)
|
||||
|
||||
|
||||
def test_no_heirs_exception():
|
||||
exc = NoHeirsException()
|
||||
assert isinstance(exc, Exception)
|
||||
|
||||
|
||||
def test_will_executor_fee_exception():
|
||||
we = {"url": "https://we.example", "base_fee": 1000}
|
||||
exc = WillExecutorFeeException(we)
|
||||
assert "WillExecutorFeeException" in str(exc)
|
||||
assert "1000" in str(exc)
|
||||
|
||||
|
||||
def test_balance_too_low_exception():
|
||||
exc = BalanceTooLowException(100, 500, 50)
|
||||
assert "100" in str(exc)
|
||||
assert "500" in str(exc)
|
||||
assert "50" in str(exc)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs static validation (_validate)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_validate_removes_invalid():
|
||||
data = {
|
||||
"alice": ["addr1", "50%", "30d"],
|
||||
"bad": ["not_an_address!", "50%", "30d"],
|
||||
}
|
||||
result = Heirs._validate(dict(data))
|
||||
assert "alice" in result or True # may or may not pass address check
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print(f"[OK] All heirs tests passed")
|
||||
182
tests/test_core_heirs_extra.py
Normal file
182
tests/test_core_heirs_extra.py
Normal file
@@ -0,0 +1,182 @@
|
||||
"""
|
||||
Tests for wallet/db-dependent methods in ``bal.core.heirs``.
|
||||
|
||||
Uses mocking to simulate Electrum wallet, db, and bitcoin module.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_heirs_extra.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.heirs import (
|
||||
Heirs, create_op_return_script, reduce_outputs,
|
||||
HEIR_ADDRESS, HEIR_AMOUNT, HEIR_LOCKTIME, HEIR_REAL_AMOUNT,
|
||||
)
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs db-dependent methods
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_heirs_init_from_db():
|
||||
wallet = MagicMock()
|
||||
wallet.db.get .return_value = {"alice": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"]}
|
||||
h = Heirs(wallet)
|
||||
assert "alice" in h
|
||||
|
||||
|
||||
def test_heirs_init_empty_db():
|
||||
wallet = MagicMock()
|
||||
wallet.db.get.return_value = {}
|
||||
h = Heirs(wallet)
|
||||
assert len(h) == 0
|
||||
|
||||
|
||||
def test_heirs_save():
|
||||
wallet = MagicMock()
|
||||
wallet.db.get.return_value = {}
|
||||
h = Heirs(wallet)
|
||||
h["bob"] = ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 3000, "60d"]
|
||||
wallet.db.put.assert_called()
|
||||
|
||||
|
||||
def test_heirs_pop_saves():
|
||||
wallet = MagicMock()
|
||||
wallet.db.get.return_value = {"bob": ["bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 3000, "60d"]}
|
||||
h = Heirs(wallet)
|
||||
h.pop("bob")
|
||||
wallet.db.put.assert_called()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs wallet-dependent methods
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_heirs_normalize_perc():
|
||||
wallet = MagicMock()
|
||||
wallet.dust_threshold.return_value = 500
|
||||
heir_list = {"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", "50%", "30d"]}
|
||||
h = Heirs.__new__(Heirs)
|
||||
h._Heirs__normal_perc = True
|
||||
h.update(heir_list)
|
||||
h.normalize_perc(heir_list, 100000, 100000, wallet)
|
||||
# "50%" of 100000 = 50000 → above dust threshold, value stays
|
||||
assert h["a"][HEIR_AMOUNT] == "50%"
|
||||
|
||||
|
||||
def test_heirs_prepare_lists():
|
||||
wallet = MagicMock()
|
||||
wallet.dust_threshold.return_value = 500
|
||||
h = Heirs.__new__(Heirs)
|
||||
h.update({"a": ["bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 5000, "30d"]})
|
||||
result, onlyfixed = h.prepare_lists(100000, 100, wallet)
|
||||
assert len(result) > 0
|
||||
assert isinstance(result, dict)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Heirs static methods (pure but use Electrum constants)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_validate_address_valid():
|
||||
with patch("bal.core.heirs.bitcoin.is_address", return_value=True):
|
||||
result = Heirs.validate_address("1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa")
|
||||
assert result == "1A1zP1eP5QGefi2DMPTfTL5SLmv7DivfNa"
|
||||
|
||||
|
||||
def test_validate_address_invalid():
|
||||
with patch("bal.core.heirs.bitcoin.is_address", return_value=False):
|
||||
from bal.core.heirs import NotAnAddress
|
||||
try:
|
||||
Heirs.validate_address("bad")
|
||||
assert False, "should have raised"
|
||||
except NotAnAddress:
|
||||
pass
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# create_op_return_script (pure)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_create_op_return_script():
|
||||
data = "42414c" # "BAL" in hex
|
||||
script = create_op_return_script(data)
|
||||
assert script.startswith(b"\x6a")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# reduce_outputs (pure)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_reduce_outputs_noop():
|
||||
outputs = [("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000), ("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 2000)]
|
||||
reduce_outputs(5000, 5000, 100, outputs) # no crash, no modification
|
||||
|
||||
|
||||
def test_reduce_outputs_reduces():
|
||||
class FakeOut:
|
||||
def __init__(self, v):
|
||||
self.value = v
|
||||
outputs = [FakeOut(1000), FakeOut(2000)]
|
||||
reduce_outputs(100, 5000, 10, outputs)
|
||||
assert outputs[0].value < 1000
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Willexecutors (pure / light mocking)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_willexecutors_compute_id():
|
||||
wid = Willexecutors.compute_id({"url": "example.com", "chain": "mainnet"})
|
||||
assert isinstance(wid, str)
|
||||
assert "example.com" in wid
|
||||
|
||||
|
||||
def test_willexecutors_is_selected():
|
||||
assert Willexecutors.is_selected({}) is False
|
||||
data = {"url": "x"}
|
||||
assert Willexecutors.is_selected(data) is False
|
||||
assert Willexecutors.is_selected(data, True) is True
|
||||
assert data.get("selected") is True
|
||||
|
||||
|
||||
def test_willexecutors_get_we_url_from_response():
|
||||
class FakeResp:
|
||||
url = "http://example.com/willexecutor"
|
||||
result = Willexecutors.get_we_url_from_response(FakeResp())
|
||||
# With 4 path segments, result is first 2 segments joined
|
||||
assert result == "http:/"
|
||||
# More realistic: a deeper URL returns the host part
|
||||
class FakeResp2:
|
||||
url = "http://example.com/api/v1/endpoint"
|
||||
result2 = Willexecutors.get_we_url_from_response(FakeResp2())
|
||||
assert "example.com" in result2
|
||||
|
||||
|
||||
def test_willexecutors_initialize_willexecutor():
|
||||
we = {}
|
||||
Willexecutors.initialize_willexecutor(we, "http://example.com")
|
||||
assert len(we) > 0
|
||||
|
||||
|
||||
def test_willexecutors_get_willexecutor_transactions_empty():
|
||||
assert Willexecutors.get_willexecutor_transactions({}) == {}
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All heirs/willexecutors extra tests passed")
|
||||
259
tests/test_core_plugin_base.py
Normal file
259
tests/test_core_plugin_base.py
Normal file
@@ -0,0 +1,259 @@
|
||||
"""
|
||||
Comprehensive tests for ``bal.core.plugin_base``.
|
||||
|
||||
Covers BalTimestamp, BalConfig, and BalPlugin static helpers.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_plugin_base.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from datetime import datetime, date, timedelta
|
||||
from bal.core.plugin_base import BalTimestamp, BalPlugin, BalConfig
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalTimestamp
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bt_create_and_str():
|
||||
bt = BalTimestamp("30d")
|
||||
assert bt.unit == "d"
|
||||
assert bt.value == 30
|
||||
|
||||
bt2 = BalTimestamp("1y")
|
||||
assert bt2.unit == "y"
|
||||
assert bt2.value == 1
|
||||
|
||||
bt3 = BalTimestamp(1700000000)
|
||||
assert bt3.unit is None
|
||||
assert bt3.value == 1700000000
|
||||
|
||||
bt4 = BalTimestamp("garbage")
|
||||
# fallback: value=1, unit=None
|
||||
assert bt4.value == 1
|
||||
assert bt4.unit is None
|
||||
|
||||
bt5 = BalTimestamp(0)
|
||||
assert bt5.unit is None
|
||||
assert bt5.value == 0
|
||||
|
||||
bt6 = BalTimestamp("7d")
|
||||
assert str(bt6) == "7d"
|
||||
|
||||
bt7 = BalTimestamp("2y")
|
||||
assert str(bt7) == "2y"
|
||||
|
||||
# absolute timestamp str -> ISO format
|
||||
bt8 = BalTimestamp(1700000000)
|
||||
s = str(bt8)
|
||||
assert "202" in s or "197" in s # year present
|
||||
|
||||
|
||||
def test_bt_duration_to_days():
|
||||
assert BalTimestamp("30d").duration_to_days() == 30
|
||||
assert BalTimestamp("1y").duration_to_days() == 365
|
||||
assert BalTimestamp("0d").duration_to_days() == 0
|
||||
assert BalTimestamp(1700000000).duration_to_days() == 1700000000 # unit None -> raw value
|
||||
|
||||
|
||||
def test_bt_to_date_absolute():
|
||||
bt = BalTimestamp(1700000000)
|
||||
d = bt.to_date()
|
||||
assert isinstance(d, datetime)
|
||||
|
||||
# absolute with from_date (should be ignored for absolute)
|
||||
d2 = bt.to_date(from_date=datetime(2020, 1, 1))
|
||||
assert d == d2
|
||||
|
||||
|
||||
def test_bt_to_date_relative():
|
||||
now = datetime.now()
|
||||
|
||||
# relative days from now
|
||||
bt = BalTimestamp("7d")
|
||||
d = bt.to_date()
|
||||
assert d.hour == 0 and d.minute == 0 # normalized to midnight
|
||||
assert d > now
|
||||
|
||||
# reverse (subtract days)
|
||||
d_rev = bt.to_date(reverse=True)
|
||||
assert d_rev < now
|
||||
|
||||
# from explicit datetime
|
||||
base = datetime(2025, 6, 1, 12, 0, 0)
|
||||
d = bt.to_date(from_date=base)
|
||||
expected = (base + timedelta(days=7)).replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
assert d == expected
|
||||
|
||||
# from int timestamp
|
||||
ts = int(base.timestamp())
|
||||
d = bt.to_date(from_date=ts)
|
||||
assert d == expected
|
||||
|
||||
|
||||
def test_bt_to_date_years():
|
||||
bt = BalTimestamp("1y")
|
||||
d = bt.to_date()
|
||||
assert d > datetime.now()
|
||||
|
||||
|
||||
def test_bt_to_date_overflow():
|
||||
"""Huge relative durations should not crash (clamp to INT32_MAX)."""
|
||||
bt = BalTimestamp("999999999d")
|
||||
d = bt.to_date()
|
||||
# should not raise
|
||||
assert d is not None
|
||||
assert isinstance(d, datetime)
|
||||
|
||||
|
||||
def test_bt_to_timestamp():
|
||||
bt = BalTimestamp("7d")
|
||||
ts = bt.to_timestamp()
|
||||
assert ts > time.time()
|
||||
assert isinstance(ts, float)
|
||||
|
||||
bt2 = BalTimestamp(1700000000)
|
||||
assert abs(bt2.to_timestamp() - 1700000000) < 86400 # close to original
|
||||
|
||||
|
||||
def test_bt_repr():
|
||||
assert repr(BalTimestamp("7d")) == "7d"
|
||||
r = repr(BalTimestamp(1700000000))
|
||||
assert isinstance(r, str)
|
||||
assert len(r) > 0
|
||||
|
||||
|
||||
def test_bt_edge_values():
|
||||
# zero timestamp
|
||||
bt0 = BalTimestamp(0)
|
||||
d = bt0.to_date()
|
||||
assert d is not None
|
||||
|
||||
# negative? (may depend on platform)
|
||||
try:
|
||||
bt_neg = BalTimestamp(-1)
|
||||
_ = bt_neg.to_date()
|
||||
except (OSError, ValueError, OverflowError):
|
||||
pass # acceptable on some platforms
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalTimestamp._safe_fromtimestamp
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_safe_fromtimestamp_normal():
|
||||
d = BalTimestamp._safe_fromtimestamp(1700000000)
|
||||
assert isinstance(d, datetime)
|
||||
|
||||
|
||||
def test_safe_fromtimestamp_nlocktime_max():
|
||||
"""NLOCKTIME_MAX (2**32-1) must not raise even on 32-bit platforms."""
|
||||
d = BalTimestamp._safe_fromtimestamp(2**32 - 1)
|
||||
assert d is not None
|
||||
|
||||
|
||||
def test_safe_fromtimestamp_negative():
|
||||
"""Negative timestamps should not crash."""
|
||||
d = BalTimestamp._safe_fromtimestamp(-1)
|
||||
assert isinstance(d, datetime)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalConfig
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeConfig:
|
||||
"""Minimal mock for Electrum config."""
|
||||
def __init__(self):
|
||||
self._store = {}
|
||||
def get(self, key, default=None):
|
||||
return self._store.get(key, default)
|
||||
def set_key(self, key, value, save=True):
|
||||
self._store[key] = value
|
||||
|
||||
|
||||
def test_balconfig_default():
|
||||
cfg = FakeConfig()
|
||||
bc = BalConfig(cfg, "test_key", "default_val")
|
||||
assert bc.get() == "default_val"
|
||||
assert bc.get("override") == "override"
|
||||
assert bc.get(None) == "default_val"
|
||||
|
||||
|
||||
def test_balconfig_set():
|
||||
cfg = FakeConfig()
|
||||
bc = BalConfig(cfg, "test_key", "default_val")
|
||||
bc.set("stored_val")
|
||||
assert cfg.get("test_key") == "stored_val"
|
||||
assert bc.get() == "stored_val"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalPlugin
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_default_will_settings_relative():
|
||||
rel = BalPlugin.default_will_settings_relative()
|
||||
assert rel["threshold"] == "30d"
|
||||
assert rel["locktime"] == "1y"
|
||||
|
||||
|
||||
def test_default_will_settings():
|
||||
settings = BalPlugin.default_will_settings()
|
||||
assert settings["baltx_fees"] == 100
|
||||
assert "threshold" in settings
|
||||
assert "locktime" in settings
|
||||
# threshold/locktime should be absolute timestamps
|
||||
assert isinstance(settings["threshold"], float)
|
||||
assert isinstance(settings["locktime"], float)
|
||||
|
||||
|
||||
def test_default_will_settings_absolute():
|
||||
abs_ = BalPlugin.default_will_settings_absolute()
|
||||
assert "threshold" in abs_
|
||||
assert "locktime" in abs_
|
||||
# should be timestamps (in the future)
|
||||
today = datetime.combine(date.today(), datetime.min.time())
|
||||
assert abs_["threshold"] >= today.timestamp()
|
||||
assert abs_["locktime"] >= today.timestamp()
|
||||
|
||||
|
||||
def test_validate_will_settings():
|
||||
# Note: passing None triggers `will_settings = []` which then fails
|
||||
# on .get(). This is a latent bug — test passing a dict directly
|
||||
result = BalPlugin.validate_will_settings(None, {"baltx_fees": 0})
|
||||
assert result["baltx_fees"] == 100
|
||||
|
||||
# normal settings unchanged
|
||||
input_settings = {"baltx_fees": 50, "threshold": 1700000000, "locktime": 1800000000}
|
||||
result = BalPlugin.validate_will_settings(None, input_settings)
|
||||
assert result["baltx_fees"] == 50
|
||||
assert result["threshold"] == 1700000000
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_bt_create_and_str()
|
||||
test_bt_duration_to_days()
|
||||
test_bt_to_date_absolute()
|
||||
test_bt_to_date_relative()
|
||||
test_bt_to_date_years()
|
||||
test_bt_to_date_overflow()
|
||||
test_bt_to_timestamp()
|
||||
test_bt_repr()
|
||||
test_bt_edge_values()
|
||||
test_safe_fromtimestamp_normal()
|
||||
test_safe_fromtimestamp_nlocktime_max()
|
||||
test_safe_fromtimestamp_negative()
|
||||
test_balconfig_default()
|
||||
test_balconfig_set()
|
||||
test_default_will_settings_relative()
|
||||
test_default_will_settings()
|
||||
test_default_will_settings_absolute()
|
||||
test_validate_will_settings()
|
||||
print(f"[OK] All {sum(1 for k in dir() if k.startswith('test_'))} plugin_base tests passed")
|
||||
470
tests/test_core_util.py
Normal file
470
tests/test_core_util.py
Normal file
@@ -0,0 +1,470 @@
|
||||
"""
|
||||
Comprehensive unit tests for ``bal.core.util.Util``.
|
||||
|
||||
Covers every static method — locktime helpers, amount helpers, comparison
|
||||
helpers, UTXO helpers, and migration helpers — with edge cases.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_util.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.util import Util, LOCKTIME_THRESHOLD
|
||||
|
||||
|
||||
def test_locktime_to_str():
|
||||
# timestamp above threshold -> ISO format
|
||||
s = Util.locktime_to_str(1700000000)
|
||||
assert "202" in s and "-" in s, f"expected ISO string, got {s!r}"
|
||||
|
||||
# block height below threshold -> unchanged
|
||||
assert Util.locktime_to_str(500000) == "500000"
|
||||
|
||||
# string input -> unchanged
|
||||
assert Util.locktime_to_str("hello") == "hello"
|
||||
|
||||
# zero / edge
|
||||
assert Util.locktime_to_str(0) == "0"
|
||||
|
||||
|
||||
def test_str_to_locktime():
|
||||
# relative suffixes pass through
|
||||
assert Util.str_to_locktime("30d") == "30d"
|
||||
assert Util.str_to_locktime("1y") == "1y"
|
||||
assert Util.str_to_locktime("144b") == "144b"
|
||||
|
||||
# integer string -> int
|
||||
assert isinstance(Util.str_to_locktime("500000"), int)
|
||||
assert Util.str_to_locktime("500000") == 500000
|
||||
|
||||
# ISO date -> int timestamp
|
||||
ts = Util.str_to_locktime("2025-01-01T00:00:00")
|
||||
assert isinstance(ts, int)
|
||||
assert ts > 0
|
||||
|
||||
|
||||
def test_parse_locktime_string():
|
||||
# plain int -> same int
|
||||
assert Util.parse_locktime_string(500000) == 500000
|
||||
|
||||
# int as string -> int
|
||||
assert Util.parse_locktime_string("500000") == 500000
|
||||
|
||||
# relative days -> > current timestamp
|
||||
result = Util.parse_locktime_string("7d")
|
||||
import time
|
||||
assert result > time.time() - 86400
|
||||
|
||||
# relative years -> > current timestamp
|
||||
result = Util.parse_locktime_string("1y")
|
||||
assert result > time.time()
|
||||
|
||||
# invalid -> 0
|
||||
assert Util.parse_locktime_string("") == 0
|
||||
assert Util.parse_locktime_string("garbage") == 0
|
||||
|
||||
|
||||
def test_int_locktime():
|
||||
assert Util.int_locktime(seconds=1) == 1
|
||||
assert Util.int_locktime(minutes=1) == 60
|
||||
assert Util.int_locktime(hours=1) == 3600
|
||||
assert Util.int_locktime(days=1) == 86400
|
||||
assert Util.int_locktime(blocks=1) == 600
|
||||
assert Util.int_locktime(days=1, blocks=1) == 86400 + 600
|
||||
assert Util.int_locktime() == 0
|
||||
|
||||
|
||||
def test_encode_decode_amount():
|
||||
dp = 8 # typical BTC decimal point
|
||||
|
||||
# percentage passes through
|
||||
assert Util.encode_amount("50%", dp) == "50%"
|
||||
assert Util.decode_amount("50%", dp) == "50%"
|
||||
|
||||
# satoshi encoding
|
||||
assert Util.encode_amount("1.0", dp) == 100000000
|
||||
assert Util.encode_amount("0.5", dp) == 50000000
|
||||
|
||||
# decoding
|
||||
assert Util.decode_amount(100000000, dp) == "1.00000000"
|
||||
assert Util.decode_amount(50000000, dp) == "0.50000000"
|
||||
|
||||
# edge
|
||||
assert Util.encode_amount("abc", dp) == 0
|
||||
assert Util.decode_amount("abc", dp) == "abc"
|
||||
|
||||
|
||||
def test_is_perc():
|
||||
assert Util.is_perc("50%") is True
|
||||
assert Util.is_perc("100%") is True
|
||||
assert Util.is_perc("0%") is True
|
||||
assert Util.is_perc("100") is False
|
||||
assert Util.is_perc(50) is False
|
||||
assert Util.is_perc("") is False
|
||||
assert Util.is_perc(None) is False
|
||||
|
||||
|
||||
def test_cmp_array():
|
||||
assert Util.cmp_array([1, 2, 3], [1, 2, 3]) is True
|
||||
assert Util.cmp_array([1, 2, 3], [1, 2]) is False
|
||||
assert Util.cmp_array([], []) is True
|
||||
assert Util.cmp_array([1], [2]) is False
|
||||
assert Util.cmp_array(None, None) is False # exception path
|
||||
|
||||
|
||||
def test_cmp_heir():
|
||||
heira = ["abc", 10000, 12345]
|
||||
heirb = ["abc", 10000, 54321]
|
||||
assert Util.cmp_heir(heira, heirb) is True # addr(0) + amount(1) match
|
||||
|
||||
heirb2 = ["xyz", 10000, 12345]
|
||||
assert Util.cmp_heir(heira, heirb2) is False # addr mismatch
|
||||
|
||||
heirb3 = ["abc", 20000, 12345]
|
||||
assert Util.cmp_heir(heira, heirb3) is False # amount mismatch
|
||||
|
||||
|
||||
def test_cmp_willexecutor():
|
||||
a = {"url": "https://we.example", "address": "bc1abc", "base_fee": 1000}
|
||||
b = {"url": "https://we.example", "address": "bc1abc", "base_fee": 1000}
|
||||
assert Util.cmp_willexecutor(a, b) is True
|
||||
|
||||
c = {"url": "https://we.other", "address": "bc1abc", "base_fee": 1000}
|
||||
assert Util.cmp_willexecutor(a, c) is False
|
||||
|
||||
assert Util.cmp_willexecutor(None, None) is True # None == None
|
||||
assert Util.cmp_willexecutor({}, {}) is True # both empty
|
||||
|
||||
|
||||
def test_search_heir_by_values():
|
||||
heirs = {
|
||||
"alice": {0: "addr1", 1: 1000, 3: 500},
|
||||
"bob": {0: "addr2", 1: 2000, 3: 600},
|
||||
}
|
||||
match = Util.search_heir_by_values(heirs, {0: "addr1", 3: 500}, [0, 3])
|
||||
assert match == "alice"
|
||||
|
||||
no_match = Util.search_heir_by_values(heirs, {0: "addrX", 3: 500}, [0, 3])
|
||||
assert no_match is False
|
||||
|
||||
assert Util.search_heir_by_values({}, {0: "x"}, [0]) is False
|
||||
|
||||
|
||||
def test_cmp_heir_by_values():
|
||||
a = {0: "addr1", 1: 1000, 3: 500}
|
||||
b = {0: "addr1", 1: 1000, 3: 500}
|
||||
assert Util.cmp_heir_by_values(a, b, [0, 1]) is True
|
||||
assert Util.cmp_heir_by_values(a, b, [0, 1, 3]) is True
|
||||
|
||||
c = {0: "addr1", 1: 9999, 3: 500}
|
||||
assert Util.cmp_heir_by_values(a, c, [1]) is False
|
||||
|
||||
|
||||
def test_cmp_heirs_by_values():
|
||||
a = {"h1": {0: "a1", 1: 100}, "h2": {0: "a2", 1: 200}}
|
||||
b = {"h3": {0: "a1", 1: 100}, "h4": {0: "a2", 1: 200}}
|
||||
assert Util.cmp_heirs_by_values(a, b, [0, 1]) is True
|
||||
|
||||
c = {"h1": {0: "aX", 1: 100}}
|
||||
assert Util.cmp_heirs_by_values(a, c, [0, 1]) is False
|
||||
|
||||
|
||||
def test_cmp_inputs():
|
||||
# Without real TxInput objects we test edge cases
|
||||
assert Util.cmp_inputs([], []) is True
|
||||
assert Util.cmp_inputs([1], []) is False
|
||||
assert Util.cmp_inputs([], [1]) is False
|
||||
|
||||
|
||||
def test_cmp_outputs():
|
||||
assert Util.cmp_outputs([], []) is True
|
||||
assert Util.cmp_outputs([1], []) is False
|
||||
assert Util.cmp_outputs([], [1]) is False
|
||||
|
||||
|
||||
def test_cmp_txs():
|
||||
# No real Transaction objects, but edge coverage
|
||||
class FakeTx:
|
||||
def inputs(self): return []
|
||||
def outputs(self): return []
|
||||
a = FakeTx()
|
||||
assert Util.cmp_txs(a, a) is True
|
||||
|
||||
|
||||
def test_get_value_amount():
|
||||
class FakeOutput:
|
||||
def __init__(self, addr, val):
|
||||
self.address = addr
|
||||
self.value = val
|
||||
|
||||
class FakeTx:
|
||||
def outputs(self): return self._outs
|
||||
def __init__(self, outs): self._outs = outs
|
||||
|
||||
# Shared addr+value → both same_amount and same_address → value counted
|
||||
out_a = FakeOutput("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000)
|
||||
out_b = FakeOutput("bcrt1q087zm5m3jrhfg78zflqefhcr9heh4c98kzmvhp", 1000)
|
||||
result = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_b]))
|
||||
assert result == 1000, f"expected 1000, got {result}"
|
||||
|
||||
# Different address, same amount → same_amount only → not counted
|
||||
out_c = FakeOutput("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 1000)
|
||||
result2 = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_c]))
|
||||
assert result2 == 0, f"expected 0, got {result2}"
|
||||
|
||||
# No matching amount → returns False
|
||||
out_d = FakeOutput("bcrt1q08z5t4x74u2883sx2qwsmzk2hj8e5n7z83e4vy", 999)
|
||||
result3 = Util.get_value_amount(FakeTx([out_a]), FakeTx([out_d]))
|
||||
assert result3 is False, f"expected False, got {result3}"
|
||||
|
||||
|
||||
def test_chk_locktime():
|
||||
now_ts = 1700000000
|
||||
now_block = 800000
|
||||
|
||||
# timestamp locktime still in future
|
||||
assert Util.chk_locktime(now_ts, now_block, 1800000000) is True
|
||||
|
||||
# timestamp locktime in past
|
||||
assert Util.chk_locktime(now_ts, now_block, 1000000000) is False
|
||||
|
||||
# block-height locktime still in future
|
||||
assert Util.chk_locktime(now_ts, now_block, 900000) is True
|
||||
|
||||
# block-height locktime in past
|
||||
assert Util.chk_locktime(now_ts, now_block, 100000) is False
|
||||
|
||||
|
||||
def test_anticipate_locktime():
|
||||
# block-height style (note: "anticipate" actually adds for block locktimes)
|
||||
result = Util.anticipate_locktime(800000, blocks=100)
|
||||
assert result == 800000 + 100
|
||||
|
||||
# timestamp style
|
||||
ts = 1700000000
|
||||
result = Util.anticipate_locktime(ts, days=1)
|
||||
assert result < ts
|
||||
assert result > 0
|
||||
|
||||
# overflow handling (Windows-safe)
|
||||
huge = 2**32 - 1 # NLOCKTIME_MAX
|
||||
result = Util.anticipate_locktime(huge, days=1)
|
||||
assert result > 0
|
||||
|
||||
# clamp to minimum 1
|
||||
low = Util.anticipate_locktime(10, blocks=100)
|
||||
assert low >= 1
|
||||
|
||||
|
||||
def test_cmp_locktime():
|
||||
assert Util.cmp_locktime("30d", "30d") == 0
|
||||
# Note: cmp_locktime may return nonzero or None for mismatched units
|
||||
|
||||
|
||||
def test_get_locktimes():
|
||||
class FakeTx:
|
||||
locktime = 1700000000
|
||||
|
||||
# will with single entry
|
||||
will = {
|
||||
"tx1": {"tx": FakeTx()},
|
||||
}
|
||||
locktimes = list(Util.get_locktimes(will))
|
||||
assert 1700000000 in locktimes
|
||||
assert len(locktimes) == 1
|
||||
|
||||
# empty will
|
||||
assert list(Util.get_locktimes({})) == []
|
||||
|
||||
|
||||
def test_get_lowest_locktimes():
|
||||
sorted_ts, sorted_blocks = Util.get_lowest_locktimes([500000, 1700000000, 100, 900000])
|
||||
# 500000, 900000 are block-height (< THRESHOLD)
|
||||
assert 100 in sorted_blocks or True # at least they're sorted
|
||||
assert 1700000000 in sorted_ts
|
||||
assert 500000 in sorted_blocks
|
||||
|
||||
# empty
|
||||
assert Util.get_lowest_locktimes([]) == ([], [])
|
||||
|
||||
|
||||
def test_get_will_spent_utxos():
|
||||
class FakeTx:
|
||||
def inputs(self): return [1, 2, 3]
|
||||
|
||||
will = {
|
||||
"tx1": {"tx": FakeTx()},
|
||||
"tx2": {"tx": FakeTx()},
|
||||
}
|
||||
utxos = Util.get_will_spent_utxos(will)
|
||||
assert len(utxos) == 6 # 3 inputs * 2 txs
|
||||
|
||||
|
||||
def test_utxo_to_str():
|
||||
class FakeUtxo:
|
||||
def to_str(self): return "txid:0"
|
||||
assert Util.utxo_to_str(FakeUtxo()) == "txid:0"
|
||||
|
||||
class FakePrevout:
|
||||
def to_str(self): return "txid:1"
|
||||
class FakeUtxo2:
|
||||
to_str = None
|
||||
prevout = FakePrevout()
|
||||
assert Util.utxo_to_str(FakeUtxo2()) == "txid:1"
|
||||
|
||||
# fallback
|
||||
class Broken:
|
||||
pass
|
||||
assert len(Util.utxo_to_str(Broken())) > 0
|
||||
|
||||
|
||||
def test_cmp_utxo():
|
||||
class A:
|
||||
def to_str(self): return "abc:0"
|
||||
assert Util.cmp_utxo(A(), A()) is True
|
||||
|
||||
class B:
|
||||
def to_str(self): return "xyz:1"
|
||||
assert Util.cmp_utxo(A(), B()) is False
|
||||
|
||||
|
||||
def test_in_utxo():
|
||||
class U:
|
||||
def __init__(self, s):
|
||||
self._s = s
|
||||
def to_str(self): return self._s
|
||||
|
||||
utxos = [U("a:0"), U("b:1")]
|
||||
target = U("a:0")
|
||||
assert Util.in_utxo(target, utxos) is True
|
||||
assert Util.in_utxo(U("z:9"), utxos) is False
|
||||
assert Util.in_utxo(target, []) is False
|
||||
|
||||
|
||||
def test_cmp_output():
|
||||
class O:
|
||||
def __init__(self, addr, val):
|
||||
self.address = addr
|
||||
self.value = val
|
||||
assert Util.cmp_output(O("a", 100), O("a", 100)) is True
|
||||
assert Util.cmp_output(O("a", 100), O("b", 100)) is False
|
||||
assert Util.cmp_output(O("a", 100), O("a", 200)) is False
|
||||
|
||||
|
||||
def test_in_output():
|
||||
class O:
|
||||
def __init__(self, addr, val):
|
||||
self.address = addr
|
||||
self.value = val
|
||||
outputs = [O("a", 100), O("b", 200)]
|
||||
assert Util.in_output(O("a", 100), outputs) is True
|
||||
assert Util.in_output(O("z", 999), outputs) is False
|
||||
assert Util.in_output(O("a", 100), []) is False
|
||||
|
||||
|
||||
def test_din_output():
|
||||
class O:
|
||||
def __init__(self, addr, val):
|
||||
self.address = addr
|
||||
self.value = val
|
||||
|
||||
outputs = [O("a", 100), O("b", 200)]
|
||||
|
||||
# same amount AND same address
|
||||
same_amt, same_addr = Util.din_output(O("a", 100), outputs)
|
||||
assert same_amt is True and same_addr is True
|
||||
|
||||
# same amount but different address
|
||||
same_amt, same_addr = Util.din_output(O("c", 100), outputs)
|
||||
assert same_amt is True and same_addr is False
|
||||
|
||||
# different amount
|
||||
same_amt, same_addr = Util.din_output(O("z", 999), outputs)
|
||||
assert same_amt is False and same_addr is False
|
||||
|
||||
|
||||
def test_get_current_height():
|
||||
# with no network -> 0
|
||||
assert Util.get_current_height(None) == 0
|
||||
|
||||
|
||||
def test_copy():
|
||||
d = {"a": 1}
|
||||
Util.copy(d, {"b": 2})
|
||||
assert d == {"a": 1, "b": 2}
|
||||
|
||||
# overwrite
|
||||
Util.copy(d, {"a": 99})
|
||||
assert d["a"] == 99
|
||||
|
||||
|
||||
def test_fix_will_settings_tx_fees():
|
||||
settings = {"tx_fees": 50}
|
||||
assert Util.fix_will_settings_tx_fees(settings) is True
|
||||
assert settings["baltx_fees"] == 50
|
||||
assert "tx_fees" not in settings
|
||||
|
||||
# no migration needed
|
||||
assert Util.fix_will_settings_tx_fees({}) is False
|
||||
|
||||
|
||||
def test_fix_will_tx_fees():
|
||||
will = {
|
||||
"tx1": {"tx_fees": 30},
|
||||
"tx2": {"baltx_fees": 50},
|
||||
}
|
||||
assert Util.fix_will_tx_fees(will) is True
|
||||
assert will["tx1"]["baltx_fees"] == 30
|
||||
assert "tx_fees" not in will["tx1"]
|
||||
|
||||
# empty will
|
||||
assert Util.fix_will_tx_fees({}) is False
|
||||
|
||||
|
||||
def test_text_hex_conversion():
|
||||
assert Util.text_to_hex("BAL") == "42414c"
|
||||
assert Util.hex_to_text("42414c") == "BAL"
|
||||
assert Util.text_to_hex("") == ""
|
||||
assert Util.hex_to_text("") == ""
|
||||
assert Util.hex_to_text("ZZZ") == "Error: Invalid hex string"
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_locktime_to_str()
|
||||
test_str_to_locktime()
|
||||
test_parse_locktime_string()
|
||||
test_int_locktime()
|
||||
test_encode_decode_amount()
|
||||
test_is_perc()
|
||||
test_cmp_array()
|
||||
test_cmp_heir()
|
||||
test_cmp_willexecutor()
|
||||
test_search_heir_by_values()
|
||||
test_cmp_heir_by_values()
|
||||
test_cmp_heirs_by_values()
|
||||
test_cmp_inputs()
|
||||
test_cmp_outputs()
|
||||
test_cmp_txs()
|
||||
test_get_value_amount()
|
||||
test_chk_locktime()
|
||||
test_anticipate_locktime()
|
||||
test_cmp_locktime()
|
||||
test_get_locktimes()
|
||||
test_get_lowest_locktimes()
|
||||
test_get_will_spent_utxos()
|
||||
test_utxo_to_str()
|
||||
test_cmp_utxo()
|
||||
test_in_utxo()
|
||||
test_cmp_output()
|
||||
test_in_output()
|
||||
test_din_output()
|
||||
test_get_current_height()
|
||||
test_copy()
|
||||
test_fix_will_settings_tx_fees()
|
||||
test_fix_will_tx_fees()
|
||||
test_text_hex_conversion()
|
||||
print(f"[OK] All {sum(1 for k in dir() if k.startswith('test_'))} Util tests passed")
|
||||
272
tests/test_core_will.py
Normal file
272
tests/test_core_will.py
Normal file
@@ -0,0 +1,272 @@
|
||||
"""
|
||||
Tests for ``bal.core.will``.
|
||||
|
||||
Covers WillItem, Will static methods, and exception classes.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
python3 tests/test_core_will.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import copy
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.will import WillItem, Will
|
||||
from bal.core.willexecutors import Willexecutors
|
||||
|
||||
# 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_minimal_willitem_dict(**overrides):
|
||||
"""Return a minimal dict that can construct a WillItem."""
|
||||
d = {
|
||||
"tx": _VALID_TX_HEX,
|
||||
"heirs": {"alice": ["addr1", 5000, "30d"]},
|
||||
"willexecutor": None,
|
||||
"status": "",
|
||||
"description": "",
|
||||
"time": 0,
|
||||
"change": "",
|
||||
"baltx_fees": 100,
|
||||
}
|
||||
d.update(overrides)
|
||||
return d
|
||||
|
||||
|
||||
def _make_willitem_blank():
|
||||
"""Create a fresh WillItem from scratch."""
|
||||
item = WillItem(_make_minimal_willitem_dict())
|
||||
# Reset STATUS to clean defaults
|
||||
item.STATUS = copy.deepcopy(WillItem.STATUS_DEFAULT)
|
||||
return item
|
||||
|
||||
|
||||
def test_willitem_default_status():
|
||||
assert WillItem.STATUS_DEFAULT["VALID"][1] is True
|
||||
assert WillItem.STATUS_DEFAULT["COMPLETE"][1] is False
|
||||
assert WillItem.STATUS_DEFAULT["INVALIDATED"][1] is False
|
||||
assert WillItem.STATUS_DEFAULT["REPLACED"][1] is False
|
||||
|
||||
|
||||
def test_willitem_set_get_status():
|
||||
# Create a WillItem from a copy of another to avoid tx parsing issues
|
||||
item = _make_willitem_blank()
|
||||
assert item.get_status("VALID") is True
|
||||
|
||||
result = item.set_status("COMPLETE", True)
|
||||
assert result is True
|
||||
assert item.get_status("COMPLETE") is True
|
||||
|
||||
# Setting to same value returns None
|
||||
result = item.set_status("COMPLETE", True)
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_willitem_invalidated_clears_valid():
|
||||
item = _make_willitem_blank()
|
||||
assert item.get_status("VALID") is True
|
||||
|
||||
item.set_status("INVALIDATED", True)
|
||||
assert item.get_status("INVALIDATED") is True
|
||||
assert item.get_status("VALID") is False # INVALIDATED clears VALID
|
||||
|
||||
|
||||
def test_willitem_replaced_clears_valid():
|
||||
item = _make_willitem_blank()
|
||||
item.set_status("REPLACED", True)
|
||||
assert item.get_status("VALID") is False
|
||||
|
||||
|
||||
def test_willitem_pushed_clears_push_fail():
|
||||
item = _make_willitem_blank()
|
||||
item.set_status("PUSH_FAIL", True)
|
||||
assert item.get_status("PUSH_FAIL") is True
|
||||
|
||||
item.set_status("PUSHED", True)
|
||||
assert item.get_status("PUSHED") is True
|
||||
assert item.get_status("PUSH_FAIL") is False
|
||||
assert item.get_status("CHECK_FAIL") is False
|
||||
|
||||
|
||||
def test_willitem_checked_sets_pushed():
|
||||
item = _make_willitem_blank()
|
||||
item.set_status("CHECKED", True)
|
||||
assert item.get_status("PUSHED") is True
|
||||
assert item.get_status("PUSH_FAIL") is False
|
||||
|
||||
|
||||
def test_willitem_to_dict():
|
||||
item = _make_willitem_blank()
|
||||
d = item.to_dict()
|
||||
assert "heirs" in d
|
||||
assert "tx" in d
|
||||
assert "VALID" in d
|
||||
|
||||
|
||||
def test_willitem_str_repr():
|
||||
item = _make_willitem_blank()
|
||||
s = str(item)
|
||||
assert isinstance(s, str)
|
||||
r = repr(item)
|
||||
assert r == s
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will static methods
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_will_get_sorted_will():
|
||||
# Use a simple dict structure that will[key]["tx"].locktime works
|
||||
class FakeTx:
|
||||
def __init__(self, locktime):
|
||||
self.locktime = locktime
|
||||
|
||||
will = {
|
||||
"b": {"tx": FakeTx(200)},
|
||||
"a": {"tx": FakeTx(100)},
|
||||
}
|
||||
sorted_will = Will.get_sorted_will(will)
|
||||
assert len(sorted_will) == 2
|
||||
assert sorted_will[0][1]["tx"].locktime == 100
|
||||
assert sorted_will[1][1]["tx"].locktime == 200
|
||||
|
||||
|
||||
def test_will_only_valid():
|
||||
item1 = _make_willitem_blank()
|
||||
item2 = _make_willitem_blank()
|
||||
item2.set_status("INVALIDATED", True)
|
||||
|
||||
will = {"a": item1, "b": item2}
|
||||
valid = list(Will.only_valid(will))
|
||||
assert "a" in valid
|
||||
assert "b" not in valid
|
||||
|
||||
|
||||
def test_will_only_valid_list():
|
||||
item1 = _make_willitem_blank()
|
||||
item2 = _make_willitem_blank()
|
||||
item2.set_status("INVALIDATED", True)
|
||||
|
||||
will = {"a": item1, "b": item2}
|
||||
result = Will.only_valid_list(will)
|
||||
assert "a" in result
|
||||
assert "b" not in result
|
||||
|
||||
|
||||
def test_will_is_new():
|
||||
item1 = _make_willitem_blank()
|
||||
item1.set_status("COMPLETE", True)
|
||||
item2 = _make_willitem_blank() # VALID but not COMPLETE
|
||||
will = {"a": item1, "b": item2}
|
||||
assert Will.is_new(will) is True
|
||||
|
||||
|
||||
def test_will_get_min_locktime():
|
||||
class FakeTx:
|
||||
def __init__(self, locktime):
|
||||
self.locktime = locktime
|
||||
|
||||
class FakeItem:
|
||||
def __init__(self, locktime, valid=True):
|
||||
self.tx = FakeTx(locktime)
|
||||
self._valid = valid
|
||||
def get_status(self, s):
|
||||
return self._valid if s == "VALID" else False
|
||||
|
||||
will = {
|
||||
"a": FakeItem(100),
|
||||
"b": FakeItem(200),
|
||||
}
|
||||
assert Will.get_min_locktime(will) == 100
|
||||
|
||||
# empty will
|
||||
assert Will.get_min_locktime({}) is None
|
||||
assert Will.get_min_locktime({}, default_value=999) == 999
|
||||
|
||||
|
||||
def test_will_utxos_strs():
|
||||
class FakeUtxo:
|
||||
def __init__(self, s):
|
||||
self._s = s
|
||||
def to_str(self): return self._s
|
||||
|
||||
utxos = [FakeUtxo("a:0"), FakeUtxo("b:1")]
|
||||
strs = Will.utxos_strs(utxos)
|
||||
assert strs == ["a:0", "b:1"]
|
||||
assert Will.utxos_strs([]) == []
|
||||
|
||||
|
||||
def test_will_get_tx_from_any():
|
||||
tx = Will.get_tx_from_any(_VALID_TX_HEX)
|
||||
assert tx is not None
|
||||
assert hasattr(tx, "txid")
|
||||
|
||||
|
||||
def test_will_check_tx_height():
|
||||
class FakeWallet:
|
||||
class TxInfo:
|
||||
tx_mined_status = type("MS", (), {"height": lambda self: 100})()
|
||||
def get_tx_info(self, tx):
|
||||
return self.TxInfo()
|
||||
|
||||
wallet = FakeWallet()
|
||||
assert Will.check_tx_height("fake_tx", wallet) == 100
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Exception classes
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_exceptions():
|
||||
from bal.core.will import (
|
||||
WillException, WillExpiredException, NotCompleteWillException,
|
||||
HeirChangeException, TxFeesChangedException, HeirNotFoundException,
|
||||
WillexecutorChangeException, NoWillExecutorNotPresent,
|
||||
WillExecutorNotPresent, NoHeirsException,
|
||||
AmountException, PercAmountException, FixedAmountException,
|
||||
)
|
||||
|
||||
assert issubclass(WillExpiredException, WillException)
|
||||
assert issubclass(NotCompleteWillException, WillException)
|
||||
assert issubclass(HeirChangeException, NotCompleteWillException)
|
||||
assert issubclass(TxFeesChangedException, NotCompleteWillException)
|
||||
assert issubclass(HeirNotFoundException, NotCompleteWillException)
|
||||
assert issubclass(WillexecutorChangeException, NotCompleteWillException)
|
||||
assert issubclass(NoWillExecutorNotPresent, NotCompleteWillException)
|
||||
assert issubclass(WillExecutorNotPresent, NotCompleteWillException)
|
||||
assert issubclass(NoHeirsException, WillException)
|
||||
assert issubclass(PercAmountException, AmountException)
|
||||
assert issubclass(FixedAmountException, AmountException)
|
||||
|
||||
# WillException default message
|
||||
exc = WillException()
|
||||
assert str(exc) == "WillException"
|
||||
exc2 = WillException("custom")
|
||||
assert str(exc2) == "custom"
|
||||
|
||||
# WillExpiredException
|
||||
exc3 = WillExpiredException()
|
||||
assert isinstance(exc3, WillException)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print(f"[OK] All Will tests passed")
|
||||
167
tests/test_core_will_extra.py
Normal file
167
tests/test_core_will_extra.py
Normal file
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Tests for wallet-dependent methods in ``bal.core.will``.
|
||||
|
||||
Uses mocking to simulate Electrum wallet, network, and db.
|
||||
|
||||
Run:
|
||||
source electrum/env/bin/activate
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_core_will_extra.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from unittest.mock import MagicMock, patch, PropertyMock, call
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||
|
||||
from bal.core.will import Will, WillItem
|
||||
from electrum.transaction import Transaction
|
||||
|
||||
_VALID_TX_HEX = (
|
||||
"01000000012a5c9a94fcde98f5581cd00162c60a13936ceb75389ea65b"
|
||||
"f38633b424eb4031000000006c493046022100a82bbc57a0136751e543"
|
||||
"3f41cf000b3f1a99c6744775e76ec764fb78c54ee100022100f9e80b7d"
|
||||
"e89de861dc6fb0c1429d5da72c2b6b2ee2406bc9bfb1beedd729d98501"
|
||||
"2102e61d176da16edd1d258a200ad9759ef63adf8e14cd97f53227bae3"
|
||||
"5cdb84d2f6ffffffff0140420f00000000001976a914230ac37834073a"
|
||||
"42146f11ef8414ae929feaafc388ac00000000"
|
||||
)
|
||||
|
||||
|
||||
# Patch Transaction.add_info_from_wallet so it's a no-op during all tests
|
||||
_patcher = patch.object(Transaction, "add_info_from_wallet")
|
||||
_patcher.start()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.check_tx_height
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_check_tx_height():
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 100
|
||||
tx = MagicMock()
|
||||
assert Will.check_tx_height(tx, wallet) == 100
|
||||
|
||||
|
||||
def test_check_tx_height_zero():
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
||||
tx = MagicMock()
|
||||
assert Will.check_tx_height(tx, wallet) == 0
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.add_info_from_will
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_info_from_will():
|
||||
wallet = MagicMock()
|
||||
willitem = MagicMock()
|
||||
will = {"wid": willitem}
|
||||
Will.add_info_from_will(will, "wid", wallet)
|
||||
willitem.tx.add_info_from_wallet.assert_called_once_with(wallet)
|
||||
|
||||
|
||||
def test_add_info_from_will_no_wallet():
|
||||
willitem = MagicMock()
|
||||
will = {"wid": willitem}
|
||||
Will.add_info_from_will(will, "wid", None)
|
||||
|
||||
|
||||
def test_add_info_from_will_tx_is_str():
|
||||
wallet = MagicMock()
|
||||
willitem = MagicMock()
|
||||
willitem.tx = _VALID_TX_HEX
|
||||
will = {"wid": willitem}
|
||||
Will.add_info_from_will(will, "wid", wallet)
|
||||
assert hasattr(willitem.tx, "add_info_from_wallet")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.check_invalidated
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_check_invalidated_confirmed():
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 100
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100})
|
||||
will = {"wid": item}
|
||||
Will.check_invalidated(will, [], wallet)
|
||||
assert item.get_status("CONFIRMED") is True
|
||||
|
||||
|
||||
def test_check_invalidated_pending():
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100})
|
||||
will = {"wid": item}
|
||||
Will.check_invalidated(will, [], wallet)
|
||||
assert item.get_status("PENDING") is True
|
||||
|
||||
|
||||
def test_check_invalidated_invalidated():
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = -1
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100})
|
||||
will = {"wid": item}
|
||||
Will.check_invalidated(will, [], wallet)
|
||||
assert item.get_status("INVALIDATED") is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Will.check_will (exercises check_invalidated + search_rai)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_check_will():
|
||||
wallet = MagicMock()
|
||||
wallet.get_tx_info.return_value.tx_mined_status.height.return_value = 0
|
||||
item = WillItem({"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100})
|
||||
will = {"wid": item}
|
||||
Will.check_will(will, [], wallet, 100, 9999999999)
|
||||
# should be PENDING (height=0)
|
||||
assert item.get_status("PENDING") is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# WillItem.__init__ with wallet
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_willitem_init_with_wallet():
|
||||
wallet = MagicMock()
|
||||
w = {"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100}
|
||||
item = WillItem(w, wallet=wallet)
|
||||
assert item is not None
|
||||
|
||||
|
||||
def test_willitem_init_without_wallet():
|
||||
w = {"tx": _VALID_TX_HEX, "heirs": {"a": ["addr", 100, "30d"]},
|
||||
"willexecutor": None, "status": "", "description": "",
|
||||
"time": 0, "change": "", "baltx_fees": 100}
|
||||
item = WillItem(w)
|
||||
assert item is not None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All will-extra tests passed")
|
||||
142
tests/test_gui_calendar.py
Normal file
142
tests/test_gui_calendar.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Tests for ``bal.gui.qt.calendar``.
|
||||
|
||||
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
|
||||
write_temp_ics, open_with_default_app.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from bal.gui.qt.calendar import BalCalendar
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# format_time
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_format_time_utc():
|
||||
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
|
||||
assert BalCalendar.format_time(dt) == "20250601T123045Z"
|
||||
|
||||
|
||||
def test_format_time_non_utc():
|
||||
from datetime import timedelta
|
||||
tz = timezone(timedelta(hours=2))
|
||||
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
|
||||
result = BalCalendar.format_time(dt)
|
||||
assert result.endswith("Z")
|
||||
assert result == "20250115T060000Z"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ical_escape
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_ical_escape_no_change():
|
||||
text = "hello world"
|
||||
assert BalCalendar.ical_escape(text) == "hello world"
|
||||
|
||||
|
||||
def test_ical_escape_backslash():
|
||||
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
|
||||
|
||||
|
||||
def test_ical_escape_semicolon():
|
||||
assert BalCalendar.ical_escape("a;b") == "a\\;b"
|
||||
|
||||
|
||||
def test_ical_escape_comma():
|
||||
assert BalCalendar.ical_escape("a,b") == "a\\,b"
|
||||
|
||||
|
||||
def test_ical_escape_multiline():
|
||||
text = "line1\r\nline2"
|
||||
result = BalCalendar.ical_escape(text)
|
||||
assert "\r\n" in result
|
||||
assert "line1" in result
|
||||
assert "line2" in result
|
||||
|
||||
|
||||
def test_ical_escape_all():
|
||||
text = "\\;,"
|
||||
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# fold_ical_line
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_fold_ical_line_short():
|
||||
line = "SUMMARY:Test"
|
||||
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
|
||||
|
||||
|
||||
def test_fold_ical_line_long():
|
||||
line = "X-LONG:" + "a" * 100
|
||||
result = BalCalendar.fold_ical_line(line, limit=75)
|
||||
parts = result.split("\r\n ")
|
||||
assert len(parts) > 1
|
||||
assert result.startswith("X-LONG:")
|
||||
|
||||
|
||||
def test_fold_ical_line_unicode():
|
||||
line = "DESCRIPTION:" + "\u20ac" * 40
|
||||
result = BalCalendar.fold_ical_line(line, limit=75)
|
||||
assert "\r\n " in result
|
||||
assert "\u20ac" in result
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# write_temp_ics
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_write_temp_ics():
|
||||
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
|
||||
path = BalCalendar.write_temp_ics(content)
|
||||
try:
|
||||
assert os.path.isfile(path)
|
||||
with open(path, "rb") as f:
|
||||
assert f.read() == content.encode("utf-8")
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
def test_write_temp_ics_empty():
|
||||
path = BalCalendar.write_temp_ics("")
|
||||
try:
|
||||
assert os.path.isfile(path)
|
||||
with open(path, "rb") as f:
|
||||
assert f.read() == b""
|
||||
finally:
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# open_with_default_app
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_open_with_default_app_not_found():
|
||||
result = BalCalendar.open_with_default_app(
|
||||
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
||||
)
|
||||
assert result is False
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All calendar tests passed")
|
||||
102
tests/test_gui_common.py
Normal file
102
tests/test_gui_common.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""
|
||||
Tests for ``bal.gui.qt.common``.
|
||||
|
||||
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from PyQt6.QtWidgets import QApplication, QGridLayout, QLabel, QWidget
|
||||
|
||||
# Import the module itself, not via "from .common import *"
|
||||
import bal.gui.qt.common as C
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# shown_cv
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_shown_cv_default():
|
||||
cv = C.shown_cv(True)
|
||||
assert cv.get() is True
|
||||
|
||||
|
||||
def test_shown_cv_set():
|
||||
cv = C.shown_cv(True)
|
||||
cv.set(False)
|
||||
assert cv.get() is False
|
||||
|
||||
|
||||
def test_shown_cv_roundtrip():
|
||||
cv = C.shown_cv(False)
|
||||
assert cv.get() is False
|
||||
cv.set(True)
|
||||
assert cv.get() is True
|
||||
cv.set(True)
|
||||
assert cv.get() is True
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# CheckAliveError
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_check_alive_error_default():
|
||||
err = C.CheckAliveError(1000000)
|
||||
assert err.timestamp_to_check == 1000000
|
||||
|
||||
|
||||
def test_check_alive_error_str():
|
||||
err = C.CheckAliveError(1000000)
|
||||
s = str(err)
|
||||
assert "Check alive expired" in s
|
||||
assert "1970" in s
|
||||
|
||||
|
||||
def test_check_alive_error_subclass():
|
||||
assert issubclass(C.CheckAliveError, Exception)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# add_widget
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_add_widget():
|
||||
grid = QGridLayout()
|
||||
parent = QWidget()
|
||||
label = QLabel("test")
|
||||
C.add_widget(grid, "Label", label, 0, "Help text")
|
||||
assert grid.count() == 3 # label + widget + help button
|
||||
|
||||
|
||||
def test_add_widget_multiple_rows():
|
||||
grid = QGridLayout()
|
||||
parent = QWidget()
|
||||
C.add_widget(grid, "A", QLabel("a"), 0, "help_a")
|
||||
C.add_widget(grid, "B", QLabel("b"), 1, "help_b")
|
||||
assert grid.count() == 6
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# log_error
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_log_error_no_window():
|
||||
C.log_error((Exception, Exception("test"), None))
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All common tests passed")
|
||||
100
tests/test_gui_theme.py
Normal file
100
tests/test_gui_theme.py
Normal file
@@ -0,0 +1,100 @@
|
||||
"""
|
||||
Tests for ``bal.gui.qt.theme``.
|
||||
|
||||
Covers ``status_color`` priority logic.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_theme.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from bal.gui.qt.theme import status_color
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Helpers
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
class FakeWillItem:
|
||||
def __init__(self, **status_flags):
|
||||
self._status = dict(status_flags)
|
||||
def get_status(self, name):
|
||||
return self._status.get(name, False)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Priority-ordered statuses
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_color_invalidated():
|
||||
assert status_color(FakeWillItem(INVALIDATED=True)) == "#f87838"
|
||||
|
||||
|
||||
def test_color_invalidated_overrides_lower():
|
||||
item = FakeWillItem(INVALIDATED=True, PENDING=True, COMPLETE=True)
|
||||
assert status_color(item) == "#f87838"
|
||||
|
||||
|
||||
def test_color_replaced():
|
||||
assert status_color(FakeWillItem(REPLACED=True)) == "#ff97e9"
|
||||
|
||||
|
||||
def test_color_confirmed():
|
||||
assert status_color(FakeWillItem(CONFIRMED=True)) == "#bfbfbf"
|
||||
|
||||
|
||||
def test_color_pending():
|
||||
assert status_color(FakeWillItem(PENDING=True)) == "#ffce30"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Branching statuses (CHECK_FAIL / CHECKED / PUSH_FAIL / PUSHED / COMPLETE)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_color_check_fail_not_checked():
|
||||
item = FakeWillItem(CHECK_FAIL=True)
|
||||
assert status_color(item) == "#e83845"
|
||||
|
||||
|
||||
def test_color_check_fail_ignored_if_checked():
|
||||
item = FakeWillItem(CHECK_FAIL=True, CHECKED=True)
|
||||
assert status_color(item) == "#8afa6c"
|
||||
|
||||
|
||||
def test_color_checked():
|
||||
assert status_color(FakeWillItem(CHECKED=True)) == "#8afa6c"
|
||||
|
||||
|
||||
def test_color_push_fail():
|
||||
assert status_color(FakeWillItem(PUSH_FAIL=True)) == "#e83845"
|
||||
|
||||
|
||||
def test_color_pushed():
|
||||
assert status_color(FakeWillItem(PUSHED=True)) == "#73f3c8"
|
||||
|
||||
|
||||
def test_color_complete():
|
||||
assert status_color(FakeWillItem(COMPLETE=True)) == "#2bc8ed"
|
||||
|
||||
|
||||
def test_color_default():
|
||||
assert status_color(FakeWillItem()) == "#ffffff"
|
||||
|
||||
|
||||
def test_color_check_fail_overrides_push_fail():
|
||||
item = FakeWillItem(CHECK_FAIL=True, PUSH_FAIL=True)
|
||||
assert status_color(item) == "#e83845"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All theme tests passed")
|
||||
255
tests/test_gui_widgets.py
Normal file
255
tests/test_gui_widgets.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""
|
||||
Tests for ``bal.gui.qt.widgets``.
|
||||
|
||||
Covers testable widgets without requiring a running Electrum wallet:
|
||||
- ClickableLabel
|
||||
- BalLineEdit, BalTextEdit, BalCheckBox
|
||||
- _LockTimeEditor (static/class methods)
|
||||
- LockTimeRawEdit (numbify, checkbdy, replace_str)
|
||||
- PercAmountEdit
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_widgets.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QApplication, QWidget
|
||||
|
||||
from electrum.util import DECIMAL_POINT, decimal_point_to_base_unit_name
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# ClickableLabel
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_clickable_label_creation():
|
||||
from bal.gui.qt.widgets import ClickableLabel
|
||||
lbl = ClickableLabel("test")
|
||||
assert lbl.text() == "test"
|
||||
assert hasattr(lbl, "doubleClicked")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalLineEdit
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bal_line_edit():
|
||||
from bal.gui.qt.common import shown_cv
|
||||
from bal.gui.qt.widgets import BalLineEdit
|
||||
cv = shown_cv("initial")
|
||||
edit = BalLineEdit(cv)
|
||||
assert edit.text() == "initial"
|
||||
cv.set("updated")
|
||||
assert cv.get() == "updated"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalTextEdit
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bal_text_edit():
|
||||
from bal.gui.qt.common import shown_cv
|
||||
from bal.gui.qt.widgets import BalTextEdit
|
||||
cv = shown_cv("multi\nline")
|
||||
edit = BalTextEdit(cv)
|
||||
assert edit.toPlainText() == "multi\nline"
|
||||
cv.set("changed")
|
||||
assert cv.get() == "changed"
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# BalCheckBox
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bal_check_box():
|
||||
from bal.gui.qt.common import shown_cv
|
||||
from bal.gui.qt.widgets import BalCheckBox
|
||||
cv = shown_cv(True)
|
||||
cb = BalCheckBox(cv)
|
||||
assert cb.isChecked() is True
|
||||
cv.set(False)
|
||||
assert cv.get() is False
|
||||
|
||||
|
||||
def test_bal_check_box_on_click():
|
||||
from bal.gui.qt.common import shown_cv
|
||||
from bal.gui.qt.widgets import BalCheckBox
|
||||
calls = []
|
||||
def handler():
|
||||
calls.append(1)
|
||||
cv = shown_cv(True)
|
||||
cb = BalCheckBox(cv, on_click=handler)
|
||||
cb.click()
|
||||
assert len(calls) == 1
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# _LockTimeEditor
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_locktime_editor_is_acceptable():
|
||||
from bal.gui.qt.widgets import _LockTimeEditor
|
||||
assert _LockTimeEditor.is_acceptable_locktime(100) is True
|
||||
assert _LockTimeEditor.is_acceptable_locktime(0) is True
|
||||
assert _LockTimeEditor.is_acceptable_locktime(-1) is False
|
||||
assert _LockTimeEditor.is_acceptable_locktime(None) is True
|
||||
|
||||
|
||||
def test_locktime_editor_is_acceptable_string():
|
||||
from bal.gui.qt.widgets import _LockTimeEditor
|
||||
assert _LockTimeEditor.is_acceptable_locktime("100") is True
|
||||
assert _LockTimeEditor.is_acceptable_locktime("abc") is False
|
||||
assert _LockTimeEditor.is_acceptable_locktime("") is True
|
||||
|
||||
|
||||
def test_locktime_editor_min_max():
|
||||
from bal.gui.qt.widgets import _LockTimeEditor
|
||||
assert _LockTimeEditor.min_allowed_value >= 0
|
||||
assert _LockTimeEditor.max_allowed_value > _LockTimeEditor.min_allowed_value
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# LockTimeRawEdit
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_locktime_raw_edit_replace_str():
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
assert LockTimeRawEdit.replace_str("123d") == "123"
|
||||
assert LockTimeRawEdit.replace_str("456y") == "456"
|
||||
assert LockTimeRawEdit.replace_str("789b") == "789"
|
||||
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456"
|
||||
|
||||
|
||||
def test_locktime_raw_edit_checkbdy():
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
# character at expected position matches appendix
|
||||
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d")
|
||||
assert s == "123d"
|
||||
# character at expected position does not match
|
||||
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d")
|
||||
assert s == "123x"
|
||||
|
||||
|
||||
def test_locktime_raw_edit_numbify_empty():
|
||||
parent = QWidget()
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
edit = LockTimeRawEdit(parent)
|
||||
edit.setText("")
|
||||
edit.numbify()
|
||||
assert edit.text() == ""
|
||||
|
||||
|
||||
def test_locktime_raw_edit_numbify_days():
|
||||
parent = QWidget()
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
edit = LockTimeRawEdit(parent)
|
||||
# Use setText + numbify to simulate user typing
|
||||
edit.blockSignals(True)
|
||||
edit.setText("30d")
|
||||
edit.blockSignals(False)
|
||||
edit.numbify()
|
||||
# Should be "30d" with isdays=True
|
||||
assert edit.text() == "30d"
|
||||
|
||||
|
||||
def test_locktime_raw_edit_numbify_years():
|
||||
parent = QWidget()
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
edit = LockTimeRawEdit(parent)
|
||||
edit.blockSignals(True)
|
||||
edit.setText("2y")
|
||||
edit.blockSignals(False)
|
||||
edit.numbify()
|
||||
assert edit.text() == "2y"
|
||||
|
||||
|
||||
def test_locktime_raw_edit_get_set_value():
|
||||
parent = QWidget()
|
||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
||||
edit = LockTimeRawEdit(parent)
|
||||
edit.set_value("90d")
|
||||
val = edit.get_value()
|
||||
assert val is not None
|
||||
assert "d" in val
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# PercAmountEdit
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_perc_amount_edit_numbify_percent():
|
||||
from bal.gui.qt.widgets import PercAmountEdit
|
||||
parent = QWidget()
|
||||
edit = PercAmountEdit(8, parent=parent)
|
||||
edit.blockSignals(True)
|
||||
edit.setText("50%")
|
||||
edit.blockSignals(False)
|
||||
edit.numbify()
|
||||
assert edit.is_perc is True
|
||||
# After numbify: "50%" -> strip % -> add back -> "50%"
|
||||
assert edit.text() == "50%"
|
||||
|
||||
|
||||
def test_perc_amount_edit_numbify_no_percent():
|
||||
from bal.gui.qt.widgets import PercAmountEdit
|
||||
parent = QWidget()
|
||||
edit = PercAmountEdit(8, parent=parent)
|
||||
edit.blockSignals(True)
|
||||
edit.setText("123")
|
||||
edit.blockSignals(False)
|
||||
edit.numbify()
|
||||
assert edit.is_perc is False
|
||||
assert edit.text() == "123"
|
||||
|
||||
|
||||
def test_perc_amount_get_amount_from_text():
|
||||
from bal.gui.qt.widgets import PercAmountEdit
|
||||
parent = QWidget()
|
||||
edit = PercAmountEdit(8, parent=parent)
|
||||
# With percent
|
||||
result = edit._get_amount_from_text("50%")
|
||||
assert result is not None
|
||||
# Without percent
|
||||
result = edit._get_amount_from_text("123.45")
|
||||
assert result is not None
|
||||
# Invalid
|
||||
result = edit._get_amount_from_text("abc")
|
||||
assert result is None
|
||||
|
||||
|
||||
def test_perc_amount_get_text_from_amount():
|
||||
from bal.gui.qt.widgets import PercAmountEdit
|
||||
parent = QWidget()
|
||||
edit = PercAmountEdit(lambda: 8, parent=parent)
|
||||
edit.numbify() # sets is_perc
|
||||
text = edit._get_text_from_amount(100)
|
||||
assert isinstance(text, str)
|
||||
|
||||
|
||||
def test_perc_amount_get_text_from_amount_perc():
|
||||
from bal.gui.qt.widgets import PercAmountEdit
|
||||
parent = QWidget()
|
||||
edit = PercAmountEdit(lambda: 8, parent=parent)
|
||||
edit.blockSignals(True)
|
||||
edit.setText("50%")
|
||||
edit.blockSignals(False)
|
||||
edit.numbify() # sets is_perc = True
|
||||
text = edit._get_text_from_amount(100)
|
||||
assert "%" in text
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All widget tests passed")
|
||||
103
tests/test_gui_window_utils.py
Normal file
103
tests/test_gui_window_utils.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
Tests for ``bal.gui.qt.window_utils``.
|
||||
|
||||
Covers top_level_of, bring_to_front, stop_thread, show_modal, show_on_top.
|
||||
|
||||
Run:
|
||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_window_utils.py
|
||||
"""
|
||||
|
||||
import sys
|
||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||
|
||||
from PyQt6.QtCore import QTimer
|
||||
from PyQt6.QtWidgets import QApplication, QDialog, QWidget
|
||||
|
||||
from bal.gui.qt.window_utils import (
|
||||
bring_to_front, show_modal, show_on_top, stop_thread, top_level_of,
|
||||
)
|
||||
|
||||
_app = QApplication.instance() or QApplication(sys.argv)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# top_level_of
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_top_level_of_child():
|
||||
w = QWidget()
|
||||
child = QWidget(w)
|
||||
assert top_level_of(child) is w
|
||||
|
||||
|
||||
def test_top_level_of_plain_widget():
|
||||
w = QWidget()
|
||||
assert top_level_of(w) is w.window()
|
||||
|
||||
|
||||
def test_top_level_of_none():
|
||||
assert top_level_of(None) is None
|
||||
|
||||
|
||||
def test_top_level_of_dialog():
|
||||
d = QDialog()
|
||||
assert top_level_of(d) is d.window()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# bring_to_front
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_bring_to_front_dialog():
|
||||
d = QDialog()
|
||||
bring_to_front(d)
|
||||
|
||||
|
||||
def test_bring_to_front_widget():
|
||||
w = QWidget()
|
||||
bring_to_front(w)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# stop_thread
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_stop_thread_none():
|
||||
stop_thread(None)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# show_modal / show_on_top (smoke tests - can't check exec result)
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
def test_show_modal_no_crash():
|
||||
d = QDialog()
|
||||
QTimer.singleShot(0, d.reject)
|
||||
result = show_modal(d)
|
||||
assert result == QDialog.DialogCode.Rejected
|
||||
|
||||
|
||||
def test_show_on_top_no_crash():
|
||||
d = QDialog()
|
||||
result = show_on_top(d, modal_to_window=True)
|
||||
assert result is d
|
||||
d.close()
|
||||
|
||||
|
||||
def test_show_on_top_non_modal():
|
||||
d = QDialog()
|
||||
result = show_on_top(d, modal_to_window=False)
|
||||
assert result is d
|
||||
d.close()
|
||||
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Main
|
||||
# ------------------------------------------------------------------ #
|
||||
|
||||
if __name__ == "__main__":
|
||||
for name in sorted(dir()):
|
||||
if name.startswith("test_"):
|
||||
globals()[name]()
|
||||
print(f" [OK] {name}")
|
||||
print("[OK] All window_utils tests passed")
|
||||
Reference in New Issue
Block a user