#!/usr/bin/python3 # Copyright (C) 2026 Danilo M. # # This program is free software; you can redistribute it and/or modify # it under the terms of the GNU General Public License version 2 as # published by the Free Software Foundation. # # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU General Public License for more details. """Run: /usr/bin/python3 desktop/island/calendar/test_calctl.py Drives calctl as a CLI against temporary vdirs and a temporary khal config, never the live calendars. Plain asserts, no framework. """ import json import os import shutil import subprocess import tempfile from pathlib import Path CALCTL = Path(__file__).resolve().parent / "calctl" tmp = Path(tempfile.mkdtemp(prefix="calctl-test-")) (tmp / "a").mkdir() (tmp / "b").mkdir() (tmp / "khal").write_text(f"""[calendars] [[home]] path = {tmp}/a type = calendar color = light green [[birthdays]] path = {tmp}/b color = '#ff8800' [locale] firstweekday = 0 [default] default_calendar = home """) ENV = dict(os.environ, CALCTL_KHAL_CONFIG=str(tmp / "khal"), CALCTL_OVERRIDES=str(tmp / "overrides.toml"), CALCTL_NOSYNC="1", TZ="Europe/Rome") def run(*args, ok=True): p = subprocess.run([str(CALCTL), *args], env=ENV, capture_output=True, text=True) r = json.loads(p.stdout) assert r["ok"] is ok, (args, p.stdout, p.stderr) assert (p.returncode == 0) is ok, (args, p.returncode) return r["data"] if ok else r["error"] def put(path, body): """A hand-written .ics, CRLF like a real one.""" Path(path).write_text(body.strip().replace("\n", "\r\n") + "\r\n") # calendars: khal colour names mapped to Theme names, a hex passed through. c = run("calendars") assert c["default"] == "home" assert [(x["name"], x["color"]) for x in c["calendars"]] == [("home", "green"), ("birthdays", "#ff8800")] assert c["calendars"][0]["path"] == str(tmp / "a") # A yearly all-day event from long ago is expanded into the asked window. put(tmp / "b" / "bday.ics", """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//test//EN BEGIN:VEVENT UID:bday-1 DTSTAMP:20200101T000000Z DTSTART;VALUE=DATE:19900417 DTEND;VALUE=DATE:19900418 RRULE:FREQ=YEARLY;WKST=TU SUMMARY:Birthday END:VEVENT END:VCALENDAR """) apr = [e for e in run("list", "2026-04-01", "2026-05-01") if e["uid"] == "bday-1"] assert len(apr) == 1, apr assert apr[0]["start"] == "2026-04-17" and apr[0]["end"] == "2026-04-17", apr assert apr[0]["allDay"] is True and apr[0]["repeat"] == "yearly" and apr[0]["color"] == "#ff8800" # A rich rule and two alarms read as custom; the weekly rule is expanded in # local time, so 09:00 stays 09:00 across the DST change on 25 October. put(tmp / "a" / "weird.ics", """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//test//EN BEGIN:VEVENT UID:weird-1 DTSTAMP:20260101T000000Z DTSTART;TZID=Europe/Rome:20261005T090000 DTEND;TZID=Europe/Rome:20261005T100000 RRULE:FREQ=WEEKLY;BYDAY=MO,WE SUMMARY:Standup X-FOO:bar BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:a TRIGGER:-PT1H END:VALARM BEGIN:VALARM ACTION:DISPLAY DESCRIPTION:b TRIGGER:-PT5M END:VALARM END:VEVENT END:VCALENDAR """) w = run("get", "weird-1", "home") assert w["repeat"] == "custom" and w["alarm"] == "custom" and w["title"] == "Standup", w assert w["start"] == "2026-10-05T09:00", w occ = [e["start"] for e in run("list", "2026-10-01", "2026-11-01") if e["uid"] == "weird-1"] assert len(occ) == 8 and occ[0] == "2026-10-05T09:00" and "2026-10-26T09:00" in occ, occ # A broken file is skipped, not fatal. (tmp / "a" / "broken.ics").write_text("this is not a calendar\n") assert run("list", "2026-10-01", "2026-11-01") # Unknown uid and bad usage: the error envelope, exit 1. assert "no event" in run("get", "nope", "home", ok=False) assert "usage" in run("frobnicate", ok=False) def ev(**kw): e = {"calendar": "home", "title": "Dentist", "allDay": False, "start": "2026-10-15T14:30", "end": "2026-10-15T15:30", "repeat": "none", "until": "", "alarm": "1h", "location": "", "notes": ""} e.update(kw) return json.dumps(e) def no_temp(): assert not list(tmp.rglob(".calctl-*")), "temp file left behind" # new, timed: the local zone with its VTIMEZONE, the alarm; read back in # local time. uid = run("new", ev())["uid"] f = tmp / "a" / f"{uid}.ics" t = f.read_text() assert "DTSTART;TZID=Europe/Rome:20261015T143000" in t, t assert "BEGIN:VTIMEZONE" in t and "TRIGGER:-PT1H" in t, t no_temp() mine = [e for e in run("list", "2026-10-01", "2026-11-01") if e["uid"] == uid] assert len(mine) == 1, mine assert mine[0]["start"] == "2026-10-15T14:30" and mine[0]["end"] == "2026-10-15T15:30" assert mine[0]["alarm"] == "1h" and mine[0]["repeat"] == "none" and mine[0]["color"] == "green" assert mine[0]["override"] is False assert run("get", uid, "home")["title"] == "Dentist" # all-day, multi-day: exclusive DTEND on disk, inclusive end in JSON. trip = run("new", ev(title="Trip", allDay=True, start="2026-10-30", end="2026-11-02", alarm="none"))["uid"] t = (tmp / "a" / f"{trip}.ics").read_text() assert "DTSTART;VALUE=DATE:20261030" in t and "DTEND;VALUE=DATE:20261103" in t, t assert "VALARM" not in t nov = [e for e in run("list", "2026-11-01", "2026-12-01") if e["uid"] == trip] assert len(nov) == 1 and nov[0]["start"] == "2026-10-30" and nov[0]["end"] == "2026-11-02", nov assert not [e for e in run("list", "2026-10-01", "2026-10-30") if e["uid"] == trip] # weekly with an until-date, across the DST change. gym = run("new", ev(title="Gym", repeat="weekly", until="2026-10-29"))["uid"] occ = [e["start"] for e in run("list", "2026-10-01", "2026-12-01") if e["uid"] == gym] assert occ == ["2026-10-15T14:30", "2026-10-22T14:30", "2026-10-29T14:30"], occ assert os.stat(tmp / "a" / f"{gym}.ics").st_mode & 0o777 == 0o644 # A late-evening series keeps its last occurrence: UNTIL is UTC on disk and # read back in local time. late = run("new", ev(title="Late", start="2026-10-05T22:30", end="2026-10-05T23:00", repeat="weekly", until="2026-10-19"))["uid"] occ = [e["start"] for e in run("list", "2026-10-01", "2026-11-01") if e["uid"] == late] assert occ == ["2026-10-05T22:30", "2026-10-12T22:30", "2026-10-19T22:30"], occ assert run("get", gym, "home")["until"] == "2026-10-29" # edit keeping custom: the rich rule, both alarms and an unknown property # survive; SEQUENCE goes up. run("edit", "weird-1", "home", ev(title="Standup v2", start="2026-10-05T09:30", end="2026-10-05T10:00", repeat="custom", alarm="custom")) t = (tmp / "a" / "weird.ics").read_text() assert "BYDAY=MO,WE" in t and t.count("BEGIN:VALARM") == 2 and "X-FOO:bar" in t, t assert "SUMMARY:Standup v2" in t and "SEQUENCE:1" in t, t no_temp() # edit to presets: the rule goes, the alarms become one. run("edit", "weird-1", "home", ev(title="Standup v2", start="2026-10-05T09:30", end="2026-10-05T10:00", repeat="none", alarm="10m")) t = (tmp / "a" / "weird.ics").read_text() assert "RRULE" not in t and t.count("BEGIN:VALARM") == 1 and "TRIGGER:-PT10M" in t, t # a custom rule cannot have its date moved: its BY* parts would stay on the # old day. put(tmp / "a" / "weird2.ics", """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//test//EN BEGIN:VEVENT UID:weird-2 DTSTAMP:20260101T000000Z DTSTART;TZID=Europe/Rome:20261005T090000 DTEND;TZID=Europe/Rome:20261005T100000 RRULE:FREQ=WEEKLY;BYDAY=MO SUMMARY:Standup END:VEVENT END:VCALENDAR """) assert "rule" in run("edit", "weird-2", "home", ev(title="Standup", start="2026-10-07T09:00", end="2026-10-07T10:00", repeat="custom", alarm="none"), ok=False) # an edit that changes nothing leaves the event's own encoding alone: zone, # DURATION, the alarm's ACTION and extras, SUMMARY's parameter. put(tmp / "a" / "foreign.ics", """ BEGIN:VCALENDAR VERSION:2.0 PRODID:-//test//EN BEGIN:VEVENT UID:foreign-1 DTSTAMP:20260101T000000Z DTSTART;TZID=America/New_York:20261005T100000 DURATION:PT1H SUMMARY;LANGUAGE=it:Chiamata BEGIN:VALARM ACTION:AUDIO TRIGGER:-PT1H ACKNOWLEDGED:20261001T080000Z END:VALARM END:VEVENT END:VCALENDAR """) g = run("get", "foreign-1", "home") same = {k: g[k] for k in ("calendar", "title", "allDay", "start", "end", "repeat", "until", "alarm", "location", "notes")} run("edit", "foreign-1", "home", json.dumps(same)) t = (tmp / "a" / "foreign.ics").read_text() for kept in ("DTSTART;TZID=America/New_York:20261005T100000", "DURATION:PT1H", "ACTION:AUDIO", "ACKNOWLEDGED:20261001T080000Z", "SUMMARY;LANGUAGE=it:Chiamata"): assert kept in t, (kept, t) # Changing only the title rewrites the title, nothing else. run("edit", "foreign-1", "home", json.dumps(dict(same, title="Call"))) t = (tmp / "a" / "foreign.ics").read_text() assert "SUMMARY:Call" in t and "TZID=America/New_York" in t and "ACTION:AUDIO" in t, t # the same UID in two calendars: each command acts on the copy it names. for d in ("a", "b"): put(tmp / d / "dup.ics", f""" BEGIN:VCALENDAR VERSION:2.0 PRODID:-//test//EN BEGIN:VEVENT UID:dup-1 DTSTAMP:20260101T000000Z DTSTART;VALUE=DATE:20261020 SUMMARY:copy {d} END:VEVENT END:VCALENDAR """) assert run("get", "dup-1", "birthdays")["title"] == "copy b" run("delete", "dup-1", "home") assert not (tmp / "a" / "dup.ics").exists() and (tmp / "b" / "dup.ics").exists() # a calendar change moves the file. run("edit", uid, "home", ev(calendar="birthdays")) assert not f.exists() and (tmp / "b" / f.name).exists() assert run("get", uid, "birthdays")["calendar"] == "birthdays" # cal-notif's per-event override is reported. (tmp / "overrides.toml").write_text(f'"{uid}" = ["10m"]\n') assert run("get", uid, "birthdays")["override"] is True # delete. assert run("delete", uid, "birthdays") == {} assert not (tmp / "b" / f.name).exists() assert "no event" in run("get", uid, "birthdays", ok=False) # validation: the error envelope, exit 1, nothing written. before = sorted(p.name for p in tmp.rglob("*.ics")) assert "title" in run("new", ev(title=" "), ok=False) assert "end" in run("new", ev(end="2026-10-15T14:00"), ok=False) assert "calendar" in run("new", ev(calendar="nope"), ok=False) assert "json" in run("new", "{not json", ok=False) assert sorted(p.name for p in tmp.rglob("*.ics")) == before no_temp() shutil.rmtree(tmp) print("test_calctl: ok")