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