forked from bitcoinafterlife/bal-electrum-plugin
220 lines
8.2 KiB
Python
220 lines
8.2 KiB
Python
"""
|
||
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")
|