forked from bitcoinafterlife/bal-electrum-plugin
add tests
This commit is contained in:
219
tests/test_calendar_event.py
Normal file
219
tests/test_calendar_event.py
Normal file
@@ -0,0 +1,219 @@
|
||||
"""
|
||||
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")
|
||||
Reference in New Issue
Block a user