core: extract GUI-free logic into bal.core (reminders/checkalive/input_rules); fix save/sign tx RLock pickle by serializing tx; keep invalid heirs in wallet on build; tb1 testnet will-executor addresses; move tests to core modules

This commit is contained in:
2026-08-05 17:17:32 -04:00
parent 0806332543
commit 95c23a4b21
22 changed files with 1516 additions and 2746 deletions

71
bal/core/checkalive.py Normal file
View File

@@ -0,0 +1,71 @@
"""
bal.core.checkalive
===================
The "Check Alive" policy: the single reference timestamp (``date_to_check``)
against which every will-validity check is evaluated, and the BASIC/ADVANCED
mode rules that decide it.
Pure, GUI-free. The GUI raises :class:`CheckAliveError` to trigger the
postpone/invalidate flow; the decision that it *should* be raised lives here.
"""
from datetime import datetime
from typing import Any
from .plugin_base import BalTimestamp
class CheckAliveError(Exception):
"""Raised when the "check alive" date is in the past."""
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def resolve_date_to_check(
is_basic_mode: bool, will_settings: Any, now: float | None = None
) -> float:
"""Return the reference timestamp for every will-validity check.
``date_to_check`` is the single reference timestamp that EVERY downstream
check reads: the build filter, the heir count, ``check_will_expired``,
``check_amounts`` and the locktime-vs-threshold guard.
* BASIC mode: the Check Alive is hidden and NOT editable, so it must never
govern those checks. ``date_to_check`` is set to *now*: every check is
evaluated against the current moment (the Check Alive effectively does not
exist) while the delivery locktime is still fully enforced.
* ADVANCED mode: the user-controlled stored threshold is used as-is.
Args:
is_basic_mode: ``True`` for the SIMPLE / BASIC user type.
will_settings: the per-wallet settings dict (``"threshold"`` key).
now: overridable clock for tests; defaults to ``datetime.now()``.
Returns:
The reference timestamp (float, UNIX seconds).
"""
if is_basic_mode:
return (now if now is not None else datetime.now().timestamp())
return BalTimestamp(will_settings["threshold"]).to_timestamp()
def check_alive_expired(
is_basic_mode: bool, date_to_check: float, now: float | None = None
) -> bool:
"""True when the Check Alive guard should fire (``CheckAliveError``).
Only ADVANCED mode can be "expired": in BASIC mode the Check Alive is inert
by construction, so a passed check-alive date must never force a postpone or
rewrite of the will.
"""
if is_basic_mode:
return False
current = now if now is not None else datetime.now().timestamp()
return date_to_check < current

View File

@@ -630,7 +630,19 @@ class Heirs(dict, Logger):
def buildTransactions(
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
):
Heirs._validate(self)
_before = list(self.keys())
Heirs._validate(self, persist=False)
_removed = [k for k in _before if k not in self]
if _removed:
# The build skips invalid heirs, but they are only dropped in memory
# (persist=False): the wallet still keeps them, so the user can fix
# or remove them deliberately instead of losing them silently.
_logger.warning(
"buildTransactions: skipped %d invalid heir(s) (kept in wallet, "
"not removed): %s",
len(_removed),
", ".join(_removed),
)
if len(self) <= 0:
_logger.info("while building transactions there was no heirs")
return
@@ -877,16 +889,22 @@ class Heirs(dict, Logger):
return (address, amount, locktime)
@staticmethod
def _validate(data, timestamp_to_check=False):
def _validate(data, timestamp_to_check=False, persist=True):
for k, v in list(data.items()):
if k == "heirs":
return Heirs._validate(v, timestamp_to_check)
return Heirs._validate(v, timestamp_to_check, persist)
try:
Heirs.validate_heir(k, v, timestamp_to_check)
except Exception as e:
_logger.info(f"exception heir removed {e}")
if persist:
data.pop(k)
else:
# Drop the invalid heir in memory only, so the overridden
# Heirs.pop (which calls save()) is not triggered: a build
# must not silently delete heirs from the wallet.
dict.pop(data, k)
return data

193
bal/core/input_rules.py Normal file
View File

@@ -0,0 +1,193 @@
"""
bal.core.input_rules
====================
Pure, GUI-free rules for the plugin's text-input widgets: locktime bounds and
acceptance, the RAW locktime sanitisation ("30d"/"1y"), and the percentage-or-
amount field normalisation.
These used to live inside the Qt widget classes in ``bal.gui.qt.widgets``.
Keeping them here makes them testable without Qt and lets any GUI front-end
reuse the exact same parsing rules.
"""
from datetime import datetime
from decimal import Decimal
from typing import Any, Optional, Tuple, Union
from electrum.bitcoin import NLOCKTIME_BLOCKHEIGHT_MAX, NLOCKTIME_MAX, NLOCKTIME_MIN
__all__ = [
"NLOCKTIME_BLOCKHEIGHT_MAX",
"NLOCKTIME_MAX",
"NLOCKTIME_MIN",
"LockTimeEditor",
"normalize_locktime_raw_text",
"normalize_perc_amount_text",
"parse_perc_amount",
"replace_dy_suffixes",
]
class LockTimeEditor:
"""Acceptance bounds shared by the RAW and Date locktime editors.
A Qt widget mixin historically; the pure parts (bounds and acceptance test)
live here so they can be tested and reused without Qt. Widget subclasses
override ``min_allowed_value``/``max_allowed_value`` to tighten the bounds.
"""
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
"""True when ``x`` (string or int) is within the allowed locktime bounds.
An empty/falsy value is accepted (the field is not yet filled in).
"""
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
"""Highest locktime timestamp accepted on this platform.
On 32-bit ``time_t`` Windows builds ``datetime.fromtimestamp`` overflows
past 2038, so the ceiling is clamped to INT32_MAX (see #6170).
"""
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is
# platform-dependent). see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
def replace_dy_suffixes(text: str) -> str:
"""Strip the relative-time suffixes (d/y) from ``text``.
Only days ("d") and years ("y") are supported. The block-height suffix
("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def _checkbdy(s: str, pos: int, appendix: str) -> Tuple[int, str]:
"""Keep a ``d``/``y`` suffix typed right after an existing suffix.
When the character just before ``pos`` equals ``appendix``, the text is
re-normalised so only one suffix remains.
"""
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = replace_dy_suffixes(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def normalize_locktime_raw_text(
text: str, pos: int
) -> Tuple[str, bool, bool, int]:
"""Sanitise the RAW locktime field text.
Only digits plus the day ("d") and year ("y") suffixes are kept; the block
suffix ("b") is removed (A1). Exactly one ``d``/``y`` suffix survives.
Args:
text: the raw field text.
pos: the cursor position within ``text``.
Returns:
``(clean, isdays, isyears, new_pos)`` where ``clean`` is the sanitised
text and ``new_pos`` the adjusted cursor position.
"""
text = text.strip()
chars = "0123456789dy"
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
isdays = False
isyears = False
pos, s = _checkbdy(s, pos, "d")
pos, s = _checkbdy(s, pos, "y")
if "d" in s:
isdays = True
if "y" in s:
isyears = True
if isdays:
s = replace_dy_suffixes(s) + "d"
if isyears:
s = replace_dy_suffixes(s) + "y"
return s, isdays, isyears, pos
def normalize_perc_amount_text(text: str, decimal_point: str) -> Tuple[str, bool]:
"""Sanitise the amount-or-percentage field text.
Keeps digits, ``%`` and the decimal point; a trailing ``%`` marks the value
as a percentage (``is_perc``). At most 8 decimal digits after the point.
Args:
text: the raw field text.
decimal_point: the decimal separator character (``electrum`` uses
``DECIMAL_POINT``, locale dependent).
Returns:
``(clean, is_perc)``.
"""
text = text.strip()
chars = "0123456789%"
chars += decimal_point
s = "".join([i for i in text if i in chars])
if "%" in s:
is_perc = True
s = s.replace("%", "")
else:
is_perc = False
if decimal_point in s:
p = s.find(decimal_point)
s = s.replace(decimal_point, "")
s = s[:p] + decimal_point + s[p : p + 8]
if is_perc:
s += "%"
return s, is_perc
def parse_perc_amount(text: str, decimal_point: str) -> Union[None, Decimal, int]:
"""Parse an amount-or-percentage field text into a numeric value.
Returns ``None`` when the text cannot be parsed.
"""
try:
text = text.replace(decimal_point, ".")
text = text.replace("%", "")
return Decimal(text)
except Exception:
return None

View File

@@ -306,7 +306,10 @@ class BalPlugin(BasePlugin):
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
)
# Default will-executor servers, keyed by network.
# Default will-executor servers, keyed by network. These addresses are
# the ones currently reported by each server's <chain>/info endpoint and
# are refreshed again on ping; testnet/testnet4 must NOT be regtest
# (bcrt1...) addresses, which are invalid on those networks.
self.WILLEXECUTORS = BalConfig(
config,
"bal_willexecutors",
@@ -325,7 +328,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
"selected": True,
}
},
@@ -334,7 +337,7 @@ class BalPlugin(BasePlugin):
"base_fee": 100000,
"status": "New",
"info": "Bitcoin After Life Will Executor",
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
"selected": True,
}
},

228
bal/core/reminders.py Normal file
View File

@@ -0,0 +1,228 @@
"""
bal.core.reminders
==================
Pure, GUI-free logic for the dead-man's-switch calendar reminders: choosing the
reminder offsets (BASIC vs ADVANCED modes) and rendering them as an RFC-5545
iCalendar (.ics) document.
Everything in this module is stdlib-only, so it can be imported and tested
without Electrum or Qt (e.g. in the lint venv).
"""
import os
import tempfile
from datetime import datetime, timedelta, timezone
from typing import Optional
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
def format_time(time) -> str:
"""Render a datetime as an RFC-5545 UTC timestamp (``YYYYMMDDTHHMMSSZ``)."""
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
def fold_ical_line(line: str, limit: int = 75) -> str:
"""Fold a line to at most ``limit`` bytes per RFC-5545, without splitting
multi-byte UTF-8 characters. Continuation lines start with a space."""
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# cut without splitting a UTF-8 continuation byte
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte de continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)
def ical_escape(text: str) -> str:
"""Escape a string per RFC-5545: backslash, semicolon, comma, newlines."""
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(fold_ical_line(line) for line in text.split("\r\n"))
def write_temp_ics(content: str) -> str:
"""Write ``content`` to a temporary ``.ics`` file and return its path."""
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
def build_ics_reminders(
*,
locktime: datetime,
basic_mode: bool,
description: str,
summary: str,
wallet_name: str,
heirs_details: str,
version: str,
num_reminders: int = 3,
now: Optional[datetime] = None,
threshold: Optional[datetime] = None,
) -> Optional[str]:
"""Build the ``.ics`` content with one VEVENT per reminder date.
Group D / D1 (revised): N *separate* VEVENTs, one per reminder date, so the
user sees several distinct appointments in their calendar. The reminder
offsets come from :func:`basic_reminder_offsets` (BASIC mode) or
:func:`compute_reminder_offsets` (ADVANCED mode, spread over the check-alive
period).
Args:
locktime: the delivery deadline (datetime).
basic_mode: use the fixed BASIC offsets instead of spreading over the
check-alive period.
description: raw EVENT_DESCRIPTION template; ``$wallet_name`` and
``$heirs_complete`` placeholders are substituted and escaped.
summary: raw EVENT_SUMMARY template; ``$wallet_name`` is substituted.
wallet_name: label used in the UID and template substitutions.
heirs_details: pre-formatted heir list injected into ``description``.
version: plugin version, embedded in the PRODID line.
num_reminders: requested reminder count (ADVANCED mode only).
now: "today" reference; defaults to ``datetime.now()``.
threshold: check-alive date (ADVANCED mode only; required there).
Returns:
The ``.ics`` content string, or ``None`` when no reminder falls in the
future (the delivery date is too close or already passed) so the caller
can show a warning instead of producing an empty-looking file.
"""
now = now if now is not None else datetime.now()
if basic_mode:
days_to_deadline = (locktime - now).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
if threshold is None:
raise ValueError("threshold is required in ADVANCED mode")
days = (locktime - threshold).days
offsets = compute_reminder_offsets(days, num_reminders)
# ToDo #2: no future reminder means there are no events to write. Return
# None so the caller shows a clear warning instead of an empty .ics file.
if not offsets:
return None
event_description = ical_escape(
f"{description}"
.replace("$wallet_name", str(wallet_name))
.replace("$heirs_complete", heirs_details)
)
summary_base = f"{summary}".replace("$wallet_name", str(wallet_name))
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
event_summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(wallet_name)}-{offset}d",
f"DTSTAMP:{format_time(now)}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{event_summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"

View File

@@ -7,11 +7,16 @@ iCalendar (.ics) generation and "open with default calendar app" helper.
When a will is built, the plugin can create a calendar event reminding the user
to "check in" before the locktime expires. This module turns the event data
into an RFC-5545 .ics file and opens it with the OS default application.
The pure RFC-5545 logic (offsets, escaping, folding, the unified .ics builder,
``write_temp_ics``) lives in :mod:`bal.core.reminders`; this module keeps only
the Qt button and the OS/subprocess glue.
"""
from PyQt6.QtGui import QAction
from PyQt6.QtWidgets import QToolButton
from ...core.reminders import write_temp_ics
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
@@ -57,7 +62,7 @@ class BalCalendarButton(QToolButton):
try:
content = self._ics_provider()
if content:
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
self._calendar_temp_path = write_temp_ics(content)
else:
self._calendar_temp_path = None
self._bal_window.show_warning(
@@ -149,13 +154,6 @@ class BalCalendarButton(QToolButton):
class BalCalendar:
@staticmethod
def write_temp_ics(content):
fd, path = tempfile.mkstemp(prefix="event_", suffix=".ics")
with os.fdopen(fd, "wb") as f:
f.write(content.encode("utf-8"))
return path
@staticmethod
def open_with_default_app(calendar_app, path):
_logger.debug("opening calendar app")
@@ -184,37 +182,3 @@ class BalCalendar:
if os.path.isdir(desktop):
return desktop
return home
@staticmethod
def format_time(time):
return time.astimezone(timezone.utc).strftime("%Y%m%dT%H%M%SZ")
#return time.astimezone(timezone.utc).strftime("%Y%m%d")
@staticmethod
def ical_escape(text: str) -> str:
# escape per RFC5545: backslash, ; , newlines
text = (
text.replace("\\", "\\\\")
.replace(";", "\\;")
.replace(",", "\\,")
)
return "\r\n".join(
BalCalendar.fold_ical_line(line)
for line in text.split("\r\n")
)
@staticmethod
def fold_ical_line(line: str, limit: int = 75) -> str:
# ritorna linee separate da CRLF e folding con spazio iniziale sulle righe successive
encoded = line.encode("utf-8")
parts = []
while len(encoded) > limit:
# taglia senza spezzare byte UTF-8
cut = limit
while (encoded[cut] & 0xC0) == 0x80: # byte di continuazione UTF-8
cut -= 1
parts.append(encoded[:cut].decode("utf-8"))
encoded = encoded[cut:]
parts.append(encoded.decode("utf-8"))
return "\r\n ".join(parts)

View File

@@ -12,7 +12,7 @@ hosts a few GUI helpers that do not deserve a module of their own:
* :func:`add_widget` - add a labelled widget (plus optional help) to a grid.
* :func:`log_error` - format an exception traceback for a dialog.
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
* :class:`CheckAliveError`- raised when the "check alive" date is in the past.
(:class:`CheckAliveError` now lives in ``bal.core.checkalive``.)
"""
import copy
@@ -181,18 +181,6 @@ def add_widget(grid, label, widget, row, help_):
class CheckAliveError(Exception):
def __init__(self, timestamp_to_check):
self.timestamp_to_check = timestamp_to_check
def __str__(self):
return "Check alive expired please update it: {}".format(
datetime.fromtimestamp(self.timestamp_to_check).isoformat()
)
def log_error(exec_info, window=None):
"""Log an error and optionally show it.

View File

@@ -19,14 +19,14 @@ use them (see ``lists`` imports below).
from typing import TYPE_CHECKING
from .calendar import BalCalendar, BalCalendarButton
from ...core.checkalive import CheckAliveError
from ...core.reminders import build_ics_reminders
from .calendar import BalCalendarButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .widgets import (
WillSettingsWidget,
WillWidget,
basic_reminder_offsets,
compute_reminder_offsets,
)
if TYPE_CHECKING:
@@ -1598,7 +1598,7 @@ class BalBuildWillDialog(BalDialog):
def _ics_provider(self):
"""Return the .ics content for the current will data."""
from datetime import datetime, timedelta
from datetime import datetime
try:
locktime_ts = Util.parse_locktime_string(
@@ -1606,22 +1606,25 @@ class BalBuildWillDialog(BalDialog):
)
locktime = datetime.fromtimestamp(locktime_ts)
if self.bal_window.bal_plugin.is_basic_mode():
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
if basic_mode:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else:
threshold_ts = BalTimestamp(
self.bal_window.will_settings["threshold"]
).to_timestamp()
threshold = datetime.fromtimestamp(threshold_ts)
days = (locktime - threshold).days
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception:
count = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
num_reminders = 3
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1629,56 +1632,21 @@ class BalBuildWillDialog(BalDialog):
for heir in self.bal_window.heirs
)
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
# ToDo #2: when no reminder falls in the future (the delivery date is
# too close or already passed), build_ics_reminders returns None so
# the caller shows a clear warning instead of producing an empty,
# seemingly-broken .ics file.
return build_ics_reminders(
locktime=locktime,
basic_mode=basic_mode,
description=raw_description,
summary=raw_summary,
wallet_name=str(self.bal_window.wallet),
heirs_details=heirs_details,
version=self.bal_window.bal_plugin.version,
num_reminders=num_reminders,
threshold=threshold,
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{self.bal_window.bal_plugin.version}",
]
total = len(offsets)
# ToDo #2: if no reminder falls in the future (the delivery date is
# too close or already passed), there are no events to write.
# Return None so the caller shows a clear warning instead of
# producing an empty, seemingly-broken .ics file.
if total == 0:
return None
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"
except Exception as e:
_logger.error(f"failed to generate .ics: {e}")
return None
@@ -1706,7 +1674,12 @@ class BalBuildWillDialog(BalDialog):
try:
if txs := self.bal_window.sign_transactions(password):
for txid, tx in txs.items():
self.bal_window.willitems[txid].tx = copy.deepcopy(tx)
# Re-parse instead of deepcopy (the signed tx can carry
# wallet-derived input info holding a threading.RLock,
# which copy.deepcopy cannot pickle).
self.bal_window.willitems[txid].tx = Will.get_tx_from_any(
str(tx)
)
self.bal_window.save_willitems()
self.msg_set_signing(self.msg_ok())
except Exception as e:

View File

@@ -20,6 +20,13 @@ Contents:
from typing import TYPE_CHECKING
from ...core.input_rules import (
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
)
from ...core.reminders import build_ics_reminders, write_temp_ics
from .calendar import BalCalendar, BalCalendarButton
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
@@ -28,86 +35,6 @@ if TYPE_CHECKING:
from .window import BalWindow
def compute_reminder_offsets(days, count):
"""Return the reminder offsets (in days BEFORE the deadline) for an .ics event.
Group D / D1. The reminders are spread uniformly across the check-alive
period and always fall *before* the delivery deadline, i.e. every returned
offset is ``>= 1`` (a reminder exactly on the deadline would be useless).
Rules:
* ``count`` is the requested number of reminders (the settings dialog
caps it at 5, default 3).
* at most ONE reminder per available day: the effective number is
``min(count, days)``;
* with ``days`` available days, offsets are chosen as evenly spaced
points inside ``[1, days]`` (1 = the day before the deadline, ``days``
= the first day of the period), de-duplicated and returned sorted
descending (earliest reminder first).
Args:
days: number of whole days between check-alive and the deadline.
count: requested number of reminders.
Returns:
A list of integer day-offsets (each ``>= 1``), e.g. ``[22, 15, 8]`` for
``days=30, count=3``. Empty if there is no room for any reminder.
"""
# No room for any reminder (deadline today or already passed).
if days < 1 or count < 1:
return []
# Never more reminders than available days (one per day at most).
effective = min(int(count), int(days))
# A single reminder: put it one day before the deadline.
if effective == 1:
return [1]
# Spread "effective" points evenly inside [1, days]. Using i/(effective-1)
# for i in 0..effective-1 gives fractions 0..1; map them onto [1, days].
# This places the first reminder at the start of the period (offset ~days)
# and the last one one day before the deadline (offset 1).
offsets = set()
for i in range(effective):
frac = i / (effective - 1) # 0.0 .. 1.0
# offset = days at frac 0 (start), 1 at frac 1 (just before deadline).
offset = round(days - frac * (days - 1))
offset = max(1, min(days, offset))
offsets.add(offset)
# Sorted descending: earliest reminder (largest offset) first.
return sorted(offsets, reverse=True)
# Fixed reminder offsets (in days BEFORE the delivery date) used in BASIC mode.
# In BASIC the check-alive parameter is hidden/unmanaged, so reminders cannot be
# spread over it; instead the owner asked for three fixed reminders: 30, 10 and
# 1 day before the inheritance delivery date.
BASIC_REMINDER_OFFSETS = (30, 10, 1)
def basic_reminder_offsets(days_to_deadline):
"""Return the BASIC-mode reminder offsets that still fall in the future.
BASIC mode uses the fixed offsets in ``BASIC_REMINDER_OFFSETS`` (30, 10 and
1 day before the delivery date). Any offset that would land in the past is
dropped, because a reminder before "today" is useless: if the delivery date
is only ``days_to_deadline`` days away, only the offsets that are ``<=
days_to_deadline`` are kept.
Args:
days_to_deadline: whole days from now until the delivery date.
Returns:
A list of integer day-offsets (each ``>= 1``), sorted as in
``BASIC_REMINDER_OFFSETS`` (descending: earliest reminder first). Empty
when the delivery date is less than one day away.
"""
horizon = max(int(days_to_deadline), 0)
return [off for off in BASIC_REMINDER_OFFSETS if 1 <= off <= horizon]
class ClickableLabel(QLabel):
doubleClicked = pyqtSignal()
@@ -219,39 +146,12 @@ class BalTxFeesWidget(QWidget):
class _LockTimeEditor:
min_allowed_value = NLOCKTIME_MIN
max_allowed_value = NLOCKTIME_MAX
alarm = None
def get_value(self) -> Optional[int]:
raise NotImplementedError()
def set_value(self, x: Any, force=True) -> None:
raise NotImplementedError()
@classmethod
def is_acceptable_locktime(cls, x: Any) -> bool:
if not x: # e.g. empty string
return True
try:
x = int(x)
except Exception as _e:
return False
return cls.min_allowed_value <= x <= cls.max_allowed_value
@staticmethod
def get_max_allowed_timestamp() -> int:
ts = NLOCKTIME_MAX
# Test if this value is within the valid timestamp limits (which is platform-dependent).
# see #6170
try:
datetime.fromtimestamp(ts)
except (OSError, OverflowError):
ts = 2**31 - 1 # INT32_MAX
datetime.fromtimestamp(ts) # test if raises
return ts
class _LockTimeEditor(LockTimeEditor):
"""Qt-side locktime editor base.
The pure acceptance bounds and helpers live in ``bal.core.input_rules``;
this is just the mixin that lets Qt widgets reuse them.
"""
class BalTimeEditWidget(QWidget, _LockTimeEditor):
@@ -572,51 +472,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
self.isyears = False
self.time_edit = time_edit
@staticmethod
def replace_str(text):
"""Strip the relative-time suffixes (d/y) from the text.
Only days ("d") and years ("y") are supported. The block-height
suffix ("b") was removed (A1): locktimes are always timestamps now.
"""
return str(text).replace("d", "").replace("y", "")
def checkbdy(self, s, pos, appendix):
try:
charpos = pos - 1
charpos = max(0, charpos)
charpos = min(len(s) - 1, charpos)
if appendix == s[charpos]:
s = self.replace_str(s) + appendix
pos = charpos
except Exception:
pass
return pos, s
def numbify(self):
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
# The block-height suffix ("b") was removed (A1): locktimes are always
# UNIX timestamps now, so block-relative input is no longer allowed.
# The sanitisation itself lives in bal.core.input_rules.
text = self.text().strip()
chars = "0123456789dy"
pos = self.cursorPosition()
pos = len("".join([i for i in text[:pos] if i in chars]))
s = "".join([i for i in text if i in chars])
self.isdays = False
self.isyears = False
pos, s = self.checkbdy(s, pos, "d")
pos, s = self.checkbdy(s, pos, "y")
if "d" in s:
self.isdays = True
if "y" in s:
self.isyears = True
if self.isdays:
s = self.replace_str(s) + "d"
if self.isyears:
s = self.replace_str(s) + "y"
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
self.isdays = isdays
self.isyears = isyears
self.blockSignals(True)
self.setText(s)
self.blockSignals(False)
@@ -1073,11 +938,12 @@ class WillSettingsWidget(QWidget):
sees several distinct appointments in their calendar.
The number of events is read from the NUM_REMINDERS setting (default 3,
capped at 5 by the settings dialog). Their dates are computed with
``compute_reminder_offsets``: the offsets are spread uniformly across the
check-alive period and the LAST event always falls one day before the
delivery deadline (locktime). If the period is shorter than the
requested number of reminders, at most one event per day is produced.
capped at 5 by the settings dialog). Their dates are computed by
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
across the check-alive period; the LAST event always falls one day
before the delivery deadline / locktime). If the period is shorter than
the requested number of reminders, at most one event per day is
produced.
Each event:
* is placed on ``locktime - offset`` days (its own visible date);
@@ -1092,103 +958,23 @@ class WillSettingsWidget(QWidget):
path the user picks in the save dialog (default name "BAL_will_event.ics"
on the Desktop).
"""
now = BalCalendar.format_time(datetime.now())
# locktime = delivery deadline. It is exposed by the date widget as
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
# transaction locktime, so the calendar uses the correct delivery date.
locktime = self.widgets["locktime"].alarm
# BASIC vs ADVANCED reminder strategy.
#
# In ADVANCED mode the reminders are spread uniformly across the
# check-alive (threshold) period, ending one day before the deadline.
#
# In BASIC mode the check-alive parameter is NOT shown nor managed by the
# user (it stays at an arbitrary default), so spreading reminders over it
# is meaningless. The owner asked that, in BASIC, the calendar simply
# saves the inheritance delivery date with three fixed reminders: 30 days
# before, 10 days before and 1 day before. We also drop any fixed offset
# that would fall in the past (a reminder before "today" is useless), so
# a short-dated will still gets the reminders that are still in the
# future.
if self.bal_window.bal_plugin.is_basic_mode():
# Whole days from now until the delivery date. Fixed offsets (30, 10,
# 1 day before) are applied by basic_reminder_offsets, which also
# drops any offset that would fall in the past.
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
else:
# ADVANCED: spread reminders over the check-alive period as before.
threshold = self.widgets["threshold"].alarm
# Whole days available between check-alive and the deadline.
days = (locktime - threshold).days
# How many reminders the user asked for (default 3 if unreadable).
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
except Exception:
count = 3
# Day-offsets BEFORE the deadline, e.g. [30, 16, 1]. The list always
# ends with 1 (one day before the locktime) when >= 2 reminders fit.
offsets = compute_reminder_offsets(days, count)
# Per-event heir details and the shared description/summary templates.
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, {self.bal_window.heirs[heir][1]}"
for heir in self.bal_window.heirs
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
# reminder falls in the future it returns None (ToDo #2) and the user is
# warned instead of getting an empty-looking file.
ics_content = self._ics_provider()
if not ics_content:
self.bal_window.show_warning(
_(
"No reminders were saved: the delivery date is too "
"close (or already passed)"
)
# BASIC mode: use factory defaults (the hidden settings are ignored).
# ADVANCED mode: use the user-configured values.
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
return
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
f"PRODID:-//Bitcoin After Life//Electrum Plugin/{self.bal_window.bal_plugin.version}",
]
# One separate VEVENT per reminder offset (its own date in the calendar).
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
# The visible date of this event: "offset" days before the deadline.
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
# Suffix the summary so the N events are easy to tell apart.
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
# Offset in the UID keeps each event unique (no merging).
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
ics_content = "\r\n".join(lines) + "\r\n"
# Keep the generated .ics in a temp file; it is copied to the path the
# user picks below.
self.temp_path = BalCalendar.write_temp_ics(ics_content)
self.temp_path = write_temp_ics(ics_content)
# Group D / D1b: always ask the user WHERE to save the .ics file (the
# plugin no longer tries to open it with a calendar app). The save
@@ -1241,26 +1027,31 @@ class WillSettingsWidget(QWidget):
def _ics_provider(self):
"""Return the .ics content for the current locktime/threshold values.
Used by :class:`BalCalendarButton` as its content provider.
Used by :class:`BalCalendarButton` as its content provider. The whole
document (reminder offsets, escaping, folding, VEVENT layout) is built
by the pure :func:`bal.core.reminders.build_ics_reminders`.
"""
from datetime import datetime, timedelta
try:
locktime = self.widgets["locktime"].alarm
if self.bal_window.bal_plugin.is_basic_mode():
days_to_deadline = (locktime - datetime.now()).days
offsets = basic_reminder_offsets(days_to_deadline)
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
if basic_mode:
# BASIC mode: use factory defaults (the hidden settings are
# ignored) and the fixed 30/10/1 offsets.
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
threshold = None
num_reminders = 3
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
threshold = self.widgets["threshold"].alarm
days = (locktime - threshold).days
try:
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
num_reminders = int(
self.bal_window.bal_plugin.NUM_REMINDERS.get()
)
except Exception:
count = 3
offsets = compute_reminder_offsets(days, count)
now = BalCalendar.format_time(datetime.now())
num_reminders = 3
heirs_details = "\r\n".join(
f" {heir} - {self.bal_window.heirs[heir][0]}, "
@@ -1268,50 +1059,17 @@ class WillSettingsWidget(QWidget):
for heir in self.bal_window.heirs
)
if self.bal_window.bal_plugin.is_basic_mode():
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
else:
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
event_description = BalCalendar.ical_escape(
f"{raw_description}"
.replace("$wallet_name", str(self.bal_window.wallet))
.replace("$heirs_complete", heirs_details)
return build_ics_reminders(
locktime=locktime,
basic_mode=basic_mode,
description=raw_description,
summary=raw_summary,
wallet_name=str(self.bal_window.wallet),
heirs_details=heirs_details,
version=self.bal_window.bal_plugin.version,
num_reminders=num_reminders,
threshold=threshold,
)
summary_base = (
f"{raw_summary}"
.replace("$wallet_name", str(self.bal_window.wallet))
)
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin/"
f"{self.bal_window.bal_plugin.version}",
]
total = len(offsets)
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(
f"{summary_base} (reminder {idx}/{total})"
)
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{str(self.bal_window.wallet)}-{offset}d",
f"DTSTAMP:{now}",
f"DTSTART:{event_dt}",
f"DTEND:{event_dt}",
f"SUMMARY:{summary}",
f"DESCRIPTION:{event_description}",
"END:VEVENT",
])
lines.append("END:VCALENDAR")
lines = [s.rstrip("\r\n") for s in lines]
return "\r\n".join(lines) + "\r\n"
except Exception as e:
_logger.error(f"failed to generate .ics: {e}")
return None
@@ -1354,40 +1112,20 @@ class PercAmountEdit(BTCAmountEdit):
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
def numbify(self):
# The text sanitisation lives in bal.core.input_rules.
text = self.text().strip()
if text == "!":
self.shortcut.emit()
return
pos = self.cursorPosition()
chars = "0123456789%"
chars += DECIMAL_POINT
s = "".join([i for i in text if i in chars])
if "%" in s:
self.is_perc = True
s = s.replace("%", "")
else:
self.is_perc = False
if DECIMAL_POINT in s:
p = s.find(DECIMAL_POINT)
s = s.replace(DECIMAL_POINT, "")
s = s[:p] + DECIMAL_POINT + s[p : p + 8]
if self.is_perc:
s += "%"
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
self.setText(s)
self.setModified(self.hasFocus())
self.setCursorPosition(pos)
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
try:
text = text.replace(DECIMAL_POINT, ".")
text = text.replace("%", "")
return (Decimal)(text)
except Exception:
return None
return parse_perc_amount(text, DECIMAL_POINT)
def _get_text_from_amount(self, amount):
out = super()._get_text_from_amount(amount)

View File

@@ -14,8 +14,16 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
it with the GUI.
"""
import json
import threading
from electrum.util import MyEncoder
from ...core.checkalive import (
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
from .common import *
from .common import _, _logger # underscore names are not re-exported by "import *"
from .dialogs import (
@@ -123,7 +131,22 @@ class BalWindow:
for k in keys:
del self.will[k]
for wid, w in self.willitems.items():
self.will[wid] = w.to_dict()
d = w.to_dict()
# Store the tx in its serialized form: the wallet DB deep-copies
# every value on save (JsonDB.put), and a live Transaction carrying
# wallet-derived input info (utxo / script_descriptor, which hold a
# threading.RLock) cannot be deep-copied. Serializing to a string
# matches how the will is re-read (get_will -> tx_from_any).
d["tx"] = str(d["tx"])
# Mirror the encoder the wallet DB uses: JsonDB.put returns False
# (silently dropping the will) when a value cannot be serialized,
# so prove it here and fail loudly instead.
try:
json.dumps(d, cls=MyEncoder)
except Exception as e:
_logger.error(f"save_willitems: will {wid} is not serializable: {e!r}")
raise
self.will[wid] = d
def init_will(self):
_logger.info("********************init_____will____________**********")
@@ -614,11 +637,11 @@ class BalWindow:
# against the current moment, i.e. the Check Alive effectively does
# not exist, while the delivery time (locktime) is still fully
# enforced. ADVANCED mode keeps the user-controlled Check Alive
# exactly as before.
if self.bal_plugin.is_basic_mode():
self.date_to_check = datetime.now().timestamp()
else:
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
# exactly as before. The policy itself lives in
# ``bal.core.checkalive.resolve_date_to_check``.
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
)
# found = False
# NOTE: block-height tracking removed (A1) - locktimes are always
# UNIX timestamps now, so we no longer read the current block height
@@ -635,9 +658,10 @@ class BalWindow:
# flow. In BASIC we therefore SKIP this check entirely, so a passed
# check-alive date never forces a postpone/rewrite of the will. The
# delivery time (locktime) is unaffected and still fully enforced.
if (
not self.bal_plugin.is_basic_mode()
and self.date_to_check < datetime.now().timestamp()
# The BASIC/ADVANCED rule lives in
# ``bal.core.checkalive.check_alive_expired``.
if check_alive_expired(
self.bal_plugin.is_basic_mode(), self.date_to_check
):
raise CheckAliveError(self.date_to_check)
@@ -945,7 +969,12 @@ class BalWindow:
targets = Will.only_valid(willitems)
for txid in targets:
wi = willitems[txid]
tx = copy.deepcopy(wi.tx)
# Do NOT deepcopy: the stored tx carries wallet-derived objects
# (utxo / script_descriptor) that hold a threading.RLock, and
# copy.deepcopy raises "cannot pickle '_thread.RLock'". Re-parse
# from the serialized form instead, which is exactly how the will
# is persisted/loaded (WillItem.to_dict -> serialize -> tx_from_any).
tx = Will.get_tx_from_any(str(wi.tx))
if wi.get_status("COMPLETE"):
txs[txid] = tx
continue
@@ -1057,7 +1086,10 @@ class BalWindow:
def on_success(txs):
if txs:
for txid, tx in txs.items():
willitems[txid].tx = copy.deepcopy(tx)
# Re-parse instead of deepcopy: the signed tx may carry
# wallet-derived input info holding a threading.RLock, which
# copy.deepcopy cannot pickle (see sign_transactions above).
willitems[txid].tx = Will.get_tx_from_any(str(tx))
if not external:
self.will[txid] = willitems[txid].to_dict()
try:
@@ -1251,7 +1283,9 @@ class BalWindow:
# very first action in a session). Fall back to "now" so the local
# validity check and the trailing update_all() always have it.
if not hasattr(self, "date_to_check") or self.date_to_check is None:
self.date_to_check = datetime.now().timestamp()
self.date_to_check = resolve_date_to_check(
self.bal_plugin.is_basic_mode(), self.will_settings
)
for wid, wi in imported.items():
if wid in self.willitems:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,95 @@
"""
Tests for ``bal.core.checkalive`` (pure, GUI-free).
Covers the CheckAliveError exception and the BASIC/ADVANCED date_to_check
policy (``resolve_date_to_check`` / ``check_alive_expired``).
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_checkalive.py
"""
import sys
import time
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
# ------------------------------------------------------------------ #
# CheckAliveError
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ #
# resolve_date_to_check
# ------------------------------------------------------------------ #
def test_basic_mode_uses_now():
fake_now = 1_800_000_000.0
assert resolve_date_to_check(True, {}, now=fake_now) == fake_now
def test_advanced_mode_uses_threshold_absolute():
threshold = time.time() + 5 * 86400
settings = {"threshold": threshold}
result = resolve_date_to_check(False, settings)
assert abs(result - threshold) < 1
def test_advanced_mode_parses_relative_threshold():
# "30d" resolves to a future timestamp (midnight-normalised).
settings = {"threshold": "30d"}
result = resolve_date_to_check(False, settings)
assert result > time.time()
# ------------------------------------------------------------------ #
# check_alive_expired
# ------------------------------------------------------------------ #
def test_basic_mode_never_expired():
assert check_alive_expired(True, 1_000_000_000.0) is False
assert check_alive_expired(True, time.time() - 10_000) is False
def test_advanced_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core checkalive tests passed")

View File

@@ -0,0 +1,165 @@
"""
Tests for ``bal.core.input_rules`` (pure, GUI-free).
Covers the locktime acceptance bounds, the RAW locktime sanitisation, and the
percentage-or-amount field normalisation that the Qt editors wrap.
Run:
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_core_input_rules.py
"""
import sys
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.input_rules import ( # noqa: E402 (path insert above)
LockTimeEditor,
normalize_locktime_raw_text,
normalize_perc_amount_text,
parse_perc_amount,
replace_dy_suffixes,
)
# ------------------------------------------------------------------ #
# LockTimeEditor
# ------------------------------------------------------------------ #
def test_locktime_editor_is_acceptable():
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():
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():
assert LockTimeEditor.min_allowed_value >= 0
assert LockTimeEditor.max_allowed_value > LockTimeEditor.min_allowed_value
def test_locktime_editor_get_max_allowed_timestamp():
# Always a valid, fromtimestamp-able value (the Windows 2038 clamp).
ts = LockTimeEditor.get_max_allowed_timestamp()
import datetime
datetime.datetime.fromtimestamp(ts) # must not raise
assert ts <= 2**32 - 1
def test_locktime_editor_subclass_bounds():
class Tight(LockTimeEditor):
min_allowed_value = 1000
max_allowed_value = 2000
assert Tight.is_acceptable_locktime(1500) is True
assert Tight.is_acceptable_locktime(999) is False
assert Tight.is_acceptable_locktime(2001) is False
# ------------------------------------------------------------------ #
# replace_dy_suffixes / RAW locktime sanitisation
# ------------------------------------------------------------------ #
def test_replace_dy_suffixes():
# replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now).
assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix)
assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains
assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_normalize_locktime_raw_empty():
s, isdays, isyears, pos = normalize_locktime_raw_text("", 0)
assert s == ""
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_days():
s, isdays, isyears, pos = normalize_locktime_raw_text("30d", 3)
assert s == "30d"
assert isdays is True
assert isyears is False
def test_normalize_locktime_raw_years():
s, isdays, isyears, pos = normalize_locktime_raw_text("2y", 2)
assert s == "2y"
assert isdays is False
assert isyears is True
def test_normalize_locktime_raw_strips_bad_chars():
s, isdays, isyears, pos = normalize_locktime_raw_text("12a34b56", 8)
assert s == "123456"
assert isdays is False
assert isyears is False
def test_normalize_locktime_raw_single_suffix():
# "1y30d" collapses to a single suffix; "d" wins (processed first), so the
# "y" is dropped along with all letters.
s, isdays, isyears, pos = normalize_locktime_raw_text("1y30d", 5)
assert isdays is True
assert isyears is False
assert s == "130d"
# ------------------------------------------------------------------ #
# Percentage-or-amount normalisation
# ------------------------------------------------------------------ #
def test_perc_normalize_percent():
s, is_perc = normalize_perc_amount_text("50%", ".")
assert is_perc is True
assert s == "50%"
def test_perc_normalize_no_percent():
s, is_perc = normalize_perc_amount_text("123", ".")
assert is_perc is False
assert s == "123"
def test_perc_normalize_strips_invalid():
s, is_perc = normalize_perc_amount_text("1a2b3", ".")
assert s == "123"
def test_perc_normalize_decimal_limit():
# At most 8 decimals after the point.
s, is_perc = normalize_perc_amount_text("1.1234567890123", ".")
assert s == "1.12345678"
def test_perc_parse():
from decimal import Decimal
assert parse_perc_amount("50%", ".") == Decimal(50)
assert parse_perc_amount("123.45", ".") == Decimal("123.45")
assert parse_perc_amount("abc", ".") is None
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core input_rules tests passed")

View File

@@ -0,0 +1,299 @@
"""
Tests for ``bal.core.reminders`` (pure, GUI-free).
Covers the reminder-offset rules (BASIC + ADVANCED), the RFC-5545 helpers
(format_time, ical_escape, fold_ical_line), write_temp_ics, and the unified
``build_ics_reminders`` builder.
This module imports only ``bal.core``, so it runs in the lint venv (no
Electrum/Qt needed) as well as the runtime venv:
Run:
python3 tests/test_core_reminders.py
"""
import os
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.core.reminders import ( # noqa: E402 (path insert above)
BASIC_REMINDER_OFFSETS,
basic_reminder_offsets,
build_ics_reminders,
compute_reminder_offsets,
fold_ical_line,
format_time,
ical_escape,
write_temp_ics,
)
# ------------------------------------------------------------------ #
# compute_reminder_offsets - distribution rules
# ------------------------------------------------------------------ #
def test_offsets_long_period_three_reminders():
"""30-day period, 3 reminders: spread out, all before the deadline."""
offsets = compute_reminder_offsets(30, 3)
assert len(offsets) == 3
# All strictly before the deadline (offset >= 1 means "n days before end").
assert all(o >= 1 for o in offsets)
# Sorted earliest-first (largest offset first).
assert offsets == sorted(offsets, reverse=True)
# One reminder near the start, one near the end.
assert max(offsets) == 30
assert min(offsets) == 1
def test_offsets_capped_at_one_per_day():
"""Short period: at most one reminder per available day."""
# 2 days but 3 requested -> only 2 reminders, one per day.
assert compute_reminder_offsets(2, 3) == [2, 1]
# 1 day but 3 requested -> a single reminder, the day before the deadline.
assert compute_reminder_offsets(1, 3) == [1]
def test_offsets_empty_when_no_room():
"""No reminders when there is no day before the deadline."""
assert compute_reminder_offsets(0, 3) == []
assert compute_reminder_offsets(-5, 3) == []
# A non-positive count also yields nothing.
assert compute_reminder_offsets(30, 0) == []
def test_offsets_never_exceed_requested_count():
"""The number of reminders never exceeds the requested count (max 5)."""
offsets = compute_reminder_offsets(100, 5)
assert len(offsets) == 5
assert all(o >= 1 for o in offsets)
# Distinct offsets only (no duplicate alarms on the same day).
assert len(set(offsets)) == len(offsets)
def test_offsets_single_reminder_is_day_before_deadline():
"""A single requested reminder fires one day before the deadline."""
assert compute_reminder_offsets(30, 1) == [1]
# ------------------------------------------------------------------ #
# basic_reminder_offsets - fixed BASIC-mode offsets
# ------------------------------------------------------------------ #
def test_basic_offsets_full_period():
"""A far-away delivery keeps all three fixed offsets (30, 10, 1)."""
assert basic_reminder_offsets(365) == [30, 10, 1]
def test_basic_offsets_truncated_by_horizon():
"""Offsets beyond the delivery horizon are dropped."""
assert basic_reminder_offsets(20) == [10, 1]
assert basic_reminder_offsets(5) == [1]
def test_basic_offsets_empty():
"""No future offsets when the delivery is less than a day away."""
assert basic_reminder_offsets(0) == []
assert basic_reminder_offsets(-10) == []
def test_basic_offsets_always_from_fixed_set():
"""Every returned offset is one of the fixed BASIC offsets."""
for horizon in range(0, 40):
result = basic_reminder_offsets(horizon)
assert set(result).issubset(set(BASIC_REMINDER_OFFSETS))
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=tz)
assert format_time(dt) == "20250601T103045Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
assert ical_escape("hello world") == "hello world"
def test_ical_escape_backslash():
assert ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\nline2"
result = ical_escape(text)
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
assert ical_escape("\\;,") == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
assert fold_ical_line("SUMMARY:Test") == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "SUMMARY:" + "a" * 100
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
def test_fold_ical_line_unicode():
line = "SUMMARY:" + "é" * 50
result = fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert all(len(p.encode("utf-8")) <= 75 for p in parts)
assert "".join(parts) == line
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = 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 = write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ #
# build_ics_reminders - unified builder
# ------------------------------------------------------------------ #
_LOCKTIME = datetime(2026, 7, 23, 9, 0, 0, tzinfo=timezone.utc)
def _common_kwargs():
return dict(
locktime=_LOCKTIME,
description="BAL will of $wallet_name\r\n$heirs_complete",
summary="BAL - Will execution of $wallet_name",
wallet_name="karen7",
heirs_details=" alice - bc1qalice, bob - bc1qbob",
version="0.6.1",
)
def test_build_ics_advanced_separate_events():
"""ADVANCED: 30-day period -> offsets [30, 16, 1], one VEVENT each."""
content = build_ics_reminders(
basic_mode=False,
num_reminders=3,
now=datetime(2026, 7, 1, 0, 0, 0, tzinfo=timezone.utc),
threshold=datetime(2026, 6, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
assert content.startswith("BEGIN:VCALENDAR")
assert content.endswith("\r\n")
lines = content.split("\r\n")
assert lines.count("BEGIN:VEVENT") == 3
assert lines.count("END:VEVENT") == 3
assert "BEGIN:VALARM" not in lines
# Unique UIDs, numbered summaries, last event one day before the deadline.
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == len(set(uids)) == 3
assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines)
last_dt = format_time(_LOCKTIME - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines
# Template substitution; the CRLF inside the description stays as a line
# break in the folded DESCRIPTION (no literal "\n" escaping in ical_escape).
desc = [ln for ln in lines if ln.startswith("DESCRIPTION:")]
assert desc and "karen7" in desc[0]
assert any(ln.lstrip().startswith("alice") for ln in lines)
def test_build_ics_basic_uses_fixed_offsets():
"""BASIC: fixed offsets (30, 10, 1) filtered to the future, threshold not
required."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 6, 1, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is not None
lines = content.split("\r\n")
uids = [ln for ln in lines if ln.startswith("UID:")]
assert len(uids) == 3
# The first event is the 30-day offset.
assert "bal-karen7-30d" in uids[0]
def test_build_ics_returns_none_when_no_reminders():
"""No future reminder -> None (the aligned behavior), not an empty file."""
content = build_ics_reminders(
basic_mode=True,
now=datetime(2026, 7, 23, 0, 0, 0, tzinfo=timezone.utc),
**_common_kwargs(),
)
assert content is None
def test_build_ics_advanced_requires_threshold():
"""ADVANCED mode without a threshold is a programming error."""
try:
build_ics_reminders(basic_mode=False, **_common_kwargs())
except ValueError:
return
raise AssertionError("expected ValueError for missing threshold in ADVANCED")
# ------------------------------------------------------------------ #
# Main
# ------------------------------------------------------------------ #
if __name__ == "__main__":
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All core reminders tests passed")

View File

@@ -10,13 +10,12 @@ Covered behaviour:
most one reminder per available day, and never returns more reminders than
requested.
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
the other GUI tests.
``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
so these tests run without Qt (or Electrum) at all.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_d_alarms.py -q
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_group_d_alarms.py
"""
import os
@@ -25,7 +24,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.plugin_base import BalConfig
from bal.gui.qt.widgets import compute_reminder_offsets
from bal.core.reminders import compute_reminder_offsets
# ------------------------------------------------------------------ #
# Mocks

View File

@@ -37,10 +37,14 @@ import pytest # pyright: ignore[reportMissingImports]
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.core.heirs import Heirs
from bal.core.reminders import (
compute_reminder_offsets,
format_time,
ical_escape,
write_temp_ics,
)
from bal.core.will import HeirNotFoundException, Will, WillItem
from bal.core.willexecutors import Willexecutors
from bal.gui.qt.calendar import BalCalendar
from bal.gui.qt.widgets import compute_reminder_offsets
# A valid serialized Bitcoin transaction hex (1 input + 1 P2PKH output,
# version 2). Reused from test_core_will.py so WillItem can parse a real tx.
@@ -196,8 +200,8 @@ def test_e1_build_separate_events_for_karen7():
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
for idx, offset in enumerate(offsets, start=1):
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
event_dt = format_time(locktime - timedelta(days=offset))
summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
lines.extend([
"BEGIN:VEVENT",
f"UID:bal-{wallet}-{offset}d",
@@ -219,7 +223,7 @@ def test_e1_build_separate_events_for_karen7():
assert any("(reminder 1/3)" in ln for ln in lines)
assert any("(reminder 3/3)" in ln for ln in lines)
# The LAST event (offset 1) sits one day before the locktime.
last_dt = BalCalendar.format_time(locktime - timedelta(days=1))
last_dt = format_time(locktime - timedelta(days=1))
assert f"DTSTART:{last_dt}" in lines
@@ -227,7 +231,7 @@ def test_e1_event_description_escaping():
"""Special iCalendar characters in karen7's event text are escaped so
the .ics file stays valid."""
raw = "Wallet karen7; heirs: alice, bob"
escaped = BalCalendar.ical_escape(raw)
escaped = ical_escape(raw)
assert "\\;" in escaped # semicolon escaped
assert "\\," in escaped # comma escaped
assert "karen7" in escaped
@@ -245,7 +249,7 @@ def test_e1_write_temp_ics_for_karen7():
"END:VEVENT\r\n"
"END:VCALENDAR\r\n"
)
path = BalCalendar.write_temp_ics(content)
path = write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:

View File

@@ -10,13 +10,12 @@ that would fall in the past.
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
drives that decision.
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
the other GUI tests.
``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
so these tests run without Qt (or Electrum) at all.
Run:
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
python3 -m pytest tests/test_group_g_basic_calendar.py -q
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_group_g_basic_calendar.py
"""
import os
@@ -24,7 +23,7 @@ import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.widgets import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
from bal.core.reminders import BASIC_REMINDER_OFFSETS, basic_reminder_offsets
def test_basic_offsets_all_future():

View File

@@ -3,116 +3,99 @@ Tests for the "BASIC mode dynamic Check Alive" fix (Group I).
Background (reported by the owner): with the plugin left in BASIC mode (the
default), the "Check Alive" (threshold) field is hidden from the user and
stays stuck at its old/default value (roughly "today + 11 months", i.e. one
year minus the default 30-day margin). If the user then anticipates the
delivery time (locktime) to something earlier than that stale threshold - e.g.
"in 1 month" - two different checks that compare locktime against
``date_to_check`` (the resolved threshold) would incorrectly treat the will as
"expired"/"invalid", even though the whole Check Alive concept is supposed to
be inert in BASIC mode.
stays stuck at its old/default value. If the user then anticipates the
delivery time (locktime) to something earlier than that stale threshold, the
checks that compare locktime against ``date_to_check`` would incorrectly treat
the will as "expired"/"invalid", even though the whole Check Alive concept is
supposed to be inert in BASIC mode.
The fix (``BalWindow.compute_date_to_check``) makes ``date_to_check`` track
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it,
so it is always < locktime by construction. In ADVANCED mode nothing changes:
the stored threshold is used as-is.
The real fix lives in ``BalWindow.init_class_variables`` (window.py), which
sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
pure policy in ``bal.core.checkalive``:
These tests call the real production method directly (no GUI/Electrum wallet
needed - it is a plain ``@staticmethod``), so they exercise the exact code
used at runtime rather than a re-implementation.
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
* ``check_alive_expired(is_basic_mode, date_to_check)`` - whether the
Check-Alive guard should fire (never in BASIC).
These tests exercise the exact code used at runtime (no GUI/Electrum wallet
needed, and no Qt import).
Run:
PYTHONPATH=electrum-src python3 -m pytest tests/test_group_i_basic_checkalive.py -q
source /home/steal/devel/bal/electrum/env/bin/activate
python3 tests/test_group_i_basic_checkalive.py
"""
import os
import sys
import time
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.window import BalWindow # noqa: E402 (path insert above)
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
def test_offset_is_two_hours():
"""Owner-approved margin: exactly 2 hours."""
assert OFFSET == 2 * 60 * 60
def _relative_days_to_midnight_timestamp(days):
"""Reproduce Util.parse_locktime_string's own normalisation: "now + N
days", truncated to midnight. Used to compute the expected value in
tests without duplicating the parsing logic itself."""
from datetime import datetime, timedelta
now = datetime.now()
return (
(now + timedelta(days=days))
.replace(hour=0, minute=0, second=0, microsecond=0)
.timestamp()
from bal.core.checkalive import ( # noqa: E402 (path insert above)
CheckAliveError,
check_alive_expired,
resolve_date_to_check,
)
def test_basic_mode_relative_locktime_one_year():
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier."""
result = BalWindow.compute_date_to_check(True, "1y", "30d")
expected_locktime = _relative_days_to_midnight_timestamp(365)
assert abs(result - (expected_locktime - OFFSET)) < 5
def test_basic_mode_resolves_to_now():
"""BASIC mode: date_to_check is "now", never the stale stored threshold."""
fake_now = 1_800_000_000.0
result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
assert result == fake_now
def test_basic_mode_anticipated_delivery_stays_consistent():
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
NOT the old ~11-month threshold - this is the exact bug scenario
reported by the owner."""
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
result = BalWindow.compute_date_to_check(
True, anticipated_locktime, stale_threshold
)
locktime_ts = _relative_days_to_midnight_timestamp(30)
# date_to_check must be (delivery - 2h), always strictly before delivery.
assert result < locktime_ts
assert abs((locktime_ts - OFFSET) - result) < 5
def test_basic_mode_ignores_stored_threshold():
"""BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
does NOT jump to the old ~11-month threshold - this is the exact bug
scenario reported by the owner."""
stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
fake_now = 1_800_000_000.0
result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
assert result == fake_now
def test_basic_mode_date_to_check_always_before_locktime():
"""Regression guard for the reported bug: whatever the delivery date is
(even very close to "now"), date_to_check must stay before it."""
for relative_locktime in ("1d", "7d", "30d", "90d", "365d"):
result = BalWindow.compute_date_to_check(True, relative_locktime, "30d")
parsed_locktime = _relative_days_to_midnight_timestamp(
int(relative_locktime[:-1])
)
assert result < parsed_locktime, (
f"date_to_check ({result}) should be before locktime "
f"({parsed_locktime}) for locktime={relative_locktime}"
)
def test_advanced_mode_uses_stored_threshold_unchanged():
"""ADVANCED mode: behaviour must stay exactly as before this fix - the
stored threshold is used as-is, regardless of the locktime value."""
def test_advanced_mode_uses_stored_threshold():
"""ADVANCED mode: behaviour stays exactly as before - the stored threshold
is used as-is, regardless of the locktime value."""
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen value
result = BalWindow.compute_date_to_check(False, "30d", absolute_threshold)
result = resolve_date_to_check(False, {"threshold": absolute_threshold})
assert abs(result - absolute_threshold) < 1
def test_basic_mode_falls_back_on_unparsable_locktime():
"""If the locktime can't be parsed for any reason, BASIC mode must not
crash: it falls back to the stored threshold, same as ADVANCED."""
absolute_threshold = time.time() + 5 * 86400
result = BalWindow.compute_date_to_check(
True, {"not": "a valid locktime"}, absolute_threshold
)
assert abs(result - absolute_threshold) < 1
def test_advanced_mode_parses_relative_threshold():
"""ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
result = resolve_date_to_check(False, {"threshold": "30d"})
assert result > time.time()
def test_basic_mode_never_expired():
"""BASIC mode can never be "expired": a passed check-alive date must never
force a postpone/rewrite of the will."""
assert check_alive_expired(True, time.time() - 10_000) is False
assert check_alive_expired(True, 1_000_000_000.0) is False
def test_advanced_mode_expired_when_past():
assert check_alive_expired(False, time.time() - 10_000) is True
def test_advanced_mode_not_expired_when_future():
assert check_alive_expired(False, time.time() + 10_000) is False
def test_basic_mode_never_raises_check_alive_error():
"""Regression guard for the reported bug: whatever the delivery date,
BASIC mode never raises CheckAliveError (the postpone/invalidate trigger)."""
date_to_check = resolve_date_to_check(True, {"threshold": "30d"})
assert not check_alive_expired(True, date_to_check)
# If it did fire, this is the exact exception that would be raised.
assert issubclass(CheckAliveError, Exception)
if __name__ == "__main__":
test_offset_is_two_hours()
test_basic_mode_relative_locktime_one_year()
test_basic_mode_anticipated_delivery_stays_consistent()
test_basic_mode_date_to_check_always_before_locktime()
test_advanced_mode_uses_stored_threshold_unchanged()
test_basic_mode_falls_back_on_unparsable_locktime()
print("All test_group_i_basic_checkalive tests passed.")
for name in sorted(dir()):
if name.startswith("test_"):
globals()[name]()
print(f" [OK] {name}")
print("[OK] All test_group_i_basic_checkalive tests passed.")

View File

@@ -1,126 +1,25 @@
"""
Tests for ``bal.gui.qt.calendar``.
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
write_temp_ics, open_with_default_app.
Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
"""
import os
import sys
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"

View File

@@ -1,7 +1,8 @@
"""
Tests for ``bal.gui.qt.common``.
Covers shown_cv, CheckAliveError, add_widget, log_error, export_meta_gui.
Covers shown_cv, add_widget, log_error, export_meta_gui (and the
CheckAliveError exception, which now lives in ``bal.core.checkalive``).
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
@@ -44,23 +45,30 @@ def test_shown_cv_roundtrip():
# ------------------------------------------------------------------ #
# CheckAliveError
# CheckAliveError (moved to bal.core.checkalive; kept tested via the
# common->core import chain)
# ------------------------------------------------------------------ #
def test_check_alive_error_default():
err = common.CheckAliveError(1000000)
from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
assert err.timestamp_to_check == 1000000
def test_check_alive_error_str():
err = common.CheckAliveError(1000000)
from bal.core.checkalive import CheckAliveError
err = CheckAliveError(1000000)
s = str(err)
assert "Check alive expired" in s
assert "1970" in s
def test_check_alive_error_subclass():
assert issubclass(common.CheckAliveError, Exception)
from bal.core.checkalive import CheckAliveError
assert issubclass(CheckAliveError, Exception)
# ------------------------------------------------------------------ #

View File

@@ -118,23 +118,25 @@ def test_locktime_editor_min_max():
def test_locktime_raw_edit_replace_str():
# replace_str only strips the day ("d") and year ("y") suffixes. The
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
# anymore (locktimes are always UNIX timestamps now).
from bal.gui.qt.widgets import LockTimeRawEdit
assert LockTimeRawEdit.replace_str("123d") == "123"
assert LockTimeRawEdit.replace_str("456y") == "456"
# anymore (locktimes are always UNIX timestamps now). The helper moved to
# bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
from bal.core.input_rules import replace_dy_suffixes
assert replace_dy_suffixes("123d") == "123"
assert replace_dy_suffixes("456y") == "456"
# "b" is left untouched (no longer a recognised suffix)
assert LockTimeRawEdit.replace_str("789b") == "789b"
assert replace_dy_suffixes("789b") == "789b"
# only d/y are stripped; a stray "b" remains
assert LockTimeRawEdit.replace_str("12d34y56b") == "123456b"
assert replace_dy_suffixes("12d34y56b") == "123456b"
def test_locktime_raw_edit_checkbdy():
from bal.gui.qt.widgets import LockTimeRawEdit
# checkbdy moved to bal.core.input_rules (_checkbdy).
from bal.core.input_rules import _checkbdy
# character at expected position matches appendix
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d")
pos, s = _checkbdy("123d", 4, "d")
assert s == "123d"
# character at expected position does not match
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d")
pos, s = _checkbdy("123x", 4, "d")
assert s == "123x"

View File

@@ -305,10 +305,14 @@ def _make_merge_fake(willitems):
wallet=FakeWallet(),
bal_window=None,
date_to_check=1700000000,
will_settings={"threshold": 1700000000},
bal_plugin=SimpleNamespace(
HISTORY_LABEL=SimpleNamespace(
get=lambda: "BAL will history ({willexecutor})"
)
),
# BASIC is the default user type; merge_will resolves date_to_check
# through bal.core.checkalive when it is missing.
is_basic_mode=lambda: True,
),
update_all=lambda: calls.append("update_all"),
)