add tests

This commit is contained in:
bot
2026-06-20 09:49:39 -04:00
parent 86ed0297a7
commit 525dde2b3c
34 changed files with 7427 additions and 0 deletions

View 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")