Files
bal-electrum-plugin/tests/test_calendar_event.py
2026-06-20 09:49:39 -04:00

220 lines
8.2 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Calendar (.ics) event-generation tests.
Verifies the two behaviours required by the spec:
1. The event date/time is the LOWER value between the will-settings locktime
and the lowest-locktime valid will transaction.
2. The alarms are generated according to the iCalendar (RFC-5545) standard
understood by the major online calendars (Google / Apple / Outlook):
``BEGIN:VALARM`` ... ``END:VALARM`` blocks with a ``TRIGGER;RELATED=END``
negative duration, and the number of alarms matches the configured count,
spread evenly across the window.
The alarm/event logic was extracted into pure ``BalCalendar`` static methods so
it can be tested without instantiating any Qt widget.
"""
import os
import re
import sys
from datetime import datetime, timedelta, timezone
sys.path.insert(0, os.path.join(os.path.dirname(__file__), os.pardir))
from bal.gui.qt.calendar import BalCalendar
DAY = 86400
# ------------------------------------------------------------------ #
# Event date/time selection
# ------------------------------------------------------------------ #
def test_event_timestamp_uses_will_settings_when_no_tx():
"""With no valid transaction the will-settings locktime is used."""
ws_locktime = 2_000_000_000
assert BalCalendar.compute_event_timestamp(ws_locktime, None) == ws_locktime
def test_event_timestamp_uses_lower_tx_locktime():
"""A valid tx with an EARLIER locktime wins over the will-settings one."""
ws_locktime = 2_000_000_000
tx_locktime = 1_900_000_000 # earlier
assert (
BalCalendar.compute_event_timestamp(ws_locktime, tx_locktime)
== tx_locktime
)
def test_event_timestamp_uses_lower_will_settings_locktime():
"""A valid tx with a LATER locktime does not push the event back."""
ws_locktime = 1_800_000_000 # earlier
tx_locktime = 1_900_000_000
assert (
BalCalendar.compute_event_timestamp(ws_locktime, tx_locktime)
== ws_locktime
)
def test_event_timestamp_equal():
ws_locktime = 1_850_000_000
assert (
BalCalendar.compute_event_timestamp(ws_locktime, ws_locktime)
== ws_locktime
)
# ------------------------------------------------------------------ #
# Alarm generation count and structure
# ------------------------------------------------------------------ #
def _parse_blocks(lines):
"""Group flat .ics lines into VALARM blocks (list of line-lists)."""
blocks, current = [], None
for line in lines:
if line == "BEGIN:VALARM":
current = []
elif line == "END:VALARM":
blocks.append(current)
current = None
elif current is not None:
current.append(line)
return blocks
def test_build_alarms_default_count_is_three():
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
blocks = _parse_blocks(BalCalendar.build_alarms(3, start, end))
assert len(blocks) == 3, "default 3 alarms expected"
def test_build_alarms_zero():
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
assert BalCalendar.build_alarms(0, start, end) == []
def test_build_alarms_custom_count():
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
for n in (1, 2, 5, 10):
blocks = _parse_blocks(BalCalendar.build_alarms(n, start, end))
assert len(blocks) == n, f"expected {n} alarms"
def test_build_alarms_structure_is_ics_compliant():
"""Each VALARM has a RELATED=END negative-duration trigger + ACTION."""
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=30)
lines = BalCalendar.build_alarms(3, start, end)
blocks = _parse_blocks(lines)
duration_re = re.compile(r"^TRIGGER;RELATED=END:-P(T?\d+[DHMS]|\d+D)$")
for block in blocks:
joined = "\n".join(block)
assert any(l.startswith("ACTION:") for l in block), \
"VALARM missing ACTION"
trigger = [l for l in block if l.startswith("TRIGGER")]
assert len(trigger) == 1, "VALARM needs exactly one TRIGGER"
assert duration_re.match(trigger[0]), \
f"trigger not RFC-5545 negative duration: {trigger[0]}"
def test_build_alarms_evenly_spread_and_ordered():
"""Alarms divide the window into (n+1) slices and are ordered toward the end.
With n=3 over 40 days the division points (back from the end) are at
30, 20 and 10 days before the deadline.
"""
start = datetime(2025, 1, 1, tzinfo=timezone.utc)
end = start + timedelta(days=40)
lines = BalCalendar.build_alarms(3, start, end)
triggers = [l for l in lines if l.startswith("TRIGGER")]
# Extract the day offsets from "TRIGGER;RELATED=END:-P30D" etc.
offsets = []
for t in triggers:
m = re.search(r"-P(\d+)D", t)
assert m, f"expected a day-based trigger, got {t}"
offsets.append(int(m.group(1)))
# 40 / (3+1) = 10-day slices -> first alarm 30d before, last 10d before.
assert offsets == [30, 20, 10], f"unexpected offsets {offsets}"
def test_build_alarms_short_window_uses_finer_units():
"""A short window falls back to hours/minutes rather than zero-day offsets."""
start = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc)
end = start + timedelta(hours=4)
lines = BalCalendar.build_alarms(3, start, end)
triggers = [l for l in lines if l.startswith("TRIGGER")]
assert len(triggers) == 3
for t in triggers:
# 4h / 4 = 1h slices -> offsets 3h, 2h, 1h (all hour-based).
assert re.search(r"-PT\d+H", t), f"expected hour trigger, got {t}"
# ------------------------------------------------------------------ #
# Full ICS document assembly (event + alarms)
# ------------------------------------------------------------------ #
def _build_full_ics(ws_locktime, min_tx_locktime, num_alarms,
now=None, threshold=None):
"""Assemble a complete VCALENDAR exactly like the widget does."""
if now is None:
now = datetime(2025, 1, 1, tzinfo=timezone.utc)
if threshold is None:
threshold = now + timedelta(days=10)
alarm_start_dt = max(now, threshold)
event_ts = BalCalendar.compute_event_timestamp(ws_locktime, min_tx_locktime)
event_dt = datetime.fromtimestamp(event_ts, tz=timezone.utc)
alarm_end_dt = event_dt
lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Bitcoin After Life//Electrum Plugin//EN",
"BEGIN:VEVENT",
"UID:bal-test",
f"DTSTAMP:{BalCalendar.format_time(now)}",
f"DTSTART:{BalCalendar.format_time(event_dt)}",
f"DTEND:{BalCalendar.format_time(event_dt)}",
"SUMMARY:BAL will execution",
"DESCRIPTION:test",
]
lines.extend(BalCalendar.build_alarms(num_alarms, alarm_start_dt, alarm_end_dt))
lines.extend(["END:VEVENT", "END:VCALENDAR"])
return "\r\n".join(lines) + "\r\n", event_dt
def test_full_ics_event_time_matches_lower_locktime():
ws_locktime = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
tx_locktime = int(datetime(2025, 6, 1, tzinfo=timezone.utc).timestamp())
ics, event_dt = _build_full_ics(ws_locktime, tx_locktime, 3)
# The earlier tx locktime must drive the event start.
expected = BalCalendar.format_time(
datetime.fromtimestamp(tx_locktime, tz=timezone.utc)
)
assert f"DTSTART:{expected}" in ics
assert "BEGIN:VCALENDAR" in ics and "END:VCALENDAR" in ics
assert ics.count("BEGIN:VALARM") == 3
assert ics.endswith("\r\n")
def test_full_ics_is_well_formed():
"""Sanity-check VCALENDAR/VEVENT/VALARM nesting balances out."""
ws_locktime = int(datetime(2026, 1, 1, tzinfo=timezone.utc).timestamp())
ics, _ = _build_full_ics(ws_locktime, None, 3)
assert ics.count("BEGIN:VCALENDAR") == ics.count("END:VCALENDAR") == 1
assert ics.count("BEGIN:VEVENT") == ics.count("END:VEVENT") == 1
assert ics.count("BEGIN:VALARM") == ics.count("END:VALARM") == 3
# ------------------------------------------------------------------ #
if __name__ == "__main__":
import inspect
for name, obj in sorted(globals().items()):
if name.startswith("test_") and inspect.isfunction(obj):
obj()
print(f" [OK] {name}")
print("[OK] All calendar-event tests passed")