Will list: context menu actions and will-executor bulk tools
PreviewList: right-click menu with sign / details / broadcast / check / copy id / copy / merge from txn / delete, with multi-selection support and availability rules (broadcast force-repushes, delete restricted to invalid or unsigned txs, sign skips non-valid txs). WillExecutorWidget: Export, Ping All and Select All become dropdown buttons (export all/selected/valid, ping all/selected, select all/only valid, deselect all/only invalid). WillExecutorListWidget: single-cell Copy action. BalWindow: txids-scoped sign/broadcast plumbing and merge_single_transaction.
This commit is contained in:
@@ -44,6 +44,7 @@ from electrum.gui.qt.util import (
|
|||||||
TaskThread,
|
TaskThread,
|
||||||
WindowModalDialog,
|
WindowModalDialog,
|
||||||
char_width_in_lineedit,
|
char_width_in_lineedit,
|
||||||
|
getOpenFileName,
|
||||||
getSaveFileName,
|
getSaveFileName,
|
||||||
import_meta_gui,
|
import_meta_gui,
|
||||||
read_QIcon_from_bytes,
|
read_QIcon_from_bytes,
|
||||||
@@ -78,6 +79,7 @@ from PyQt6.QtGui import QColor, QPainter, QPalette, QStandardItem, QStandardItem
|
|||||||
from PyQt6.QtWidgets import (
|
from PyQt6.QtWidgets import (
|
||||||
QAbstractItemView,
|
QAbstractItemView,
|
||||||
QAbstractSpinBox,
|
QAbstractSpinBox,
|
||||||
|
QApplication,
|
||||||
QCheckBox,
|
QCheckBox,
|
||||||
QComboBox,
|
QComboBox,
|
||||||
QDateTimeEdit,
|
QDateTimeEdit,
|
||||||
@@ -97,6 +99,7 @@ from PyQt6.QtWidgets import (
|
|||||||
QStyle,
|
QStyle,
|
||||||
QStyleOptionFrame,
|
QStyleOptionFrame,
|
||||||
QTextEdit,
|
QTextEdit,
|
||||||
|
QToolButton,
|
||||||
QVBoxLayout,
|
QVBoxLayout,
|
||||||
QWidget,
|
QWidget,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -23,6 +23,48 @@ from .dialogs import BalBuildWillDialog, BalDialog
|
|||||||
from .widgets import BalCheckBox, WillSettingsWidget
|
from .widgets import BalCheckBox, WillSettingsWidget
|
||||||
|
|
||||||
|
|
||||||
|
def _can_sign(will_item):
|
||||||
|
"""True if a will transaction may still be signed (not fully signed)."""
|
||||||
|
return bool(will_item) and not will_item.get_status("COMPLETE")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_broadcast(will_item):
|
||||||
|
"""True if a will transaction is ready to broadcast (fully signed)."""
|
||||||
|
return bool(will_item) and will_item.get_status("COMPLETE")
|
||||||
|
|
||||||
|
|
||||||
|
def _can_delete(will_item):
|
||||||
|
"""True if a will transaction may be deleted (invalid or unsigned).
|
||||||
|
|
||||||
|
Valid AND fully-signed transactions are never deletable: removing them
|
||||||
|
would silently drop a committed inheritance.
|
||||||
|
"""
|
||||||
|
return bool(will_item) and (
|
||||||
|
not will_item.get_status("VALID") or not will_item.get_status("COMPLETE")
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_select_all(willexecutors, select, valid=None):
|
||||||
|
"""Apply a bulk selection across a ``{url: we_dict}`` mapping.
|
||||||
|
|
||||||
|
``select`` is the target selection value. When ``valid`` is given (a
|
||||||
|
``{url: bool}`` validity map), the selection is restricted by validity:
|
||||||
|
selecting sets valid ones to True and invalid ones to False, while
|
||||||
|
deselecting only clears the invalid ones (valid ones keep their state).
|
||||||
|
Without ``valid`` every entry is simply set to ``select``. The mapping is
|
||||||
|
mutated in place and returned.
|
||||||
|
"""
|
||||||
|
for url, we in willexecutors.items():
|
||||||
|
if valid is not None:
|
||||||
|
if select:
|
||||||
|
we["selected"] = valid.get(url, False)
|
||||||
|
elif not valid.get(url, False):
|
||||||
|
we["selected"] = False
|
||||||
|
else:
|
||||||
|
we["selected"] = select
|
||||||
|
return willexecutors
|
||||||
|
|
||||||
|
|
||||||
class HeirListWidget(MyTreeView, MessageBoxMixin):
|
class HeirListWidget(MyTreeView, MessageBoxMixin):
|
||||||
class Columns(MyTreeView.BaseColumnsEnum):
|
class Columns(MyTreeView.BaseColumnsEnum):
|
||||||
NAME = enum.auto()
|
NAME = enum.auto()
|
||||||
@@ -308,29 +350,64 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
selected_keys.append(sel_key)
|
selected_keys.append(sel_key)
|
||||||
if selected_keys and idx.isValid():
|
if selected_keys and idx.isValid():
|
||||||
column_title = self.model().horizontalHeaderItem(column).text()
|
column_title = self.model().horizontalHeaderItem(column).text()
|
||||||
# column_data = "\n".join(
|
|
||||||
# self.model().itemFromIndex(s_idx).text()
|
|
||||||
# for s_idx in self.selected_in_column(column)
|
|
||||||
# )
|
|
||||||
|
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("details").format(column_title),
|
_("details").format(column_title),
|
||||||
lambda: self.show_transaction(selected_keys),
|
lambda: self.show_transaction(selected_keys),
|
||||||
).setEnabled(len(selected_keys) < 2)
|
).setEnabled(len(selected_keys) == 1)
|
||||||
|
menu.addAction(
|
||||||
|
_("sign").format(column_title),
|
||||||
|
lambda: self.sign_transactions(selected_keys),
|
||||||
|
).setEnabled(
|
||||||
|
any(_can_sign(self.will.get(k)) for k in selected_keys)
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
_("broadcast").format(column_title),
|
||||||
|
lambda: self.broadcast_transactions(selected_keys),
|
||||||
|
).setEnabled(
|
||||||
|
any(_can_broadcast(self.will.get(k)) for k in selected_keys)
|
||||||
|
)
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("check ").format(column_title),
|
_("check ").format(column_title),
|
||||||
lambda: self.check_transactions(selected_keys),
|
lambda: self.check_transactions(selected_keys),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
menu.addSeparator()
|
||||||
|
menu.addAction(
|
||||||
|
_("copy id").format(column_title),
|
||||||
|
lambda: self.copy_txids(selected_keys),
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
_("copy").format(column_title),
|
||||||
|
lambda: self.copy_tx_hexes(selected_keys),
|
||||||
|
)
|
||||||
|
menu.addAction(
|
||||||
|
_("merge from txn").format(column_title),
|
||||||
|
lambda: self.merge_from_txn(),
|
||||||
|
)
|
||||||
|
|
||||||
menu.addSeparator()
|
menu.addSeparator()
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
_("delete").format(column_title), lambda: self.delete(selected_keys)
|
||||||
|
).setEnabled(
|
||||||
|
any(_can_delete(self.will.get(k)) for k in selected_keys)
|
||||||
)
|
)
|
||||||
|
|
||||||
menu.exec(self.viewport().mapToGlobal(position))
|
menu.exec(self.viewport().mapToGlobal(position))
|
||||||
|
|
||||||
|
def is_deletable(self, key):
|
||||||
|
"""True if the will transaction ``key`` may be deleted.
|
||||||
|
|
||||||
|
Deletion is only allowed for transactions that are NOT valid or NOT
|
||||||
|
fully signed (invalidated/replaced/mempool/confirmed items, or
|
||||||
|
unsigned/partially signed ones). Valid and complete transactions are
|
||||||
|
kept (deleting them would silently drop a committed inheritance).
|
||||||
|
"""
|
||||||
|
return _can_delete(self.will.get(key))
|
||||||
|
|
||||||
def delete(self, selected_keys):
|
def delete(self, selected_keys):
|
||||||
for key in selected_keys:
|
keys = [k for k in selected_keys if self.is_deletable(k)]
|
||||||
|
for key in keys:
|
||||||
del self.will[key]
|
del self.will[key]
|
||||||
try:
|
try:
|
||||||
del self.bal_window.willitems[key]
|
del self.bal_window.willitems[key]
|
||||||
@@ -342,6 +419,75 @@ class PreviewList(MyTreeView, MessageBoxMixin):
|
|||||||
pass
|
pass
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
def sign_transactions(self, selected_keys):
|
||||||
|
"""Sign all selected transactions that are not fully signed yet."""
|
||||||
|
keys = [
|
||||||
|
k for k in selected_keys
|
||||||
|
if _can_sign(self.will.get(k))
|
||||||
|
]
|
||||||
|
if keys:
|
||||||
|
self.bal_window.ask_password_and_sign_transactions(
|
||||||
|
callback=self.update, txids=keys
|
||||||
|
)
|
||||||
|
|
||||||
|
def broadcast_transactions(self, selected_keys):
|
||||||
|
"""Force-broadcast all selected fully-signed transactions.
|
||||||
|
|
||||||
|
``force=True`` makes the will-executors re-accept transactions that
|
||||||
|
were already pushed before.
|
||||||
|
"""
|
||||||
|
keys = [
|
||||||
|
k for k in selected_keys
|
||||||
|
if _can_broadcast(self.will.get(k))
|
||||||
|
]
|
||||||
|
if keys:
|
||||||
|
self.bal_window.broadcast_transactions(force=True, txids=keys)
|
||||||
|
self.update()
|
||||||
|
|
||||||
|
def copy_txids(self, selected_keys):
|
||||||
|
"""Copy the selected transaction IDs to the clipboard (one per line)."""
|
||||||
|
self.place_text_on_clipboard(
|
||||||
|
"\n".join(selected_keys), title=_("Transaction IDs")
|
||||||
|
)
|
||||||
|
|
||||||
|
def copy_tx_hexes(self, selected_keys):
|
||||||
|
"""Copy the selected transactions (serialised hex) to the clipboard."""
|
||||||
|
hexes = "\n".join(str(self.will[k].tx) for k in selected_keys)
|
||||||
|
self.place_text_on_clipboard(hexes, title=_("Transactions"))
|
||||||
|
|
||||||
|
def merge_from_txn(self):
|
||||||
|
"""Merge a transaction read from the clipboard, or from a file if the
|
||||||
|
clipboard does not contain a valid transaction.
|
||||||
|
"""
|
||||||
|
tx = None
|
||||||
|
try:
|
||||||
|
tx = tx_from_any(QApplication.clipboard().text())
|
||||||
|
except Exception:
|
||||||
|
tx = None
|
||||||
|
if tx is None:
|
||||||
|
filename = getOpenFileName(
|
||||||
|
parent=self.bal_window.window,
|
||||||
|
title=_("Open transaction file"),
|
||||||
|
filter="All files (*)",
|
||||||
|
config=self.bal_window.window.config,
|
||||||
|
)
|
||||||
|
if not filename:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
with open(filename, "r") as f:
|
||||||
|
data = f.read()
|
||||||
|
tx = tx_from_any(data)
|
||||||
|
except Exception as e:
|
||||||
|
self.bal_window.show_error(
|
||||||
|
_("Invalid transaction file: {}").format(e)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
self.bal_window.merge_single_transaction(tx)
|
||||||
|
except Exception as e:
|
||||||
|
self.bal_window.show_error(str(e))
|
||||||
|
self.update()
|
||||||
|
|
||||||
def check_transactions(self, selected_keys):
|
def check_transactions(self, selected_keys):
|
||||||
wout = {}
|
wout = {}
|
||||||
for k in selected_keys:
|
for k in selected_keys:
|
||||||
@@ -778,6 +924,15 @@ class WillExecutorListWidget(MyTreeView):
|
|||||||
# self.model().itemFromIndex(s_idx).text()
|
# self.model().itemFromIndex(s_idx).text()
|
||||||
# for s_idx in self.selected_in_column(column)
|
# for s_idx in self.selected_in_column(column)
|
||||||
# )
|
# )
|
||||||
|
# When exactly ONE cell is selected, offer "Copy" to copy the value
|
||||||
|
# of that cell to the clipboard (e.g. a single url / address / fee).
|
||||||
|
# This list has no ``main_window``, so use QApplication directly.
|
||||||
|
if len(self.selectionModel().selectedIndexes()) == 1:
|
||||||
|
cell_value = self.model().itemFromIndex(idx).text()
|
||||||
|
menu.addAction(
|
||||||
|
_("Copy"),
|
||||||
|
lambda: QApplication.clipboard().setText(cell_value),
|
||||||
|
)
|
||||||
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
if Willexecutors.is_selected(self._bal_parent.willexecutors_list[sel_key]):
|
||||||
menu.addAction(
|
menu.addAction(
|
||||||
_("deselect").format(column_title),
|
_("deselect").format(column_title),
|
||||||
@@ -1026,13 +1181,44 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
b.clicked.connect(self.import_file)
|
b.clicked.connect(self.import_file)
|
||||||
buttonbox.addWidget(b)
|
buttonbox.addWidget(b)
|
||||||
|
|
||||||
b = QPushButton(_("Export"))
|
def _menu_button(label):
|
||||||
b.clicked.connect(self.export_file)
|
btn = QToolButton()
|
||||||
buttonbox.addWidget(b)
|
btn.setText(_(label))
|
||||||
|
btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
|
||||||
|
buttonbox.addWidget(btn)
|
||||||
|
return btn
|
||||||
|
|
||||||
b = QPushButton(_("Ping All"))
|
export_btn = _menu_button("Export")
|
||||||
b.clicked.connect(self.update_willexecutors)
|
export_menu = QMenu(export_btn)
|
||||||
buttonbox.addWidget(b)
|
export_menu.addAction(_("Export all"), lambda: self.export_file())
|
||||||
|
export_menu.addAction(
|
||||||
|
_("Export selected"), lambda: self.export_file(subset="selected")
|
||||||
|
)
|
||||||
|
export_menu.addAction(
|
||||||
|
_("Export only valid"), lambda: self.export_file(subset="valid")
|
||||||
|
)
|
||||||
|
export_btn.setMenu(export_menu)
|
||||||
|
|
||||||
|
ping_btn = _menu_button("Ping All")
|
||||||
|
ping_menu = QMenu(ping_btn)
|
||||||
|
ping_menu.addAction(_("Ping all"), lambda: self.update_willexecutors())
|
||||||
|
ping_menu.addAction(
|
||||||
|
_("Ping selected"), lambda: self.ping_selected_willexecutors()
|
||||||
|
)
|
||||||
|
ping_btn.setMenu(ping_menu)
|
||||||
|
|
||||||
|
select_btn = _menu_button("Select All")
|
||||||
|
select_menu = QMenu(select_btn)
|
||||||
|
select_menu.addAction(_("Select all"), lambda: self.set_select_all(True))
|
||||||
|
select_menu.addAction(
|
||||||
|
_("Select only valid"), lambda: self.set_select_all(True, only_valid=True)
|
||||||
|
)
|
||||||
|
select_menu.addAction(_("Deselect all"), lambda: self.set_select_all(False))
|
||||||
|
select_menu.addAction(
|
||||||
|
_("Deselect only invalid"),
|
||||||
|
lambda: self.set_select_all(False, only_valid=True),
|
||||||
|
)
|
||||||
|
select_btn.setMenu(select_menu)
|
||||||
|
|
||||||
vbox.addLayout(buttonbox)
|
vbox.addLayout(buttonbox)
|
||||||
# self.will_executor_list_widget.update()
|
# self.will_executor_list_widget.update()
|
||||||
@@ -1245,13 +1431,68 @@ class WillExecutorWidget(QWidget, MessageBoxMixin):
|
|||||||
|
|
||||||
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
self.bal_window.download_list(self.bal_window.willexecutors, on_success)
|
||||||
|
|
||||||
def export_file(self, path):
|
def export_file(self, subset=None):
|
||||||
|
data = self.export_data(subset)
|
||||||
|
if subset and not data:
|
||||||
|
self.show_message(_("No will-executor matches the selected filter"))
|
||||||
|
return
|
||||||
export_meta_gui(
|
export_meta_gui(
|
||||||
self.bal_window.window, "willexecutors.json", self.export_json_file
|
self.bal_window.window,
|
||||||
|
"willexecutors.json",
|
||||||
|
partial(self.export_json_file, subset=subset),
|
||||||
)
|
)
|
||||||
|
|
||||||
def export_json_file(self, path):
|
def export_data(self, subset=None):
|
||||||
write_json_file(path, self.willexecutors_list)
|
data = self.willexecutors_list
|
||||||
|
if subset == "selected":
|
||||||
|
data = {
|
||||||
|
url: we
|
||||||
|
for url, we in data.items()
|
||||||
|
if Willexecutors.is_selected(we)
|
||||||
|
}
|
||||||
|
elif subset == "valid":
|
||||||
|
valid = self._validity()
|
||||||
|
data = {
|
||||||
|
url: we for url, we in data.items() if valid.get(url, False)
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
|
||||||
|
def export_json_file(self, path, subset=None):
|
||||||
|
write_json_file(path, self.export_data(subset))
|
||||||
|
|
||||||
|
def _validity(self):
|
||||||
|
max_fee = self.bal_plugin.MAX_WILLEXECUTOR_FEE.get()
|
||||||
|
dust = self.bal_window.window.wallet.dust_threshold()
|
||||||
|
return {
|
||||||
|
url: Willexecutors.is_valid(we, max_fee=max_fee, dust=dust)
|
||||||
|
for url, we in self.willexecutors_list.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
def _selected_willexecutors(self):
|
||||||
|
return {
|
||||||
|
url: we
|
||||||
|
for url, we in self.willexecutors_list.items()
|
||||||
|
if Willexecutors.is_selected(we)
|
||||||
|
}
|
||||||
|
|
||||||
|
def set_select_all(self, select, only_valid=False):
|
||||||
|
"""Apply a bulk selection across all will-executors.
|
||||||
|
|
||||||
|
``select=True`` selects all (or only the valid ones when
|
||||||
|
``only_valid=True``, deselecting the invalid ones); ``select=False``
|
||||||
|
deselects all (or only the invalid ones when ``only_valid=True``,
|
||||||
|
leaving the valid ones selected).
|
||||||
|
"""
|
||||||
|
valid = self._validity() if only_valid else None
|
||||||
|
_apply_select_all(self.willexecutors_list, select, valid)
|
||||||
|
self.save_willexecutors()
|
||||||
|
|
||||||
|
def ping_selected_willexecutors(self):
|
||||||
|
wes = self._selected_willexecutors()
|
||||||
|
if not wes:
|
||||||
|
self.show_message(_("No will-executor is selected"))
|
||||||
|
return
|
||||||
|
self.update_willexecutors(wes)
|
||||||
|
|
||||||
def import_file(self):
|
def import_file(self):
|
||||||
import_meta_gui(
|
import_meta_gui(
|
||||||
|
|||||||
@@ -923,7 +923,7 @@ class BalWindow:
|
|||||||
)
|
)
|
||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def sign_transactions(self, password, will=None):
|
def sign_transactions(self, password, will=None, txids=None):
|
||||||
try:
|
try:
|
||||||
willitems = will if will is not None else self.willitems
|
willitems = will if will is not None else self.willitems
|
||||||
txs = {}
|
txs = {}
|
||||||
@@ -936,7 +936,14 @@ class BalWindow:
|
|||||||
msg = _(f"signed: {signed}\n")
|
msg = _(f"signed: {signed}\n")
|
||||||
return msg + _(f"signing: {tosign}")
|
return msg + _(f"signing: {tosign}")
|
||||||
|
|
||||||
for txid in Will.only_valid(willitems):
|
if txids is not None:
|
||||||
|
targets = [
|
||||||
|
t for t in txids
|
||||||
|
if t in willitems and willitems[t].get_status("VALID")
|
||||||
|
]
|
||||||
|
else:
|
||||||
|
targets = Will.only_valid(willitems)
|
||||||
|
for txid in targets:
|
||||||
wi = willitems[txid]
|
wi = willitems[txid]
|
||||||
tx = copy.deepcopy(wi.tx)
|
tx = copy.deepcopy(wi.tx)
|
||||||
if wi.get_status("COMPLETE"):
|
if wi.get_status("COMPLETE"):
|
||||||
@@ -1043,7 +1050,7 @@ class BalWindow:
|
|||||||
# re-wire them if this same window is reused for another wallet.
|
# re-wire them if this same window is reused for another wallet.
|
||||||
self._menubar_initialized = False
|
self._menubar_initialized = False
|
||||||
|
|
||||||
def ask_password_and_sign_transactions(self, callback=None, will=None):
|
def ask_password_and_sign_transactions(self, callback=None, will=None, txids=None):
|
||||||
external = will is not None
|
external = will is not None
|
||||||
willitems = will if external else self.willitems
|
willitems = will if external else self.willitems
|
||||||
|
|
||||||
@@ -1076,14 +1083,14 @@ class BalWindow:
|
|||||||
log_error(exec_info, self.bal_window)
|
log_error(exec_info, self.bal_window)
|
||||||
|
|
||||||
password = self.get_wallet_password()
|
password = self.get_wallet_password()
|
||||||
task = partial(self.sign_transactions, password, will=will)
|
task = partial(self.sign_transactions, password, will=will, txids=txids)
|
||||||
msg = _("Signing transactions...")
|
msg = _("Signing transactions...")
|
||||||
self.waiting_dialog = BalWaitingDialog(
|
self.waiting_dialog = BalWaitingDialog(
|
||||||
self, msg, task, on_success, on_failure, exe=False
|
self, msg, task, on_success, on_failure, exe=False
|
||||||
)
|
)
|
||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def broadcast_transactions(self, force=False, will=None):
|
def broadcast_transactions(self, force=False, will=None, txids=None):
|
||||||
external = will is not None
|
external = will is not None
|
||||||
|
|
||||||
def on_success(sulcess):
|
def on_success(sulcess):
|
||||||
@@ -1113,15 +1120,19 @@ class BalWindow:
|
|||||||
# _logger.error("lasti:", tb.tb_lasti)
|
# _logger.error("lasti:", tb.tb_lasti)
|
||||||
# tb = tb.tb_next
|
# tb = tb.tb_next
|
||||||
|
|
||||||
task = partial(self.push_transactions_to_willexecutors, force, will=will)
|
task = partial(self.push_transactions_to_willexecutors, force, will=will, txids=txids)
|
||||||
msg = _("Selecting Will-Executors")
|
msg = _("Selecting Will-Executors")
|
||||||
self.waiting_dialog = BalWaitingDialog(
|
self.waiting_dialog = BalWaitingDialog(
|
||||||
self, msg, task, on_success, on_failure, exe=False
|
self, msg, task, on_success, on_failure, exe=False
|
||||||
)
|
)
|
||||||
self.waiting_dialog.exe()
|
self.waiting_dialog.exe()
|
||||||
|
|
||||||
def push_transactions_to_willexecutors(self, force=False, will=None):
|
def push_transactions_to_willexecutors(self, force=False, will=None, txids=None):
|
||||||
willitems = will if will is not None else self.willitems
|
willitems = will if will is not None else self.willitems
|
||||||
|
if txids is not None:
|
||||||
|
willitems = {
|
||||||
|
t: willitems[t] for t in txids if t in willitems
|
||||||
|
}
|
||||||
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
willexecutors = Willexecutors.get_willexecutor_transactions(willitems, force=force)
|
||||||
|
|
||||||
def getMsg(willexecutors):
|
def getMsg(willexecutors):
|
||||||
@@ -1299,6 +1310,17 @@ class BalWindow:
|
|||||||
Will.normalize_will(willitems, self.wallet)
|
Will.normalize_will(willitems, self.wallet)
|
||||||
self.merge_will(willitems)
|
self.merge_will(willitems)
|
||||||
|
|
||||||
|
def merge_single_transaction(self, tx):
|
||||||
|
"""Merge a single raw transaction (e.g. from the clipboard or a file)
|
||||||
|
into the live will.
|
||||||
|
|
||||||
|
The transaction is wrapped in a fresh :class:`WillItem` and merged
|
||||||
|
through :meth:`merge_will`, so existing items are combined/updated and
|
||||||
|
new transactions are added wholesale, exactly like a will-file merge.
|
||||||
|
"""
|
||||||
|
wi = WillItem({"tx": str(tx)}, _id=tx.txid(), wallet=self.wallet)
|
||||||
|
self.merge_will({wi._id: wi})
|
||||||
|
|
||||||
def merge_will_ui(self):
|
def merge_will_ui(self):
|
||||||
def on_success():
|
def on_success():
|
||||||
self.will_list_widget.update_will(self.willitems)
|
self.will_list_widget.update_will(self.willitems)
|
||||||
|
|||||||
150
tests/test_gui_will_menu.py
Normal file
150
tests/test_gui_will_menu.py
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
"""
|
||||||
|
Tests for the Will-tab context menu and Will-Executor bulk-selection helpers
|
||||||
|
in ``bal.gui.qt.lists``.
|
||||||
|
|
||||||
|
Covers ``_can_sign`` / ``_can_broadcast`` / ``_can_delete`` and
|
||||||
|
``_apply_select_all`` (the pure logic behind the new context-menu actions and
|
||||||
|
the "Select All" dropdown).
|
||||||
|
|
||||||
|
Run:
|
||||||
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_will_menu.py
|
||||||
|
"""
|
||||||
|
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
|
from bal.gui.qt.lists import (
|
||||||
|
_apply_select_all,
|
||||||
|
_can_broadcast,
|
||||||
|
_can_delete,
|
||||||
|
_can_sign,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class FakeWillItem:
|
||||||
|
"""Minimal stand-in for ``bal.core.will.WillItem`` (get_status only)."""
|
||||||
|
|
||||||
|
def __init__(self, **status_flags):
|
||||||
|
self._status = dict(status_flags)
|
||||||
|
|
||||||
|
def get_status(self, name):
|
||||||
|
return self._status.get(name, False)
|
||||||
|
|
||||||
|
|
||||||
|
def _we(selected=False):
|
||||||
|
return {"selected": selected}
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _can_sign / _can_broadcast
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_can_sign_unsigned():
|
||||||
|
assert _can_sign(FakeWillItem()) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_partially_signed():
|
||||||
|
assert _can_sign(FakeWillItem(PARTIALLY_SIGNED=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_complete_false():
|
||||||
|
assert _can_sign(FakeWillItem(COMPLETE=True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_sign_none_false():
|
||||||
|
assert _can_sign(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_broadcast_complete():
|
||||||
|
assert _can_broadcast(FakeWillItem(COMPLETE=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_broadcast_unsigned_false():
|
||||||
|
assert _can_broadcast(FakeWillItem()) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_broadcast_none_false():
|
||||||
|
assert _can_broadcast(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _can_delete
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_can_delete_invalid():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=False, COMPLETE=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_unsigned():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=True)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_invalid_and_unsigned():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=False)) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_valid_and_complete_false():
|
||||||
|
assert _can_delete(FakeWillItem(VALID=True, COMPLETE=True)) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_can_delete_none_false():
|
||||||
|
assert _can_delete(None) is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# _apply_select_all
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
def test_select_all_sets_everything():
|
||||||
|
wes = {"a": _we(False), "b": _we(False)}
|
||||||
|
_apply_select_all(wes, True)
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_deselect_all_clears_everything():
|
||||||
|
wes = {"a": _we(True), "b": _we(True)}
|
||||||
|
_apply_select_all(wes, False)
|
||||||
|
assert wes["a"]["selected"] is False
|
||||||
|
assert wes["b"]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_only_valid():
|
||||||
|
wes = {"a": _we(False), "b": _we(True), "c": _we(True)}
|
||||||
|
valid = {"a": True, "b": True, "c": False}
|
||||||
|
_apply_select_all(wes, True, valid)
|
||||||
|
# a, b are valid -> selected; c is invalid -> deselected
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is True
|
||||||
|
assert wes["c"]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_deselect_only_invalid():
|
||||||
|
wes = {"a": _we(True), "b": _we(True), "c": _we(True)}
|
||||||
|
valid = {"a": True, "b": False, "c": True}
|
||||||
|
_apply_select_all(wes, False, valid)
|
||||||
|
# b is invalid -> deselected; a, c are valid -> keep their state
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is False
|
||||||
|
assert wes["c"]["selected"] is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_select_only_valid_missing_url_treated_invalid():
|
||||||
|
wes = {"a": _we(False), "b": _we(True)}
|
||||||
|
valid = {"a": True}
|
||||||
|
_apply_select_all(wes, True, valid)
|
||||||
|
assert wes["a"]["selected"] is True
|
||||||
|
assert wes["b"]["selected"] is False
|
||||||
|
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
# Main
|
||||||
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
for name in sorted(dir()):
|
||||||
|
if name.startswith("test_"):
|
||||||
|
globals()[name]()
|
||||||
|
print(f" [OK] {name}")
|
||||||
|
print("[OK] All will-menu tests passed")
|
||||||
Reference in New Issue
Block a user