129 lines
4.3 KiB
Python
129 lines
4.3 KiB
Python
"""iCalendar mapping tests (SPEC 4)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from mdcaldav.config import Config
|
|
from mdcaldav.ical import apply_to_task, escape, fold, from_ics, to_ics, unescape
|
|
from mdcaldav.model import Status
|
|
from mdcaldav.parser import parse
|
|
|
|
|
|
def prop(text: str, name: str) -> str | None:
|
|
from mdcaldav.ical import unfold
|
|
|
|
for line in unfold(text):
|
|
key = line.split(":", 1)[0].split(";", 1)[0]
|
|
if key == name:
|
|
return line.split(":", 1)[1]
|
|
return None
|
|
|
|
|
|
def one(src: bytes, cfg: Config):
|
|
return parse(src, "t.md", cfg)[0]
|
|
|
|
|
|
def test_basic_vtodo(cfg: Config):
|
|
text = to_ics(one(b"- [ ] Water the seedlings\n", cfg))
|
|
assert "BEGIN:VTODO" in text and "END:VTODO" in text
|
|
assert prop(text, "SUMMARY") == "Water the seedlings"
|
|
assert prop(text, "STATUS") == "NEEDS-ACTION"
|
|
assert prop(text, "PRIORITY") is None
|
|
assert text.endswith("\r\n")
|
|
|
|
|
|
def test_completed_maps_percent_and_timestamp(cfg: Config):
|
|
when = datetime(2026, 1, 7, 15, 30, tzinfo=timezone.utc)
|
|
text = to_ics(one(b"- [x] Rotate the compost\n", cfg), completed_at=when)
|
|
assert prop(text, "STATUS") == "COMPLETED"
|
|
assert prop(text, "PERCENT-COMPLETE") == "100"
|
|
assert prop(text, "COMPLETED") == "20260107T153000Z"
|
|
|
|
|
|
def test_priority_bands(cfg: Config):
|
|
for marker, expected in (("A", "1"), ("B", "5"), ("C", "9")):
|
|
text = to_ics(one(f"- [ ] [#{marker}] x\n".encode(), cfg))
|
|
assert prop(text, "PRIORITY") == expected
|
|
|
|
|
|
def test_description_and_categories(cfg: Config):
|
|
src = b"## Garden\n\n- [ ] Prune\n - Use the long loppers\n"
|
|
task = parse(src, "t.md", cfg)[0]
|
|
text = to_ics(task)
|
|
assert prop(text, "DESCRIPTION") == "Use the long loppers"
|
|
assert "Garden" in (prop(text, "CATEGORIES") or "")
|
|
|
|
|
|
def test_parent_relation(cfg: Config):
|
|
tasks = parse(b"- [ ] parent\n - [ ] child\n", "t.md", cfg)
|
|
text = to_ics(tasks[1])
|
|
assert f"RELATED-TO;RELTYPE=PARENT:{tasks[0].uid}" in text
|
|
|
|
|
|
def test_text_escaping_round_trip():
|
|
raw = "Comma, semi; back\\slash\nnewline"
|
|
assert unescape(escape(raw)) == raw
|
|
|
|
|
|
def test_special_characters_survive_serialization(cfg: Config):
|
|
src = "- [ ] Buy: milk, eggs; and a note\\here\n".encode()
|
|
text = to_ics(one(src, cfg))
|
|
assert from_ics(text)["title"] == "Buy: milk, eggs; and a note\\here"
|
|
|
|
|
|
def test_folding_respects_character_boundaries():
|
|
line = "SUMMARY:" + "é" * 100
|
|
folded = fold(line)
|
|
assert all(len(seg.encode()) <= 75 for seg in folded.split("\r\n "))
|
|
from mdcaldav.ical import unfold
|
|
|
|
assert unfold(folded)[0] == line
|
|
|
|
|
|
def test_long_summary_round_trips(cfg: Config):
|
|
title = "Plan " + "the very long garden bed layout " * 6
|
|
text = to_ics(one(f"- [ ] {title}\n".encode(), cfg))
|
|
assert from_ics(text)["title"] == title.strip()
|
|
|
|
|
|
def test_from_ics_ignores_unmodelled_properties():
|
|
text = (
|
|
"BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:Task\r\n"
|
|
"RRULE:FREQ=WEEKLY\r\nBEGIN:VALARM\r\nTRIGGER:-PT15M\r\n"
|
|
"SUMMARY:Alarm text\r\nEND:VALARM\r\nSTATUS:COMPLETED\r\n"
|
|
"END:VTODO\r\nEND:VCALENDAR\r\n"
|
|
)
|
|
fields = from_ics(text)
|
|
assert fields["title"] == "Task" # not clobbered by the VALARM's SUMMARY
|
|
assert fields["status"] is Status.COMPLETED
|
|
assert "rrule" not in fields
|
|
|
|
|
|
def test_percent_complete_implies_completion():
|
|
text = (
|
|
"BEGIN:VCALENDAR\r\nBEGIN:VTODO\r\nUID:abc\r\nSUMMARY:T\r\n"
|
|
"PERCENT-COMPLETE:100\r\nEND:VTODO\r\nEND:VCALENDAR\r\n"
|
|
)
|
|
assert from_ics(text)["status"] is Status.COMPLETED
|
|
|
|
|
|
def test_apply_to_task_reports_only_real_changes(cfg: Config):
|
|
task = one(b"- [ ] [#A] Ship it\n", cfg)
|
|
same = from_ics(to_ics(task))
|
|
assert apply_to_task(task, same, cfg) == {}
|
|
|
|
changed = dict(same, status=Status.COMPLETED, title="Ship it now")
|
|
assert apply_to_task(task, changed, cfg) == {
|
|
"status": Status.COMPLETED,
|
|
"title": "Ship it now",
|
|
}
|
|
|
|
|
|
def test_priority_snaps_to_configured_bands(cfg: Config):
|
|
task = one(b"- [ ] [#A] Ship it\n", cfg)
|
|
# A client sending 3 stays in the "high" band → still [#A], so no change.
|
|
assert apply_to_task(task, {"priority": 3}, cfg) == {}
|
|
assert apply_to_task(task, {"priority": 9}, cfg) == {"priority": 9}
|
|
assert apply_to_task(task, {"priority": 0}, cfg) == {"priority": None}
|