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