Files
Aether/scripts/kalender-selbsttest.py
T
marcandClaude Opus 5 0c9cc69493 feat(calendar): Wiederholungsmuster, Terminfarbe synchronisierbar, Selbsttest
Terminfarbe: Sie kam auf dem Telefon nie an. RFC 7986 schreibt für COLOR
einen CSS3-*Namen* vor, wir schrieben Hex – DAVx5 liest die Eigenschaft
über eine Namenstabelle und verwirft alles andere, also blieb der Termin
im Samsung-Kalender farblos. Jetzt beides nebeneinander:

    COLOR:tomato            für fremde Clients (damit die Farbe ankommt)
    X-AETHER-COLOR:#ea4335  für die eigene Palette (damit sie genau bleibt)

Beim Lesen gewinnt der genaue Wert; ein von außen eingefärbter Termin
wird über die Namenstabelle übersetzt. Nächstgelegener Ton per Abstand im
RGB-Würfel (color.go).

Wiederholungen können jetzt mehr als "alle N":

- wöchentlich an ausgewählten Wochentagen (Mo–So, Mehrfachauswahl). Der
  Wochentag des Starts bleibt fest angehakt – eine Reihe, die ihren
  eigenen ersten Termin auslässt, ist keine.
- monatlich und jährlich wahlweise am festen Kalendertag (am 4.) oder am
  n-ten Wochentag (am 1. Freitag). Beide Angaben leitet der Server aus
  dem Startdatum ab, statt sie getrennt entgegenzunehmen – so kann die
  Regel gar nicht erst auf einen anderen Tag zeigen als der Termin.
- Der fünfte Wochentag eines Monats wird bewusst zu "letzter": Einen
  fünften Freitag hat nicht jeder Monat, BYDAY=5FR ließe die Reihe sonst
  in etwa jedem zweiten Monat stillschweigend ausfallen.

Regeln, die das Formular weiterhin nicht abbildet, bleiben lesbar, aber
nicht bearbeitbar (409) – unverändert.

Dazu scripts/kalender-selbsttest.py, 19 Prüfungen gegen die laufende
Instanz, in TESTING.md verlinkt. Alle grün, unter anderem:

    am 1. Freitag monatlich   -> 04.09. 02.10. 06.11. 04.12.
    am 1. Freitag im September -> 2026-09-04, 2027-09-03
    Mo+Fr wöchentlich          -> 07./11./14./18./21./25.09.
    ganztägig am 3.            -> bleibt der 3.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 01:16:06 +02:00

187 lines
8.1 KiB
Python
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""Selbsttest der Kalender-Schnittstelle gegen eine laufende Instanz.
python3 scripts/kalender-selbsttest.py [BASIS-URL] [BENUTZER] [PASSWORT]
Voreinstellung: http://localhost:8090, admin/admin123 (Entwicklungsumgebung).
Prüft die Stellen, an denen bereits einmal etwas schiefging – allen voran das
Ganztags-Datum, das in einer Zeitzone östlich von UTC einen Tag zurücksprang –
und die Wiederholungsmuster, deren Fehler man sonst erst Wochen später
bemerkt ("jeder 1. Freitag" darf nicht im Monat ohne fünften Freitag
ausfallen).
**Achtung: legt Termine im ersten Kalender des Kontos an** und löscht am Ende
*alle* Termine darin. Nicht gegen einen Kalender laufen lassen, in dem echte
Termine stehen.
"""
import http.cookiejar
import json
import sys
import urllib.request
BASE = sys.argv[1] if len(sys.argv) > 1 else "http://localhost:8090"
USER = sys.argv[2] if len(sys.argv) > 2 else "admin"
PASSWORT = sys.argv[3] if len(sys.argv) > 3 else "admin123"
ok_count = 0
fail = []
def api(method, path, body=None, headers=None, raw=False):
url = BASE + path
data = None
hdrs = dict(headers or {})
if body is not None:
data = json.dumps(body).encode()
hdrs["Content-Type"] = "application/json"
req = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
with OPENER.open(req) as r:
payload = r.read().decode()
if raw:
return r.status, payload
return r.status, (json.loads(payload).get("data") if payload else None)
except urllib.error.HTTPError as e:
return e.code, None
def check(name, cond, detail=""):
global ok_count
if cond:
ok_count += 1
print(f" \033[32mok\033[0m {name}")
else:
fail.append(name)
print(f" \033[31mFEHL\033[0m {name} {detail}")
jar = http.cookiejar.CookieJar()
OPENER = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
# ── Anmelden ────────────────────────────────────────────────────────────
st, data = api("POST", "/api/v1/auth/login",
{"username": USER, "password": PASSWORT})
assert st == 200, f"Anmeldung fehlgeschlagen: {st}"
CSRF = {"X-CSRF-Token": data["csrf_token"]}
st, cals = api("GET", "/api/v1/calendars")
CAL = cals[0]["id"]
EV = f"/api/v1/calendars/{CAL}/events"
print(f"Kalender: {cals[0]['name']}\n")
def create(**body):
st, d = api("POST", EV, body, CSRF)
assert st == 201, f"Anlegen fehlgeschlagen ({st}): {body.get('summary')}"
return d
def events(frm, to):
st, d = api("GET", f"{EV}?from={frm}&to={to}")
return d or []
def starts(summary, frm="2026-01-01T00:00:00Z", to="2027-12-31T00:00:00Z"):
return [e["start"][:10] for e in events(frm, to) if e["summary"] == summary]
print("── Ganztägig: Datum darf nicht verrutschen ──")
e = create(summary="T ganztag", start="2026-09-03T00:00:00Z",
end="2026-09-03T00:00:00Z", all_day=True)
check("ein Tag bleibt der 3.", e["start"][:10] == "2026-09-03", e["start"])
check("Ende ist derselbe Tag", e["end"][:10] == "2026-09-03", e["end"])
e2 = create(summary="T mehrtags", start="2026-09-10T00:00:00Z",
end="2026-09-12T00:00:00Z", all_day=True)
check("mehrtägig 10.–12.", e2["start"][:10] == "2026-09-10" and e2["end"][:10] == "2026-09-12",
f'{e2["start"]}..{e2["end"]}')
print("\n── Uhrzeit: unverändert durchreichen ──")
e3 = create(summary="T uhrzeit", start="2026-09-15T12:30:00Z",
end="2026-09-15T13:30:00Z", all_day=False)
check("Zeitpunkt unverändert", e3["start"] == "2026-09-15T12:30:00Z", e3["start"])
check("nicht als ganztägig gelesen", e3["all_day"] is False)
print("\n── Farbe: Name für fremde Clients, Hex für uns ──")
e4 = create(summary="T farbe", start="2026-09-16T10:00:00Z",
end="2026-09-16T11:00:00Z", all_day=False, color="#ea4335")
check("Hex kommt unverändert zurück", e4.get("color") == "#ea4335", e4.get("color"))
st, ics = api("GET", f"/api/v1/calendars/{CAL}/export", raw=True)
block = [b for b in ics.split("BEGIN:VEVENT") if "T farbe" in b]
check("COLOR als CSS3-Name im .ics", block and "COLOR:tomato" in block[0],
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
check("X-AETHER-COLOR mit genauem Wert",
block and "#ea4335" in "".join(l for l in block[0].splitlines() if "X-AETHER-COLOR" in l),
[l for l in (block[0].splitlines() if block else []) if "COLOR" in l])
print("\n── Wöchentlich an bestimmten Tagen ──")
# 7. Sept 2026 ist ein Montag. Reihe Mo+Fr, 3 Wochen.
create(summary="T wochentage", start="2026-09-07T08:00:00Z",
end="2026-09-07T09:00:00Z", all_day=False,
recurrence={"freq": "WEEKLY", "interval": 1, "count": 6,
"byday": ["MO", "FR"]})
got = starts("T wochentage")
check("Mo+Fr, 6 Termine", got == ["2026-09-07", "2026-09-11", "2026-09-14",
"2026-09-18", "2026-09-21", "2026-09-25"], got)
print("\n── Monatlich: am festen Tag vs. am n-ten Wochentag ──")
# 4. Sept 2026 ist der 1. Freitag des Monats.
create(summary="T monat tag", start="2026-09-04T08:00:00Z",
end="2026-09-04T09:00:00Z", all_day=False,
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "DAY"})
got = starts("T monat tag")
check("am 4. jedes Monats", got == ["2026-09-04", "2026-10-04", "2026-11-04", "2026-12-04"], got)
create(summary="T monat wochentag", start="2026-09-04T10:00:00Z",
end="2026-09-04T11:00:00Z", all_day=False,
recurrence={"freq": "MONTHLY", "interval": 1, "count": 4, "mode": "WEEKDAY"})
got = starts("T monat wochentag")
# jeweils der 1. Freitag: 4.9. / 2.10. / 6.11. / 4.12.
check("am 1. Freitag jedes Monats", got == ["2026-09-04", "2026-10-02", "2026-11-06", "2026-12-04"], got)
print("\n── Jährlich: beide Muster ──")
create(summary="T jahr tag", start="2026-09-04T08:00:00Z",
end="2026-09-04T09:00:00Z", all_day=False,
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "DAY"})
check("am 4. September", starts("T jahr tag") == ["2026-09-04", "2027-09-04"], starts("T jahr tag"))
create(summary="T jahr wochentag", start="2026-09-04T12:00:00Z",
end="2026-09-04T13:00:00Z", all_day=False,
recurrence={"freq": "YEARLY", "interval": 1, "count": 2, "mode": "WEEKDAY"})
# 1. Freitag im September: 2026-09-04, 2027-09-03
check("am 1. Freitag im September",
starts("T jahr wochentag") == ["2026-09-04", "2027-09-03"], starts("T jahr wochentag"))
print("\n── Regel bleibt beim Bearbeiten erhalten ──")
ser = [e for e in events("2026-09-01T00:00:00Z", "2026-12-31T00:00:00Z")
if e["summary"] == "T monat wochentag"][0]
check("Regel wird als bearbeitbar gemeldet", ser.get("recurrence", {}).get("mode") == "WEEKDAY",
ser.get("recurrence"))
check("Reihenbeginn mitgeliefert", ser.get("series_start", "")[:10] == "2026-09-04",
ser.get("series_start"))
st, _ = api("PATCH", f"{EV}/{ser['uid']}",
{"summary": "T monat wochentag", "start": "2026-09-04T10:00:00Z",
"end": "2026-09-04T11:00:00Z", "all_day": False,
"recurrence": {"freq": "MONTHLY", "interval": 2, "count": 3, "mode": "WEEKDAY"}}, CSRF)
check("Bearbeiten erlaubt (200)", st == 200, st)
got = starts("T monat wochentag")
check("jetzt jeden 2. Monat, 1. Freitag", got == ["2026-09-04", "2026-11-06", "2027-01-01"], got)
print("\n── Zeitfenster begrenzt wirklich ──")
win = [e for e in events("2026-09-14T00:00:00Z", "2026-09-20T00:00:00Z")
if e["summary"] == "T wochentage"]
check("nur die eine Woche", [e["start"][:10] for e in win] == ["2026-09-14", "2026-09-18"],
[e["start"][:10] for e in win])
print("\n── Aufräumen ──")
seen = {e["uid"] for e in events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")}
for uid in seen:
api("DELETE", f"{EV}/{uid}", None, CSRF)
rest = events("2020-01-01T00:00:00Z", "2030-01-01T00:00:00Z")
check("Kalender wieder leer", rest == [], rest)
print(f"\n{ok_count} bestanden, {len(fail)} fehlgeschlagen")
if fail:
print("Fehlgeschlagen: " + ", ".join(fail))
sys.exit(1 if fail else 0)