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:
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")
|
||||
Reference in New Issue
Block a user