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:
71
bal/core/checkalive.py
Normal file
71
bal/core/checkalive.py
Normal 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
|
||||||
@@ -630,7 +630,19 @@ class Heirs(dict, Logger):
|
|||||||
def buildTransactions(
|
def buildTransactions(
|
||||||
self, bal_plugin, wallet, tx_fees=None, utxos=None, from_locktime=0
|
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:
|
if len(self) <= 0:
|
||||||
_logger.info("while building transactions there was no heirs")
|
_logger.info("while building transactions there was no heirs")
|
||||||
return
|
return
|
||||||
@@ -877,16 +889,22 @@ class Heirs(dict, Logger):
|
|||||||
return (address, amount, locktime)
|
return (address, amount, locktime)
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _validate(data, timestamp_to_check=False):
|
def _validate(data, timestamp_to_check=False, persist=True):
|
||||||
|
|
||||||
for k, v in list(data.items()):
|
for k, v in list(data.items()):
|
||||||
if k == "heirs":
|
if k == "heirs":
|
||||||
return Heirs._validate(v, timestamp_to_check)
|
return Heirs._validate(v, timestamp_to_check, persist)
|
||||||
try:
|
try:
|
||||||
Heirs.validate_heir(k, v, timestamp_to_check)
|
Heirs.validate_heir(k, v, timestamp_to_check)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
_logger.info(f"exception heir removed {e}")
|
_logger.info(f"exception heir removed {e}")
|
||||||
|
if persist:
|
||||||
data.pop(k)
|
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
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
193
bal/core/input_rules.py
Normal file
193
bal/core/input_rules.py
Normal 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
|
||||||
@@ -306,7 +306,10 @@ class BalPlugin(BasePlugin):
|
|||||||
config, "bal_event_summary", "BAL -Will execution of $wallet_name"
|
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(
|
self.WILLEXECUTORS = BalConfig(
|
||||||
config,
|
config,
|
||||||
"bal_willexecutors",
|
"bal_willexecutors",
|
||||||
@@ -325,7 +328,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
"address": "tb1qp5tmrvtm6dmz23mzkf55n5d53xh39wt0gwpp5m",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -334,7 +337,7 @@ class BalPlugin(BasePlugin):
|
|||||||
"base_fee": 100000,
|
"base_fee": 100000,
|
||||||
"status": "New",
|
"status": "New",
|
||||||
"info": "Bitcoin After Life Will Executor",
|
"info": "Bitcoin After Life Will Executor",
|
||||||
"address": "bcrt1qa5cntu4hgadw8zd3n6sq2nzjy34sxdtd9u0gp7",
|
"address": "tb1qfj5ewmczg8ck2z0eeff6uysdrxd4qdy2ltrx5a",
|
||||||
"selected": True,
|
"selected": True,
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
228
bal/core/reminders.py
Normal file
228
bal/core/reminders.py
Normal 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"
|
||||||
@@ -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
|
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
|
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.
|
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.QtGui import QAction
|
||||||
from PyQt6.QtWidgets import QToolButton
|
from PyQt6.QtWidgets import QToolButton
|
||||||
|
|
||||||
|
from ...core.reminders import write_temp_ics
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
|
|
||||||
@@ -57,7 +62,7 @@ class BalCalendarButton(QToolButton):
|
|||||||
try:
|
try:
|
||||||
content = self._ics_provider()
|
content = self._ics_provider()
|
||||||
if content:
|
if content:
|
||||||
self._calendar_temp_path = BalCalendar.write_temp_ics(content)
|
self._calendar_temp_path = write_temp_ics(content)
|
||||||
else:
|
else:
|
||||||
self._calendar_temp_path = None
|
self._calendar_temp_path = None
|
||||||
self._bal_window.show_warning(
|
self._bal_window.show_warning(
|
||||||
@@ -149,13 +154,6 @@ class BalCalendarButton(QToolButton):
|
|||||||
|
|
||||||
|
|
||||||
class BalCalendar:
|
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
|
@staticmethod
|
||||||
def open_with_default_app(calendar_app, path):
|
def open_with_default_app(calendar_app, path):
|
||||||
_logger.debug("opening calendar app")
|
_logger.debug("opening calendar app")
|
||||||
@@ -184,37 +182,3 @@ class BalCalendar:
|
|||||||
if os.path.isdir(desktop):
|
if os.path.isdir(desktop):
|
||||||
return desktop
|
return desktop
|
||||||
return home
|
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)
|
|
||||||
|
|||||||
@@ -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:`add_widget` - add a labelled widget (plus optional help) to a grid.
|
||||||
* :func:`log_error` - format an exception traceback for a dialog.
|
* :func:`log_error` - format an exception traceback for a dialog.
|
||||||
* :func:`export_meta_gui` - export plugin metadata to a JSON file.
|
* :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
|
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):
|
def log_error(exec_info, window=None):
|
||||||
"""Log an error and optionally show it.
|
"""Log an error and optionally show it.
|
||||||
|
|
||||||
|
|||||||
@@ -19,14 +19,14 @@ use them (see ``lists`` imports below).
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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 *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .widgets import (
|
from .widgets import (
|
||||||
WillSettingsWidget,
|
WillSettingsWidget,
|
||||||
WillWidget,
|
WillWidget,
|
||||||
basic_reminder_offsets,
|
|
||||||
compute_reminder_offsets,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@@ -1598,7 +1598,7 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
|
|
||||||
def _ics_provider(self):
|
def _ics_provider(self):
|
||||||
"""Return the .ics content for the current will data."""
|
"""Return the .ics content for the current will data."""
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime
|
||||||
|
|
||||||
try:
|
try:
|
||||||
locktime_ts = Util.parse_locktime_string(
|
locktime_ts = Util.parse_locktime_string(
|
||||||
@@ -1606,22 +1606,25 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
)
|
)
|
||||||
locktime = datetime.fromtimestamp(locktime_ts)
|
locktime = datetime.fromtimestamp(locktime_ts)
|
||||||
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||||
days_to_deadline = (locktime - datetime.now()).days
|
if basic_mode:
|
||||||
offsets = basic_reminder_offsets(days_to_deadline)
|
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:
|
else:
|
||||||
threshold_ts = BalTimestamp(
|
threshold_ts = BalTimestamp(
|
||||||
self.bal_window.will_settings["threshold"]
|
self.bal_window.will_settings["threshold"]
|
||||||
).to_timestamp()
|
).to_timestamp()
|
||||||
threshold = datetime.fromtimestamp(threshold_ts)
|
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:
|
try:
|
||||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
num_reminders = int(
|
||||||
|
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
count = 3
|
num_reminders = 3
|
||||||
offsets = compute_reminder_offsets(days, count)
|
|
||||||
|
|
||||||
now = BalCalendar.format_time(datetime.now())
|
|
||||||
|
|
||||||
heirs_details = "\r\n".join(
|
heirs_details = "\r\n".join(
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||||
@@ -1629,56 +1632,21 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
for heir in self.bal_window.heirs
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
# ToDo #2: when no reminder falls in the future (the delivery date is
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
# too close or already passed), build_ics_reminders returns None so
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
# the caller shows a clear warning instead of producing an empty,
|
||||||
else:
|
# seemingly-broken .ics file.
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
return build_ics_reminders(
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
locktime=locktime,
|
||||||
|
basic_mode=basic_mode,
|
||||||
event_description = BalCalendar.ical_escape(
|
description=raw_description,
|
||||||
f"{raw_description}"
|
summary=raw_summary,
|
||||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
wallet_name=str(self.bal_window.wallet),
|
||||||
.replace("$heirs_complete", heirs_details)
|
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:
|
except Exception as e:
|
||||||
_logger.error(f"failed to generate .ics: {e}")
|
_logger.error(f"failed to generate .ics: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1706,7 +1674,12 @@ class BalBuildWillDialog(BalDialog):
|
|||||||
try:
|
try:
|
||||||
if txs := self.bal_window.sign_transactions(password):
|
if txs := self.bal_window.sign_transactions(password):
|
||||||
for txid, tx in txs.items():
|
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.bal_window.save_willitems()
|
||||||
self.msg_set_signing(self.msg_ok())
|
self.msg_set_signing(self.msg_ok())
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ Contents:
|
|||||||
|
|
||||||
from typing import TYPE_CHECKING
|
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 .calendar import BalCalendar, BalCalendarButton
|
||||||
from .common import *
|
from .common import *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
@@ -28,86 +35,6 @@ if TYPE_CHECKING:
|
|||||||
from .window import BalWindow
|
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):
|
class ClickableLabel(QLabel):
|
||||||
doubleClicked = pyqtSignal()
|
doubleClicked = pyqtSignal()
|
||||||
|
|
||||||
@@ -219,39 +146,12 @@ class BalTxFeesWidget(QWidget):
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
class _LockTimeEditor:
|
class _LockTimeEditor(LockTimeEditor):
|
||||||
min_allowed_value = NLOCKTIME_MIN
|
"""Qt-side locktime editor base.
|
||||||
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
|
|
||||||
|
|
||||||
|
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):
|
class BalTimeEditWidget(QWidget, _LockTimeEditor):
|
||||||
@@ -572,51 +472,16 @@ class LockTimeRawEdit(QLineEdit, _LockTimeEditor):
|
|||||||
self.isyears = False
|
self.isyears = False
|
||||||
self.time_edit = time_edit
|
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):
|
def numbify(self):
|
||||||
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
# Only digits plus the day ("d") and year ("y") suffixes are accepted.
|
||||||
# The block-height suffix ("b") was removed (A1): locktimes are always
|
# The block-height suffix ("b") was removed (A1): locktimes are always
|
||||||
# UNIX timestamps now, so block-relative input is no longer allowed.
|
# UNIX timestamps now, so block-relative input is no longer allowed.
|
||||||
|
# The sanitisation itself lives in bal.core.input_rules.
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
chars = "0123456789dy"
|
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
pos = len("".join([i for i in text[:pos] if i in chars]))
|
s, isdays, isyears, pos = normalize_locktime_raw_text(text, pos)
|
||||||
s = "".join([i for i in text if i in chars])
|
self.isdays = isdays
|
||||||
self.isdays = False
|
self.isyears = isyears
|
||||||
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"
|
|
||||||
self.blockSignals(True)
|
self.blockSignals(True)
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.blockSignals(False)
|
self.blockSignals(False)
|
||||||
@@ -1073,11 +938,12 @@ class WillSettingsWidget(QWidget):
|
|||||||
sees several distinct appointments in their calendar.
|
sees several distinct appointments in their calendar.
|
||||||
|
|
||||||
The number of events is read from the NUM_REMINDERS setting (default 3,
|
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
|
capped at 5 by the settings dialog). Their dates are computed by
|
||||||
``compute_reminder_offsets``: the offsets are spread uniformly across the
|
``bal.core.reminders.build_ics_reminders`` (offsets spread uniformly
|
||||||
check-alive period and the LAST event always falls one day before the
|
across the check-alive period; the LAST event always falls one day
|
||||||
delivery deadline (locktime). If the period is shorter than the
|
before the delivery deadline / locktime). If the period is shorter than
|
||||||
requested number of reminders, at most one event per day is produced.
|
the requested number of reminders, at most one event per day is
|
||||||
|
produced.
|
||||||
|
|
||||||
Each event:
|
Each event:
|
||||||
* is placed on ``locktime - offset`` days (its own visible date);
|
* 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"
|
path the user picks in the save dialog (default name "BAL_will_event.ics"
|
||||||
on the Desktop).
|
on the Desktop).
|
||||||
"""
|
"""
|
||||||
now = BalCalendar.format_time(datetime.now())
|
# The .ics content (offsets, escaping, folding, VEVENT layout) is built
|
||||||
|
# by the pure ``build_ics_reminders`` in bal.core.reminders. When no
|
||||||
# locktime = delivery deadline. It is exposed by the date widget as
|
# reminder falls in the future it returns None (ToDo #2) and the user is
|
||||||
# ``.alarm`` and already reflects the (possibly auto-anticipated) minimum
|
# warned instead of getting an empty-looking file.
|
||||||
# transaction locktime, so the calendar uses the correct delivery date.
|
ics_content = self._ics_provider()
|
||||||
locktime = self.widgets["locktime"].alarm
|
if not ics_content:
|
||||||
|
self.bal_window.show_warning(
|
||||||
# BASIC vs ADVANCED reminder strategy.
|
_(
|
||||||
#
|
"No reminders were saved: the delivery date is too "
|
||||||
# In ADVANCED mode the reminders are spread uniformly across the
|
"close (or already passed)"
|
||||||
# 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
|
|
||||||
)
|
)
|
||||||
# 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
|
# Keep the generated .ics in a temp file; it is copied to the path the
|
||||||
# user picks below.
|
# 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
|
# 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
|
# plugin no longer tries to open it with a calendar app). The save
|
||||||
@@ -1241,26 +1027,31 @@ class WillSettingsWidget(QWidget):
|
|||||||
def _ics_provider(self):
|
def _ics_provider(self):
|
||||||
"""Return the .ics content for the current locktime/threshold values.
|
"""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:
|
try:
|
||||||
locktime = self.widgets["locktime"].alarm
|
locktime = self.widgets["locktime"].alarm
|
||||||
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
basic_mode = self.bal_window.bal_plugin.is_basic_mode()
|
||||||
days_to_deadline = (locktime - datetime.now()).days
|
if basic_mode:
|
||||||
offsets = basic_reminder_offsets(days_to_deadline)
|
# 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:
|
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
|
threshold = self.widgets["threshold"].alarm
|
||||||
days = (locktime - threshold).days
|
|
||||||
try:
|
try:
|
||||||
count = int(self.bal_window.bal_plugin.NUM_REMINDERS.get())
|
num_reminders = int(
|
||||||
|
self.bal_window.bal_plugin.NUM_REMINDERS.get()
|
||||||
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
count = 3
|
num_reminders = 3
|
||||||
offsets = compute_reminder_offsets(days, count)
|
|
||||||
|
|
||||||
now = BalCalendar.format_time(datetime.now())
|
|
||||||
|
|
||||||
heirs_details = "\r\n".join(
|
heirs_details = "\r\n".join(
|
||||||
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
f" {heir} - {self.bal_window.heirs[heir][0]}, "
|
||||||
@@ -1268,50 +1059,17 @@ class WillSettingsWidget(QWidget):
|
|||||||
for heir in self.bal_window.heirs
|
for heir in self.bal_window.heirs
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.bal_window.bal_plugin.is_basic_mode():
|
return build_ics_reminders(
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.default
|
locktime=locktime,
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.default
|
basic_mode=basic_mode,
|
||||||
else:
|
description=raw_description,
|
||||||
raw_description = self.bal_window.bal_plugin.EVENT_DESCRIPTION.get()
|
summary=raw_summary,
|
||||||
raw_summary = self.bal_window.bal_plugin.EVENT_SUMMARY.get()
|
wallet_name=str(self.bal_window.wallet),
|
||||||
|
heirs_details=heirs_details,
|
||||||
event_description = BalCalendar.ical_escape(
|
version=self.bal_window.bal_plugin.version,
|
||||||
f"{raw_description}"
|
num_reminders=num_reminders,
|
||||||
.replace("$wallet_name", str(self.bal_window.wallet))
|
threshold=threshold,
|
||||||
.replace("$heirs_complete", heirs_details)
|
|
||||||
)
|
)
|
||||||
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:
|
except Exception as e:
|
||||||
_logger.error(f"failed to generate .ics: {e}")
|
_logger.error(f"failed to generate .ics: {e}")
|
||||||
return None
|
return None
|
||||||
@@ -1354,40 +1112,20 @@ class PercAmountEdit(BTCAmountEdit):
|
|||||||
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
super().__init__(decimal_point, is_int, parent, max_amount=max_amount)
|
||||||
|
|
||||||
def numbify(self):
|
def numbify(self):
|
||||||
|
# The text sanitisation lives in bal.core.input_rules.
|
||||||
text = self.text().strip()
|
text = self.text().strip()
|
||||||
if text == "!":
|
if text == "!":
|
||||||
self.shortcut.emit()
|
self.shortcut.emit()
|
||||||
return
|
return
|
||||||
pos = self.cursorPosition()
|
pos = self.cursorPosition()
|
||||||
chars = "0123456789%"
|
s, self.is_perc = normalize_perc_amount_text(text, DECIMAL_POINT)
|
||||||
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 += "%"
|
|
||||||
|
|
||||||
self.setText(s)
|
self.setText(s)
|
||||||
self.setModified(self.hasFocus())
|
self.setModified(self.hasFocus())
|
||||||
self.setCursorPosition(pos)
|
self.setCursorPosition(pos)
|
||||||
|
|
||||||
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
def _get_amount_from_text(self, text: str) -> Union[None, Decimal, int]:
|
||||||
try:
|
return parse_perc_amount(text, DECIMAL_POINT)
|
||||||
text = text.replace(DECIMAL_POINT, ".")
|
|
||||||
text = text.replace("%", "")
|
|
||||||
return (Decimal)(text)
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _get_text_from_amount(self, amount):
|
def _get_text_from_amount(self, amount):
|
||||||
out = super()._get_text_from_amount(amount)
|
out = super()._get_text_from_amount(amount)
|
||||||
|
|||||||
@@ -14,8 +14,16 @@ The actual Bitcoin logic lives in :mod:`bal.core`; this class only coordinates
|
|||||||
it with the GUI.
|
it with the GUI.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
import threading
|
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 *
|
||||||
from .common import _, _logger # underscore names are not re-exported by "import *"
|
from .common import _, _logger # underscore names are not re-exported by "import *"
|
||||||
from .dialogs import (
|
from .dialogs import (
|
||||||
@@ -123,7 +131,22 @@ class BalWindow:
|
|||||||
for k in keys:
|
for k in keys:
|
||||||
del self.will[k]
|
del self.will[k]
|
||||||
for wid, w in self.willitems.items():
|
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):
|
def init_will(self):
|
||||||
_logger.info("********************init_____will____________**********")
|
_logger.info("********************init_____will____________**********")
|
||||||
@@ -614,11 +637,11 @@ class BalWindow:
|
|||||||
# against the current moment, i.e. the Check Alive effectively does
|
# against the current moment, i.e. the Check Alive effectively does
|
||||||
# not exist, while the delivery time (locktime) is still fully
|
# not exist, while the delivery time (locktime) is still fully
|
||||||
# enforced. ADVANCED mode keeps the user-controlled Check Alive
|
# enforced. ADVANCED mode keeps the user-controlled Check Alive
|
||||||
# exactly as before.
|
# exactly as before. The policy itself lives in
|
||||||
if self.bal_plugin.is_basic_mode():
|
# ``bal.core.checkalive.resolve_date_to_check``.
|
||||||
self.date_to_check = datetime.now().timestamp()
|
self.date_to_check = resolve_date_to_check(
|
||||||
else:
|
self.bal_plugin.is_basic_mode(), self.will_settings
|
||||||
self.date_to_check = BalTimestamp(self.will_settings['threshold']).to_timestamp()
|
)
|
||||||
# found = False
|
# found = False
|
||||||
# NOTE: block-height tracking removed (A1) - locktimes are always
|
# NOTE: block-height tracking removed (A1) - locktimes are always
|
||||||
# UNIX timestamps now, so we no longer read the current block height
|
# 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
|
# flow. In BASIC we therefore SKIP this check entirely, so a passed
|
||||||
# check-alive date never forces a postpone/rewrite of the will. The
|
# check-alive date never forces a postpone/rewrite of the will. The
|
||||||
# delivery time (locktime) is unaffected and still fully enforced.
|
# delivery time (locktime) is unaffected and still fully enforced.
|
||||||
if (
|
# The BASIC/ADVANCED rule lives in
|
||||||
not self.bal_plugin.is_basic_mode()
|
# ``bal.core.checkalive.check_alive_expired``.
|
||||||
and self.date_to_check < datetime.now().timestamp()
|
if check_alive_expired(
|
||||||
|
self.bal_plugin.is_basic_mode(), self.date_to_check
|
||||||
):
|
):
|
||||||
raise CheckAliveError(self.date_to_check)
|
raise CheckAliveError(self.date_to_check)
|
||||||
|
|
||||||
@@ -945,7 +969,12 @@ class BalWindow:
|
|||||||
targets = Will.only_valid(willitems)
|
targets = Will.only_valid(willitems)
|
||||||
for txid in targets:
|
for txid in targets:
|
||||||
wi = willitems[txid]
|
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"):
|
if wi.get_status("COMPLETE"):
|
||||||
txs[txid] = tx
|
txs[txid] = tx
|
||||||
continue
|
continue
|
||||||
@@ -1057,7 +1086,10 @@ class BalWindow:
|
|||||||
def on_success(txs):
|
def on_success(txs):
|
||||||
if txs:
|
if txs:
|
||||||
for txid, tx in txs.items():
|
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:
|
if not external:
|
||||||
self.will[txid] = willitems[txid].to_dict()
|
self.will[txid] = willitems[txid].to_dict()
|
||||||
try:
|
try:
|
||||||
@@ -1251,7 +1283,9 @@ class BalWindow:
|
|||||||
# very first action in a session). Fall back to "now" so the local
|
# very first action in a session). Fall back to "now" so the local
|
||||||
# validity check and the trailing update_all() always have it.
|
# validity check and the trailing update_all() always have it.
|
||||||
if not hasattr(self, "date_to_check") or self.date_to_check is None:
|
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():
|
for wid, wi in imported.items():
|
||||||
if wid in self.willitems:
|
if wid in self.willitems:
|
||||||
|
|||||||
2209
tests/karen7
2209
tests/karen7
File diff suppressed because it is too large
Load Diff
95
tests/test_core_checkalive.py
Normal file
95
tests/test_core_checkalive.py
Normal 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")
|
||||||
165
tests/test_core_input_rules.py
Normal file
165
tests/test_core_input_rules.py
Normal 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")
|
||||||
299
tests/test_core_reminders.py
Normal file
299
tests/test_core_reminders.py
Normal 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")
|
||||||
@@ -10,13 +10,12 @@ Covered behaviour:
|
|||||||
most one reminder per available day, and never returns more reminders than
|
most one reminder per available day, and never returns more reminders than
|
||||||
requested.
|
requested.
|
||||||
|
|
||||||
``compute_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
``compute_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
|
||||||
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
so these tests run without Qt (or Electrum) at all.
|
||||||
the other GUI tests.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
python3 -m pytest tests/test_group_d_alarms.py -q
|
python3 tests/test_group_d_alarms.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -25,7 +24,7 @@ import sys
|
|||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.plugin_base import BalConfig
|
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
|
# Mocks
|
||||||
|
|||||||
@@ -37,10 +37,14 @@ import pytest # pyright: ignore[reportMissingImports]
|
|||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
||||||
|
|
||||||
from bal.core.heirs import Heirs
|
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.will import HeirNotFoundException, Will, WillItem
|
||||||
from bal.core.willexecutors import Willexecutors
|
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,
|
# 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.
|
# 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"]
|
lines = ["BEGIN:VCALENDAR", "VERSION:2.0"]
|
||||||
for idx, offset in enumerate(offsets, start=1):
|
for idx, offset in enumerate(offsets, start=1):
|
||||||
event_dt = BalCalendar.format_time(locktime - timedelta(days=offset))
|
event_dt = format_time(locktime - timedelta(days=offset))
|
||||||
summary = BalCalendar.ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
summary = ical_escape(f"{summary_base} (reminder {idx}/{total})")
|
||||||
lines.extend([
|
lines.extend([
|
||||||
"BEGIN:VEVENT",
|
"BEGIN:VEVENT",
|
||||||
f"UID:bal-{wallet}-{offset}d",
|
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 1/3)" in ln for ln in lines)
|
||||||
assert any("(reminder 3/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.
|
# 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
|
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
|
"""Special iCalendar characters in karen7's event text are escaped so
|
||||||
the .ics file stays valid."""
|
the .ics file stays valid."""
|
||||||
raw = "Wallet karen7; heirs: alice, bob"
|
raw = "Wallet karen7; heirs: alice, bob"
|
||||||
escaped = BalCalendar.ical_escape(raw)
|
escaped = ical_escape(raw)
|
||||||
assert "\\;" in escaped # semicolon escaped
|
assert "\\;" in escaped # semicolon escaped
|
||||||
assert "\\," in escaped # comma escaped
|
assert "\\," in escaped # comma escaped
|
||||||
assert "karen7" in escaped
|
assert "karen7" in escaped
|
||||||
@@ -245,7 +249,7 @@ def test_e1_write_temp_ics_for_karen7():
|
|||||||
"END:VEVENT\r\n"
|
"END:VEVENT\r\n"
|
||||||
"END:VCALENDAR\r\n"
|
"END:VCALENDAR\r\n"
|
||||||
)
|
)
|
||||||
path = BalCalendar.write_temp_ics(content)
|
path = write_temp_ics(content)
|
||||||
try:
|
try:
|
||||||
assert os.path.isfile(path)
|
assert os.path.isfile(path)
|
||||||
with open(path, "rb") as f:
|
with open(path, "rb") as f:
|
||||||
|
|||||||
@@ -10,13 +10,12 @@ that would fall in the past.
|
|||||||
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
These tests pin the behaviour of the pure helper ``basic_reminder_offsets`` that
|
||||||
drives that decision.
|
drives that decision.
|
||||||
|
|
||||||
``basic_reminder_offsets`` lives in ``bal.gui.qt.widgets`` (which imports
|
``basic_reminder_offsets`` now lives in ``bal.core.reminders`` (pure, GUI-free),
|
||||||
PyQt6), so these tests are run headless with ``QT_QPA_PLATFORM=offscreen`` like
|
so these tests run without Qt (or Electrum) at all.
|
||||||
the other GUI tests.
|
|
||||||
|
|
||||||
Run:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen PYTHONPATH=electrum-src \
|
source /home/steal/devel/bal/electrum/env/bin/activate
|
||||||
python3 -m pytest tests/test_group_g_basic_calendar.py -q
|
python3 tests/test_group_g_basic_calendar.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -24,7 +23,7 @@ import sys
|
|||||||
|
|
||||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
|
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():
|
def test_basic_offsets_all_future():
|
||||||
|
|||||||
@@ -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
|
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
|
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
|
stays stuck at its old/default value. If the user then anticipates the
|
||||||
year minus the default 30-day margin). If the user then anticipates the
|
delivery time (locktime) to something earlier than that stale threshold, the
|
||||||
delivery time (locktime) to something earlier than that stale threshold - e.g.
|
checks that compare locktime against ``date_to_check`` would incorrectly treat
|
||||||
"in 1 month" - two different checks that compare locktime against
|
the will as "expired"/"invalid", even though the whole Check Alive concept is
|
||||||
``date_to_check`` (the resolved threshold) would incorrectly treat the will as
|
supposed to be inert in BASIC mode.
|
||||||
"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 real fix lives in ``BalWindow.init_class_variables`` (window.py), which
|
||||||
the delivery time LIVE in BASIC mode, placed a fixed 2-hour margin before it,
|
sets ``date_to_check`` to *now* in BASIC mode, and is now implemented by the
|
||||||
so it is always < locktime by construction. In ADVANCED mode nothing changes:
|
pure policy in ``bal.core.checkalive``:
|
||||||
the stored threshold is used as-is.
|
|
||||||
|
|
||||||
These tests call the real production method directly (no GUI/Electrum wallet
|
* ``resolve_date_to_check(is_basic_mode, will_settings)`` - the single
|
||||||
needed - it is a plain ``@staticmethod``), so they exercise the exact code
|
reference timestamp: "now" in BASIC, the stored threshold in ADVANCED;
|
||||||
used at runtime rather than a re-implementation.
|
* ``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:
|
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 sys
|
||||||
import time
|
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)
|
from bal.core.checkalive import ( # noqa: E402 (path insert above)
|
||||||
|
CheckAliveError,
|
||||||
OFFSET = BalWindow.BASIC_MODE_CHECK_ALIVE_OFFSET_SECONDS
|
check_alive_expired,
|
||||||
|
resolve_date_to_check,
|
||||||
|
|
||||||
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()
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_relative_locktime_one_year():
|
def test_basic_mode_resolves_to_now():
|
||||||
"""BASIC + default "1y" delivery: date_to_check tracks it, 2h earlier."""
|
"""BASIC mode: date_to_check is "now", never the stale stored threshold."""
|
||||||
result = BalWindow.compute_date_to_check(True, "1y", "30d")
|
fake_now = 1_800_000_000.0
|
||||||
expected_locktime = _relative_days_to_midnight_timestamp(365)
|
result = resolve_date_to_check(True, {"threshold": time.time() + 86400}, now=fake_now)
|
||||||
assert abs(result - (expected_locktime - OFFSET)) < 5
|
assert result == fake_now
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_anticipated_delivery_stays_consistent():
|
def test_basic_mode_ignores_stored_threshold():
|
||||||
"""BASIC + delivery anticipated to 1 month: date_to_check follows it,
|
"""BASIC + delivery anticipated to 1 month: date_to_check stays "now" and
|
||||||
NOT the old ~11-month threshold - this is the exact bug scenario
|
does NOT jump to the old ~11-month threshold - this is the exact bug
|
||||||
reported by the owner."""
|
scenario reported by the owner."""
|
||||||
stale_threshold = "30d" # would resolve close to the OLD 1-year locktime
|
stale_threshold = time.time() + 11 * 30 * 86400 # the old stuck value
|
||||||
anticipated_locktime = "30d" # user moved delivery to ~1 month from now
|
fake_now = 1_800_000_000.0
|
||||||
result = BalWindow.compute_date_to_check(
|
result = resolve_date_to_check(True, {"threshold": stale_threshold}, now=fake_now)
|
||||||
True, anticipated_locktime, stale_threshold
|
assert result == fake_now
|
||||||
)
|
|
||||||
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_date_to_check_always_before_locktime():
|
def test_advanced_mode_uses_stored_threshold():
|
||||||
"""Regression guard for the reported bug: whatever the delivery date is
|
"""ADVANCED mode: behaviour stays exactly as before - the stored threshold
|
||||||
(even very close to "now"), date_to_check must stay before it."""
|
is used as-is, regardless of the locktime value."""
|
||||||
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."""
|
|
||||||
absolute_threshold = time.time() + 5 * 86400 # arbitrary user-chosen 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
|
assert abs(result - absolute_threshold) < 1
|
||||||
|
|
||||||
|
|
||||||
def test_basic_mode_falls_back_on_unparsable_locktime():
|
def test_advanced_mode_parses_relative_threshold():
|
||||||
"""If the locktime can't be parsed for any reason, BASIC mode must not
|
"""ADVANCED + relative threshold ("30d") resolves to a future timestamp."""
|
||||||
crash: it falls back to the stored threshold, same as ADVANCED."""
|
result = resolve_date_to_check(False, {"threshold": "30d"})
|
||||||
absolute_threshold = time.time() + 5 * 86400
|
assert result > time.time()
|
||||||
result = BalWindow.compute_date_to_check(
|
|
||||||
True, {"not": "a valid locktime"}, absolute_threshold
|
|
||||||
)
|
def test_basic_mode_never_expired():
|
||||||
assert abs(result - absolute_threshold) < 1
|
"""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__":
|
if __name__ == "__main__":
|
||||||
test_offset_is_two_hours()
|
for name in sorted(dir()):
|
||||||
test_basic_mode_relative_locktime_one_year()
|
if name.startswith("test_"):
|
||||||
test_basic_mode_anticipated_delivery_stays_consistent()
|
globals()[name]()
|
||||||
test_basic_mode_date_to_check_always_before_locktime()
|
print(f" [OK] {name}")
|
||||||
test_advanced_mode_uses_stored_threshold_unchanged()
|
print("[OK] All test_group_i_basic_checkalive tests passed.")
|
||||||
test_basic_mode_falls_back_on_unparsable_locktime()
|
|
||||||
print("All test_group_i_basic_checkalive tests passed.")
|
|
||||||
|
|||||||
@@ -1,126 +1,25 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.gui.qt.calendar``.
|
Tests for ``bal.gui.qt.calendar``.
|
||||||
|
|
||||||
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
|
Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
|
||||||
write_temp_ics, 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:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import os
|
|
||||||
import sys
|
import sys
|
||||||
from datetime import datetime, timezone
|
|
||||||
|
|
||||||
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
sys.path.insert(0, __file__.rsplit("/", 2)[0])
|
||||||
|
|
||||||
from bal.gui.qt.calendar import BalCalendar
|
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
|
# open_with_default_app
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|
||||||
|
|
||||||
def test_open_with_default_app_not_found():
|
def test_open_with_default_app_not_found():
|
||||||
result = BalCalendar.open_with_default_app(
|
result = BalCalendar.open_with_default_app(
|
||||||
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
"/nonexistent/calendar_app", "/tmp/fake.ics"
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""
|
"""
|
||||||
Tests for ``bal.gui.qt.common``.
|
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:
|
Run:
|
||||||
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_common.py
|
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():
|
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
|
assert err.timestamp_to_check == 1000000
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_str():
|
def test_check_alive_error_str():
|
||||||
err = common.CheckAliveError(1000000)
|
from bal.core.checkalive import CheckAliveError
|
||||||
|
|
||||||
|
err = CheckAliveError(1000000)
|
||||||
s = str(err)
|
s = str(err)
|
||||||
assert "Check alive expired" in s
|
assert "Check alive expired" in s
|
||||||
assert "1970" in s
|
assert "1970" in s
|
||||||
|
|
||||||
|
|
||||||
def test_check_alive_error_subclass():
|
def test_check_alive_error_subclass():
|
||||||
assert issubclass(common.CheckAliveError, Exception)
|
from bal.core.checkalive import CheckAliveError
|
||||||
|
|
||||||
|
assert issubclass(CheckAliveError, Exception)
|
||||||
|
|
||||||
|
|
||||||
# ------------------------------------------------------------------ #
|
# ------------------------------------------------------------------ #
|
||||||
|
|||||||
@@ -118,23 +118,25 @@ def test_locktime_editor_min_max():
|
|||||||
def test_locktime_raw_edit_replace_str():
|
def test_locktime_raw_edit_replace_str():
|
||||||
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
# replace_str only strips the day ("d") and year ("y") suffixes. The
|
||||||
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
# block-height suffix ("b") was removed (A1), so "b" is NOT stripped
|
||||||
# anymore (locktimes are always UNIX timestamps now).
|
# anymore (locktimes are always UNIX timestamps now). The helper moved to
|
||||||
from bal.gui.qt.widgets import LockTimeRawEdit
|
# bal.core.input_rules (replace_dy_suffixes); the widget delegates to it.
|
||||||
assert LockTimeRawEdit.replace_str("123d") == "123"
|
from bal.core.input_rules import replace_dy_suffixes
|
||||||
assert LockTimeRawEdit.replace_str("456y") == "456"
|
assert replace_dy_suffixes("123d") == "123"
|
||||||
|
assert replace_dy_suffixes("456y") == "456"
|
||||||
# "b" is left untouched (no longer a recognised suffix)
|
# "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
|
# 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():
|
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
|
# character at expected position matches appendix
|
||||||
pos, s = LockTimeRawEdit.checkbdy(None, "123d", 4, "d")
|
pos, s = _checkbdy("123d", 4, "d")
|
||||||
assert s == "123d"
|
assert s == "123d"
|
||||||
# character at expected position does not match
|
# character at expected position does not match
|
||||||
pos, s = LockTimeRawEdit.checkbdy(None, "123x", 4, "d")
|
pos, s = _checkbdy("123x", 4, "d")
|
||||||
assert s == "123x"
|
assert s == "123x"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -305,10 +305,14 @@ def _make_merge_fake(willitems):
|
|||||||
wallet=FakeWallet(),
|
wallet=FakeWallet(),
|
||||||
bal_window=None,
|
bal_window=None,
|
||||||
date_to_check=1700000000,
|
date_to_check=1700000000,
|
||||||
|
will_settings={"threshold": 1700000000},
|
||||||
bal_plugin=SimpleNamespace(
|
bal_plugin=SimpleNamespace(
|
||||||
HISTORY_LABEL=SimpleNamespace(
|
HISTORY_LABEL=SimpleNamespace(
|
||||||
get=lambda: "BAL will history ({willexecutor})"
|
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"),
|
update_all=lambda: calls.append("update_all"),
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user