#!/usr/bin/python3 # Tests for cal-notif. Run: /usr/bin/python3 test_cal_notif.py # Copyright (C) 2026 Danilo M. # Licensed under the GNU General Public License version 2 only. import importlib.machinery import importlib.util import os import tempfile import time from datetime import datetime, timedelta from pathlib import Path from zoneinfo import ZoneInfo os.environ["TZ"] = "Europe/Rome" # all-day and floating times are local time.tzset() HERE = Path(__file__).resolve().parent _loader = importlib.machinery.SourceFileLoader("cal_notif", str(HERE / "cal-notif")) _spec = importlib.util.spec_from_loader("cal_notif", _loader) cn = importlib.util.module_from_spec(_spec) _loader.exec_module(cn) ROME = ZoneInfo("Europe/Rome") H, M, D = timedelta(hours=1), timedelta(minutes=1), timedelta(days=1) def ics(*events): body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events) return f"BEGIN:VCALENDAR\nVERSION:2.0\n{body}END:VCALENDAR\n".encode() def at(y, mo, d, h=0, mi=0): return datetime(y, mo, d, h, mi, tzinfo=ROME) def test_durations(): assert cn.parse_duration("1d 2h 15m") == D + 2 * H + 15 * M assert cn.parse_duration("90m") == 90 * M assert cn.parse_duration("0m") == timedelta() for bad in ("", "5", "1x", "m", "2h1d"): try: cn.parse_duration(bad) raise AssertionError(bad) except ValueError: pass assert cn.fmt_duration(D + 2 * H) == "1d 2h" assert cn.fmt_duration(timedelta()) == "0m" assert cn.fmt_duration(timedelta(minutes=-45)) == "-45m" assert cn.spoken_it(10 * M) == "dieci minuti" assert cn.spoken_it(H) == "un'ora" assert cn.spoken_it(D + 2 * H) == "un giorno e due ore" assert cn.spoken_it(2 * D + H + 21 * M) == "due giorni, un'ora e ventuno minuti" assert cn.number_it(28) == "ventotto" and cn.number_it(45) == "quarantacinque" def test_overrides_roundtrip(): with tempfile.TemporaryDirectory() as d: p = Path(d) / "overrides.toml" ov = {"a@example.org": ["1h", "10m"], 'q"uote@example.org': [], "café\U0001F600@example.org": ["30m"]} cn.save_overrides(ov, p) assert cn.load_overrides(p) == ov assert cn.load_overrides(Path(d) / "missing.toml") == {} def test_config(): with tempfile.TemporaryDirectory() as d: p = Path(d) / "config.toml" assert cn.load_config(p)["voice"]["lang"] == "it" # no file: defaults p.write_text('snooze = "15m"\n[voice]\nlang = "en-us"\n' '[defaults]\nbd = { offsets = ["1d"], voice = false }\n') c = cn.load_config(p) assert c["snooze"] == "15m" and c["late"] == "10m" assert c["voice"]["lang"] == "en-us" and c["voice"]["lead_silence"] == 0.3 # merged p.write_text('[defaults]\n"*" = ["soon"]\n') try: cn.load_config(p) raise AssertionError except ValueError: pass def test_expand(): lo, hi = at(2026, 1, 1), at(2026, 2, 1) # Weekly zoned event, two EXDATEs, one moved occurrence. weekly = """UID:w@example.org DTSTART;TZID=Europe/Rome:20260105T090000 DTEND;TZID=Europe/Rome:20260105T100000 RRULE:FREQ=WEEKLY;UNTIL=20260131T000000Z EXDATE;TZID=Europe/Rome:20260112T090000,20260119T090000 SUMMARY:standup""" moved = """UID:w@example.org RECURRENCE-ID;TZID=Europe/Rome:20260126T090000 DTSTART;TZID=Europe/Rome:20260126T150000 DTEND;TZID=Europe/Rome:20260126T160000 SUMMARY:standup moved""" occ = cn.expand(ics(weekly, moved), "work", lo, hi) got = sorted((o.start, o.summary) for o in occ) assert got == [(at(2026, 1, 5, 9), "standup"), (at(2026, 1, 26, 15), "standup moved")], got # All-day yearly with a UTC UNTIL (dateutil rejects the naive/aware mix # unless normalised) and a relative VALARM 1 day before. bday = """UID:b@example.org DTSTART;VALUE=DATE:19800112 RRULE:FREQ=YEARLY;UNTIL=20300101T000000Z SUMMARY:birthday BEGIN:VALARM ACTION:DISPLAY TRIGGER:-P1D END:VALARM""" (o,) = cn.expand(ics(bday), "bd", lo, hi) assert o.allday and o.start == datetime(2026, 1, 12).astimezone() assert o.valarms == (datetime(2026, 1, 11).astimezone(),) # A week-ahead VALARM on an event past `hi` widens that event's window. far = """UID:f@example.org DTSTART;TZID=Europe/Rome:20260205T090000 SUMMARY:far BEGIN:VALARM ACTION:DISPLAY TRIGGER:-P7D END:VALARM""" (o,) = cn.expand(ics(far), "c", lo, hi) assert o.valarms == (at(2026, 1, 29, 9),) assert cn.expand(ics(far.replace("-P7D", "-PT1H")), "c", lo, hi) == [] # RELATED=END trigger counts from DTEND. end = """UID:e@example.org DTSTART;TZID=Europe/Rome:20260110T090000 DTEND;TZID=Europe/Rome:20260110T110000 BEGIN:VALARM ACTION:DISPLAY TRIGGER;RELATED=END:-PT15M END:VALARM""" (o,) = cn.expand(ics(end), "c", lo, hi) assert o.valarms == (at(2026, 1, 10, 10, 45),) def test_alarm_action_none(): lo, hi = at(2026, 1, 1), at(2026, 2, 1) # RFC 9074 ACTION:NONE placeholder (Apple/iCloud "no alarm") must not # count as a VALARM, so alarms_for falls back to the calendar default. none_alarm = """UID:n@example.org DTSTART;TZID=Europe/Rome:20260115T090000 SUMMARY:silent BEGIN:VALARM ACTION:NONE TRIGGER;VALUE=DATE-TIME:19760401T005545Z END:VALARM""" (o,) = cn.expand(ics(none_alarm), "c", lo, hi) assert o.valarms == () cfg = cn.load_config(Path("/nonexistent")) cfg["defaults"] = {"c": ["15m"]} assert cn.alarms_for(o, cfg, {}) == [o.start - 15 * M] def test_load_events(): with tempfile.TemporaryDirectory() as d: cal = Path(d) / "123" cal.mkdir() (cal / "displayname").write_text("work\n") (cal / "a.ics").write_bytes(ics("UID:a@example.org\nDTSTART;TZID=Europe/Rome:20260110T090000")) (cal / "broken.ics").write_text("not a calendar") (o,) = cn.load_events(d, at(2026, 1, 1), at(2026, 2, 1)) # broken one skipped assert o.cal == "work" and o.uid == "a@example.org" def test_precedence(): cfg = cn.load_config(Path("/nonexistent")) cfg["defaults"] = {"bd": ["1d", "9h"], "*": ["15m"], "quiet": {"offsets": ["1h"], "voice": False}} t = at(2026, 1, 10, 12) mk = lambda uid, cal, va=(): cn.Occ(uid, cal, "x", "", t, False, va) assert cn.alarms_for(mk("a", "bd"), cfg, {}) == [t - D, t - 9 * H] assert cn.alarms_for(mk("a", "other"), cfg, {}) == [t - 15 * M] assert cn.alarms_for(mk("a", "bd", (t - 5 * M,)), cfg, {}) == [t - 5 * M] # VALARM beats default assert cn.alarms_for(mk("a", "bd", (t - 5 * M,)), cfg, {"a": ["2h"]}) == [t - 2 * H] # override beats VALARM assert cn.alarms_for(mk("a", "bd", (t - 5 * M,)), cfg, {"a": []}) == [] # silenced assert cn.cal_entry(cfg, "quiet") == (["1h"], False) assert cn.current_offsets(mk("a", "bd"), cfg, {}) == ["1d", "9h"] assert cn.horizon(cfg, {"a": ["3d"]}) == 4 * D def test_due(): t = at(2026, 1, 10, 12) o = cn.Occ("a", "c", "x", "", t, False, ()) late = 10 * M e = [(t - 30 * M, o), (t - 5 * M, o), (t, o)] assert cn.due(e, t - 6 * M, t - 5 * M, late) == [(t - 5 * M, o)] assert cn.due(e, t - 60 * M, t - 5 * M, late) == [(t - 5 * M, o)] # 30m one too old assert cn.due(e, t - M, t + M, late) == [(t, o)] # started: only the T-0 assert cn.announce(o, t - 10 * M, cn.DEFAULTS["voice"]) == "Tra dieci minuti: x" assert cn.announce(o, t + 5 * M, cn.DEFAULTS["voice"]) == "Adesso: x" def test_choice(): assert cn.parse_choice("1d, 1h") == ["1d", "1h"] assert cn.parse_choice("90m") == ["1h 30m"] assert cn.parse_choice("none") == [] assert cn.parse_choice("reset") is None try: cn.parse_choice("soon") raise AssertionError except ValueError: pass if __name__ == "__main__": for name, fn in list(globals().items()): if name.startswith("test_"): fn() print("ok", name)