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

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,126 +1,25 @@
"""
Tests for ``bal.gui.qt.calendar``.
Covers BalCalendar static methods: format_time, ical_escape, fold_ical_line,
write_temp_ics, open_with_default_app.
Covers the GUI/OS glue that stayed in this module: BalCalendar.open_with_default_app.
The RFC-5545 helpers (format_time, ical_escape, fold_ical_line, write_temp_ics)
moved to ``bal.core.reminders`` and are tested in ``tests/test_core_reminders.py``.
Run:
QT_QPA_PLATFORM=offscreen python3 tests/test_gui_calendar.py
"""
import os
import sys
from datetime import datetime, timezone
sys.path.insert(0, __file__.rsplit("/", 2)[0])
from bal.gui.qt.calendar import BalCalendar
# ------------------------------------------------------------------ #
# format_time
# ------------------------------------------------------------------ #
def test_format_time_utc():
dt = datetime(2025, 6, 1, 12, 30, 45, tzinfo=timezone.utc)
assert BalCalendar.format_time(dt) == "20250601T123045Z"
def test_format_time_non_utc():
from datetime import timedelta
tz = timezone(timedelta(hours=2))
dt = datetime(2025, 1, 15, 8, 0, 0, tzinfo=tz)
result = BalCalendar.format_time(dt)
assert result.endswith("Z")
assert result == "20250115T060000Z"
# ------------------------------------------------------------------ #
# ical_escape
# ------------------------------------------------------------------ #
def test_ical_escape_no_change():
text = "hello world"
assert BalCalendar.ical_escape(text) == "hello world"
def test_ical_escape_backslash():
assert BalCalendar.ical_escape("a\\b") == "a\\\\b"
def test_ical_escape_semicolon():
assert BalCalendar.ical_escape("a;b") == "a\\;b"
def test_ical_escape_comma():
assert BalCalendar.ical_escape("a,b") == "a\\,b"
def test_ical_escape_multiline():
text = "line1\r\nline2"
result = BalCalendar.ical_escape(text)
assert "\r\n" in result
assert "line1" in result
assert "line2" in result
def test_ical_escape_all():
text = "\\;,"
assert BalCalendar.ical_escape(text) == "\\\\\\;\\,"
# ------------------------------------------------------------------ #
# fold_ical_line
# ------------------------------------------------------------------ #
def test_fold_ical_line_short():
line = "SUMMARY:Test"
assert BalCalendar.fold_ical_line(line) == "SUMMARY:Test"
def test_fold_ical_line_long():
line = "X-LONG:" + "a" * 100
result = BalCalendar.fold_ical_line(line, limit=75)
parts = result.split("\r\n ")
assert len(parts) > 1
assert result.startswith("X-LONG:")
def test_fold_ical_line_unicode():
line = "DESCRIPTION:" + "\u20ac" * 40
result = BalCalendar.fold_ical_line(line, limit=75)
assert "\r\n " in result
assert "\u20ac" in result
# ------------------------------------------------------------------ #
# write_temp_ics
# ------------------------------------------------------------------ #
def test_write_temp_ics():
content = "BEGIN:VCALENDAR\r\nEND:VCALENDAR\r\n"
path = BalCalendar.write_temp_ics(content)
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == content.encode("utf-8")
finally:
os.unlink(path)
def test_write_temp_ics_empty():
path = BalCalendar.write_temp_ics("")
try:
assert os.path.isfile(path)
with open(path, "rb") as f:
assert f.read() == b""
finally:
os.unlink(path)
# ------------------------------------------------------------------ #
# open_with_default_app
# ------------------------------------------------------------------ #
def test_open_with_default_app_not_found():
result = BalCalendar.open_with_default_app(
"/nonexistent/calendar_app", "/tmp/fake.ics"

View File

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

View File

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

View File

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