Add the iCalendar round-trip
Parsing goes through icalendar's low-level parser, which keeps properties in order and keeps repeated ones. Writing is ours: that crate's writer escapes a whole property value as text, so CATEGORIES:Work,Personal would go out as one category named "Work,Personal" to every other client. Anything the model does not interpret is carried rather than dropped -- X-MOZ-LASTACK, X-EVOLUTION-ALARM-UID, ACKNOWLEDGED, the X-MICROSOFT-CDO set, unrecognised ATTENDEE parameters, and whole VTODO/VJOURNAL components. A calendar has several clients writing to it and this one is not the authority on which properties matter. VTIMEZONE is modelled properly, and TZID is stored exactly as written: Exchange names its zones "Pacific Standard Time", which no IANA lookup resolves, and normalising at parse time would make the document unrepresentable. Mapping to a real zone belongs at the point of use. Tested against a golden corpus captured from the live Baikal (seven producing clients over five years) and a published Outlook feed, scrubbed of private content with the structure left byte-for-byte. Eight hand-written fixtures cover what neither server had: DURATION, floating times, RDATE, DST boundaries, leap day, and the full escape set. The contract is that parse -> write -> parse is stable, plus a check that no property name loses occurrences across the trip, since a parser that dropped ATTENDEE entirely would round-trip perfectly and still be wrong.
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Golden-file corpus
|
||||
|
||||
Every file here is real iCalendar data unless it sits under `synthetic/`. The point of the corpus
|
||||
is that `runway-core` is tested against what servers and clients actually emit, not against what
|
||||
the RFC says they should.
|
||||
|
||||
The contract each file must satisfy is in `tests/ical.rs`: **parse → serialise → parse must be
|
||||
stable**, and the second parse must equal the first. A file that cannot round-trip is a bug in the
|
||||
parser or the writer, never a reason to edit the file.
|
||||
|
||||
## Provenance
|
||||
|
||||
`baikal/` — captured from a live Baikal (SabreDAV) server holding six calendars written by seven
|
||||
different clients over five years. One file per CalDAV resource, exactly as the server returned it
|
||||
in a `calendar-query` REPORT.
|
||||
|
||||
| File | What it covers |
|
||||
|---|---|
|
||||
| `davx5-allday-weekly-alarm.ics` | `VALUE=DATE` all-day series, `RRULE`, one `VALARM` |
|
||||
| `davx5-zoned-two-alarms-vtimezone.ics` | `TZID=America/New_York`, **two** `VALARM`s, `VTIMEZONE` emitted *after* the `VEVENT` |
|
||||
| `evolution-x-lic-error.ics` | `X-LIC-ERROR` baked in by libical, `ACKNOWLEDGED`, `X-EVOLUTION-ALARM-UID`, `TRIGGER;RELATED=START` |
|
||||
| `thunderbird-google-invite-attendees.ics` | three `ATTENDEE`s with `CUTYPE`/`PARTSTAT`/`X-NUM-GUESTS`, `RRULE` with `COUNT`, `Europe/Zurich` |
|
||||
| `thunderbird-x-moz-props.ics` | `X-MOZ-GENERATION`, `X-MOZ-LASTACK` — vendor state that must survive a round-trip |
|
||||
| `outlook-invite-quoted-cn.ics` | `ORGANIZER`/`ATTENDEE` with a **quoted `CN` containing a comma**, `LANGUAGE` parameters, `X-MICROSOFT-CDO-*` |
|
||||
| `runway-v1-unfolded-line.ics` | **v1's own broken output**, still stored on the server: a 248-octet unfolded `DESCRIPTION` (RFC violation) and `CREATED` with no `Z` |
|
||||
|
||||
`outlook/` — captured from a published Outlook/Exchange `.ics` feed (252 events, 103 UIDs), trimmed
|
||||
to seven components that carry the interesting structure.
|
||||
|
||||
| Covers |
|
||||
|---|
|
||||
| **Windows timezone names** (`TZID=Pacific Standard Time`), not IANA — with the matching `VTIMEZONE` definitions, whose `DTSTART:16010101T020000` is year **1601** |
|
||||
| A master with four `RECURRENCE-ID` overrides, one of which moves the occurrence to a **different day** |
|
||||
| An **orphan override**: a `RECURRENCE-ID` whose master lies outside the feed window and must still render |
|
||||
| `EXDATE` with **many comma-separated values on one line** |
|
||||
| `METHOD:PUBLISH`, `X-WR-CALNAME`, and the full `X-MICROSOFT-*` set |
|
||||
|
||||
`synthetic/` — hand-authored, because nothing in the real corpus exercises these: `DURATION`
|
||||
instead of `DTEND`, floating (zoneless) times, `RDATE` on an event, a CalDAV resource bundling a
|
||||
master with its override, a DST spring-forward series, a leap-day series, and the complete set of
|
||||
RFC 5545 text escapes with non-ASCII.
|
||||
|
||||
## Scrubbing
|
||||
|
||||
The real files have been scrubbed. Structure is preserved **exactly** — property names, parameters,
|
||||
property order, component nesting, every datetime, `TZID`, `RRULE`, `EXDATE`, `RECURRENCE-ID`,
|
||||
`SEQUENCE` and `X-` property is byte-for-byte what the server sent. Only human-readable content was
|
||||
replaced: `SUMMARY`, `DESCRIPTION`, `LOCATION`, display names, email addresses and URLs, each with
|
||||
synthetic text of comparable shape that keeps the RFC 5545 escaping in play. `UID`s were replaced by
|
||||
a stable hash that preserves their original *format*, so master/override linkage inside a series
|
||||
still holds.
|
||||
|
||||
`runway-v1-unfolded-line.ics` is deliberately **not** refolded: its over-long line is the defect
|
||||
under test.
|
||||
@@ -0,0 +1,23 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5/4.4.8-gplay ical4j/3.2.19 (com.digibites.calendar)
|
||||
BEGIN:VEVENT
|
||||
UID:eee51914-187b-40d5-342c-dc80c118438a
|
||||
STATUS:CONFIRMED
|
||||
SUMMARY:Check-in
|
||||
CLASS:PUBLIC
|
||||
TRANSP:OPAQUE
|
||||
DTSTART;VALUE=DATE:20250331
|
||||
DTEND;VALUE=DATE:20250401
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
CREATED:20250902T164854Z
|
||||
DTSTAMP:20250902T164854Z
|
||||
LAST-MODIFIED:20250902T164854Z
|
||||
SEQUENCE:2
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Retrospective demo retro
|
||||
TRIGGER:-PT4H
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,40 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:DAVx5/4.5.19-gplay ical4j/4.3.0
|
||||
BEGIN:VEVENT
|
||||
DTSTAMP:20260811T142404Z
|
||||
UID:3a21fd46-26c4-85b5-eee3-6d2d2256f8ef
|
||||
SUMMARY:Briefing
|
||||
DTSTART;TZID=America/New_York:20260818T083000
|
||||
DTEND;TZID=America/New_York:20260818T093000
|
||||
RRULE:FREQ=WEEKLY;INTERVAL=1;BYDAY=TU
|
||||
STATUS:CONFIRMED
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT1H
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Design
|
||||
END:VALARM
|
||||
BEGIN:VALARM
|
||||
TRIGGER:-PT12H
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Design
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/New_York
|
||||
BEGIN:STANDARD
|
||||
TZNAME:EST
|
||||
TZOFFSETFROM:-0400
|
||||
TZOFFSETTO:-0500
|
||||
DTSTART:20071104T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZNAME:EDT
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0400
|
||||
DTSTART:20070311T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,44 @@
|
||||
BEGIN:VCALENDAR
|
||||
CALSCALE:GREGORIAN
|
||||
PRODID:-//Ximian//NONSGML Evolution Calendar//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/Denver
|
||||
X-LIC-LOCATION:America/Denver
|
||||
BEGIN:DAYLIGHT
|
||||
TZNAME:MDT
|
||||
TZOFFSETFROM:-0700
|
||||
TZOFFSETTO:-0600
|
||||
DTSTART:20070311T020000
|
||||
RRULE:FREQ=YEARLY;UNTIL=20370308T090000Z;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZNAME:MST
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0700
|
||||
DTSTART:20071104T020000
|
||||
RRULE:FREQ=YEARLY;UNTIL=20361102T080000Z;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20210128T161146Z
|
||||
LAST-MODIFIED:20211019T060028Z
|
||||
DTSTAMP:20210128T203012Z
|
||||
UID:c615f0a3-b567-cd71-7925-216aa5c3cc06
|
||||
SUMMARY:Handoff
|
||||
X-MOZ-LASTACK:20210128T203012Z
|
||||
DTSTART;TZID=America/Denver:20210128T133000
|
||||
DTEND;TZID=America/Denver:20210128T143000
|
||||
TRANSP:OPAQUE
|
||||
X-MOZ-GENERATION:2
|
||||
BEGIN:VALARM
|
||||
X-EVOLUTION-ALARM-UID:799e9f4195fcc29e5cc98f57eda3937d400a3543
|
||||
ACTION:DISPLAY
|
||||
DESCRIPTION:Check-in retro sync
|
||||
TRIGGER;RELATED=START:-PT15M
|
||||
ACKNOWLEDGED:20211019T060028Z
|
||||
X-LIC-ERROR;X-LIC-ERRORTYPE=PARAMETER-VALUE-PARSE-ERROR:Got a VALUE paramet
|
||||
er with an illegal type for property: VALUE=DURATION
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,54 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/Denver
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:-0700
|
||||
TZOFFSETTO:-0600
|
||||
TZNAME:MDT
|
||||
DTSTART:19700308T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0700
|
||||
TZNAME:MST
|
||||
DTSTART:19701101T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
LAST-MODIFIED:20210928T163536Z
|
||||
DTSTAMP:20210928T163536Z
|
||||
UID:BC7787EBDF79D94DA9E1A90BFB6ECA7E9CD51D0402D0FAEEEC9CD42126A75F5DBC7787E
|
||||
BDF79D94DA9E1A90BFB6ECA7E9CD51D0402D0FAEE
|
||||
SUMMARY:Standup Design
|
||||
PRIORITY:5
|
||||
STATUS:CONFIRMED
|
||||
ORGANIZER;CN=Dana Okafor;SCHEDULE-AGENT=CLIENT:mailto:dana.okafor@example.o
|
||||
rg
|
||||
ATTENDEE;RSVP=TRUE;CN="Priya Raman";PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:
|
||||
mailto:priya.raman@example.org
|
||||
DTSTART;TZID=America/Denver:20210930T140000
|
||||
DTEND;TZID=America/Denver:20210930T143000
|
||||
DESCRIPTION;LANGUAGE=en-US:Quarterly handoff budget retrospective workshop
|
||||
kickoff kickoff demo\nJoin: https://example.org/meet/653ba53ffa43\n
|
||||
CLASS:PUBLIC
|
||||
TRANSP:OPAQUE
|
||||
SEQUENCE:0
|
||||
LOCATION;LANGUAGE=en-US:Virtual
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:0
|
||||
X-MICROSOFT-CDO-OWNERAPPTID:-566454299
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:0
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-LOCATIONS:[]
|
||||
X-MOZ-RECEIVED-SEQUENCE:0
|
||||
X-MOZ-RECEIVED-DTSTAMP:20210928T141728Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,21 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//calendar-app//calendar-app//EN
|
||||
BEGIN:VEVENT
|
||||
UID:0097f029-5408-49be-e522-73f1e8e7ab35-1764105096
|
||||
DTSTAMP:20251125T211136Z
|
||||
DTSTART:20251203T200000Z
|
||||
DTEND:20251203T203000Z
|
||||
SUMMARY:Briefing
|
||||
DESCRIPTION:https://gov.teams.example.org/l/meetup-join/19%3agcch%3ameeting_bc0ea8b8a3984e5c81dc505caf2c54b0%40thread.v2/0?context=%7b%22Tid%22%3a%22f50f4fcc-38ea-4525-a42e-e89136a91fed%22%2c%22Oid%22%3a%22b755cbb7-d40e-4324-b018-503bb3bfe89d%22%7d
|
||||
STATUS:CONFIRMED
|
||||
CLASS:PUBLIC
|
||||
CREATED:20251125T211136
|
||||
LAST-MODIFIED:20251125T211136Z
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:-PT120M
|
||||
DESCRIPTION:Check-in
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,49 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Europe/Zurich
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETFROM:+0100
|
||||
TZOFFSETTO:+0200
|
||||
TZNAME:CEST
|
||||
DTSTART:19700329T020000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETFROM:+0200
|
||||
TZOFFSETTO:+0100
|
||||
TZNAME:CET
|
||||
DTSTART:19701025T030000
|
||||
RRULE:FREQ=YEARLY;BYDAY=-1SU;BYMONTH=10
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20210520T192010Z
|
||||
LAST-MODIFIED:20210520T201659Z
|
||||
DTSTAMP:20210520T201659Z
|
||||
UID:4d11f4742abb8ae6e6ab54d8b3@google.com
|
||||
SUMMARY:Review Briefing
|
||||
STATUS:CONFIRMED
|
||||
ORGANIZER;CN=Jordan Vale;SCHEDULE-AGENT=CLIENT:mailto:jordan.vale@example.o
|
||||
rg
|
||||
ATTENDEE;RSVP=TRUE;CN=Jordan Vale;PARTSTAT=ACCEPTED;CUTYPE=INDIVIDUAL;ROLE=
|
||||
REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:jordan.vale@example.org
|
||||
ATTENDEE;RSVP=TRUE;CN=Priya Raman;PARTSTAT=NEEDS-ACTION;CUTYPE=INDIVIDUAL;R
|
||||
OLE=REQ-PARTICIPANT;X-NUM-GUESTS=0:mailto:priya.raman@example.org
|
||||
ATTENDEE;PARTSTAT=ACCEPTED;ROLE=REQ-PARTICIPANT:mailto:priya.raman@example.
|
||||
org
|
||||
RRULE:FREQ=WEEKLY;COUNT=5;BYDAY=TU,TH
|
||||
DTSTART;TZID=Europe/Zurich:20210520T210000
|
||||
DTEND;TZID=Europe/Zurich:20210520T213000
|
||||
X-MICROSOFT-CDO-OWNERAPPTID:-786089143
|
||||
DESCRIPTION:Join: https://example.org/meet/75a54b52f57c\nJoin: https://exam
|
||||
ple.org/meet/e93ac4d1f299\nRetrospective retro onboarding design planning
|
||||
planning handoff roadmap design standup demo handoff
|
||||
LOCATION:Conference Room B
|
||||
SEQUENCE:0
|
||||
TRANSP:OPAQUE
|
||||
X-MOZ-RECEIVED-SEQUENCE:0
|
||||
X-MOZ-RECEIVED-DTSTAMP:20210520T192011Z
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,137 @@
|
||||
BEGIN:VCALENDAR
|
||||
PRODID:-//Mozilla.org/NONSGML Mozilla Calendar V1.1//EN
|
||||
VERSION:2.0
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:America/Denver
|
||||
X-TZINFO:America/Denver[2023c]
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-065956
|
||||
TZNAME:America/Denver(STD)
|
||||
DTSTART:18831118T120004
|
||||
RDATE:18831118T120004
|
||||
END:STANDARD
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-060000
|
||||
TZNAME:America/Denver(STD)
|
||||
DTSTART:19181027T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=19201031T020000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19180331T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=-1SU;UNTIL=19210327T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-060000
|
||||
TZNAME:America/Denver(STD)
|
||||
DTSTART:19210522T020000
|
||||
RDATE:19210522T020000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19420209T020000
|
||||
RDATE:19420209T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-060000
|
||||
TZNAME:America/Denver(STD)
|
||||
DTSTART:19450930T020000
|
||||
RDATE:19450930T020000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19650425T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19730429T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19740106T020000
|
||||
RDATE:19740106T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19750223T020000
|
||||
RDATE:19750223T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19760425T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=-1SU;UNTIL=19860427T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:19870405T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=4;BYDAY=1SU;UNTIL=20060402T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-060000
|
||||
TZNAME:America/Denver(STD)
|
||||
DTSTART:19651031T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=10;BYDAY=-1SU;UNTIL=20061029T020000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:America/Denver(DST)
|
||||
DTSTART:20070311T020000
|
||||
RDATE:20070311T020000
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-060000
|
||||
TZNAME:America/Denver(STD)
|
||||
DTSTART:20071104T020000
|
||||
RDATE:20071104T020000
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
TZOFFSETTO:-060000
|
||||
TZOFFSETFROM:-070000
|
||||
TZNAME:(DST)
|
||||
DTSTART:20080309T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=3;BYDAY=2SU
|
||||
END:DAYLIGHT
|
||||
BEGIN:STANDARD
|
||||
TZOFFSETTO:-070000
|
||||
TZOFFSETFROM:-060000
|
||||
TZNAME:(STD)
|
||||
DTSTART:20081102T020000
|
||||
RRULE:FREQ=YEARLY;BYMONTH=11;BYDAY=1SU
|
||||
END:STANDARD
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
CREATED:20230901T204357Z
|
||||
LAST-MODIFIED:20230901T204400Z
|
||||
DTSTAMP:20230901T204400Z
|
||||
UID:c9525154-42b2-3c07-b11f-46760a4dab35
|
||||
SUMMARY:Check-in Budget
|
||||
STATUS:CONFIRMED
|
||||
X-MOZ-LASTACK:20230901T204400Z
|
||||
DTSTART;TZID=America/Denver:20230831T180000
|
||||
DTEND;TZID=America/Denver:20230831T190000
|
||||
X-MOZ-GENERATION:1
|
||||
BEGIN:VALARM
|
||||
ACTION:DISPLAY
|
||||
TRIGGER:-PT2H
|
||||
DESCRIPTION:Retrospective
|
||||
END:VALARM
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,230 @@
|
||||
BEGIN:VCALENDAR
|
||||
METHOD:PUBLISH
|
||||
PRODID:Microsoft Exchange Server 2010
|
||||
VERSION:2.0
|
||||
X-WR-CALNAME:Calendar
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Central Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0500
|
||||
TZOFFSETTO:-0600
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0500
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Mountain Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0600
|
||||
TZOFFSETTO:-0700
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0700
|
||||
TZOFFSETTO:-0600
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VTIMEZONE
|
||||
TZID:Pacific Standard Time
|
||||
BEGIN:STANDARD
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0700
|
||||
TZOFFSETTO:-0800
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=1SU;BYMONTH=11
|
||||
END:STANDARD
|
||||
BEGIN:DAYLIGHT
|
||||
DTSTART:16010101T020000
|
||||
TZOFFSETFROM:-0800
|
||||
TZOFFSETTO:-0700
|
||||
RRULE:FREQ=YEARLY;INTERVAL=1;BYDAY=2SU;BYMONTH=3
|
||||
END:DAYLIGHT
|
||||
END:VTIMEZONE
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||
RRULE:FREQ=WEEKLY;UNTIL=20270825T190000Z;INTERVAL=1;BYDAY=WE;WKST=SU
|
||||
EXDATE;TZID=Pacific Standard Time:20251217T120000,20251224T120000,20251231T
|
||||
120000,20260513T120000,20260701T120000,20260805T120000,20260819T120000,202
|
||||
60826T120000,20260902T120000,20260909T120000
|
||||
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||
SUMMARY:Handoff Triage
|
||||
DTSTART;TZID=Pacific Standard Time:20251015T120000
|
||||
DTEND;TZID=Pacific Standard Time:20251015T122500
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:29
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:1
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||
RECURRENCE-ID;TZID=Pacific Standard Time:20251015T120000
|
||||
SUMMARY:Handoff Triage
|
||||
DTSTART;TZID=Pacific Standard Time:20251016T110000
|
||||
DTEND;TZID=Pacific Standard Time:20251016T113000
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:29
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:3
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||
RECURRENCE-ID;TZID=Pacific Standard Time:20251105T120000
|
||||
SUMMARY:Handoff Triage
|
||||
DTSTART;TZID=Pacific Standard Time:20251105T120000
|
||||
DTEND;TZID=Pacific Standard Time:20251105T122500
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:29
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:3
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||
RECURRENCE-ID;TZID=Pacific Standard Time:20251112T120000
|
||||
SUMMARY:Handoff Triage
|
||||
DTSTART;TZID=Pacific Standard Time:20251112T120000
|
||||
DTEND;TZID=Pacific Standard Time:20251112T122500
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:29
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:3
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:\nJoin: https://example.org/meet/d5b85f228adc\n
|
||||
UID:5d2296acb04b3a6d80097d073f@google.com
|
||||
RECURRENCE-ID;TZID=Pacific Standard Time:20251210T120000
|
||||
SUMMARY:Handoff Triage
|
||||
DTSTART;TZID=Pacific Standard Time:20251209T130000
|
||||
DTEND;TZID=Pacific Standard Time:20251209T133000
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:29
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:29
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:3
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:Briefing\nJoin: https://example.org/meet/5934b829a167\n
|
||||
UID:D5BFA26242816AE1AB2B2D7D14A1B96E3C984A142C64CCBBBF1CC42774AC46FAD5BFA26
|
||||
242816AE1AB2B2D7D14A1B96E3C984A142C64CCBB
|
||||
RECURRENCE-ID;TZID=Mountain Standard Time:20260526T133000
|
||||
SUMMARY:Briefing Onboarding
|
||||
DTSTART;TZID=Mountain Standard Time:20260526T133000
|
||||
DTEND;TZID=Mountain Standard Time:20260526T140000
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:1
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:1
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:3
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
DESCRIPTION:\nJoin: https://example.org/meet/5cfc4622592a\n
|
||||
UID:16A4709A509B3E413F90C38AB729CEA79577D656B130C5C3251996E325F3B9AF16A4709
|
||||
A509B3E413F90C38AB729CEA79577D656B130C5C3
|
||||
SUMMARY:Planning Check-in
|
||||
DTSTART;TZID=Central Standard Time:20260406T093000
|
||||
DTEND;TZID=Central Standard Time:20260406T101500
|
||||
CLASS:PUBLIC
|
||||
PRIORITY:5
|
||||
DTSTAMP:20260826T175427Z
|
||||
TRANSP:OPAQUE
|
||||
STATUS:CONFIRMED
|
||||
SEQUENCE:1
|
||||
LOCATION:Annex, Room 7
|
||||
X-MICROSOFT-CDO-APPT-SEQUENCE:1
|
||||
X-MICROSOFT-CDO-BUSYSTATUS:TENTATIVE
|
||||
X-MICROSOFT-CDO-INTENDEDSTATUS:BUSY
|
||||
X-MICROSOFT-CDO-ALLDAYEVENT:FALSE
|
||||
X-MICROSOFT-CDO-IMPORTANCE:1
|
||||
X-MICROSOFT-CDO-INSTTYPE:0
|
||||
X-MICROSOFT-DONOTFORWARDMEETING:FALSE
|
||||
X-MICROSOFT-DISALLOW-COUNTER:FALSE
|
||||
X-MICROSOFT-REQUESTEDATTENDANCEMODE:DEFAULT
|
||||
X-MICROSOFT-ISRESPONSEREQUESTED:FALSE
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,13 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:allday-exdate@fixtures.runway.invalid
|
||||
DTSTAMP:20260101T000000Z
|
||||
DTSTART;VALUE=DATE:20260105
|
||||
DTEND;VALUE=DATE:20260106
|
||||
SUMMARY:Bin day
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
EXDATE;VALUE=DATE:20260223,20260302
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:dst-spring-forward@fixtures.runway.invalid
|
||||
DTSTAMP:20260301T090000Z
|
||||
DTSTART;TZID=America/Denver:20260302T090000
|
||||
DTEND;TZID=America/Denver:20260302T093000
|
||||
SUMMARY:Weekly across spring forward
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=4
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:duration-not-dtend@fixtures.runway.invalid
|
||||
DTSTAMP:20260114T090000Z
|
||||
DTSTART;TZID=America/Denver:20260114T090000
|
||||
DURATION:PT1H30M
|
||||
SUMMARY:Ninety minutes expressed as a duration
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,11 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:floating@fixtures.runway.invalid
|
||||
DTSTAMP:20260114T090000Z
|
||||
DTSTART:20260114T090000
|
||||
DTEND:20260114T100000
|
||||
SUMMARY:Nine in the morning\, wherever you are
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,12 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:leap-day@fixtures.runway.invalid
|
||||
DTSTAMP:20240229T000000Z
|
||||
DTSTART;VALUE=DATE:20240229
|
||||
DTEND;VALUE=DATE:20240301
|
||||
SUMMARY:Leap day
|
||||
RRULE:FREQ=YEARLY;BYMONTH=2;BYMONTHDAY=29
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,14 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:rdate-exdate@fixtures.runway.invalid
|
||||
DTSTAMP:20260105T090000Z
|
||||
DTSTART;TZID=America/Denver:20260105T090000
|
||||
DTEND;TZID=America/Denver:20260105T093000
|
||||
SUMMARY:Weekly with an extra date and two skipped
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO;COUNT=8
|
||||
RDATE;TZID=America/Denver:20260110T090000
|
||||
EXDATE;TZID=America/Denver:20260119T090000,20260202T090000
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,22 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:series-override@fixtures.runway.invalid
|
||||
DTSTAMP:20260105T090000Z
|
||||
DTSTART;TZID=America/Denver:20260105T090000
|
||||
DTEND;TZID=America/Denver:20260105T100000
|
||||
SUMMARY:Standup
|
||||
RRULE:FREQ=WEEKLY;BYDAY=MO
|
||||
SEQUENCE:0
|
||||
END:VEVENT
|
||||
BEGIN:VEVENT
|
||||
UID:series-override@fixtures.runway.invalid
|
||||
RECURRENCE-ID;TZID=America/Denver:20260119T090000
|
||||
DTSTAMP:20260112T142200Z
|
||||
DTSTART;TZID=America/Denver:20260119T140000
|
||||
DTEND;TZID=America/Denver:20260119T150000
|
||||
SUMMARY:Standup (moved to the afternoon)
|
||||
SEQUENCE:1
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,16 @@
|
||||
BEGIN:VCALENDAR
|
||||
VERSION:2.0
|
||||
PRODID:-//Runway//Runway Test Fixture//EN
|
||||
BEGIN:VEVENT
|
||||
UID:escapes@fixtures.runway.invalid
|
||||
DTSTAMP:20260114T090000Z
|
||||
DTSTART;TZID=Europe/Zurich:20260114T090000
|
||||
DTEND;TZID=Europe/Zurich:20260114T100000
|
||||
SUMMARY:Lezione d'italiano — caffè\, cioccolato e “virgolette”
|
||||
DESCRIPTION:Line one\nLine two\; with a semicolon\nA backslash: \\\nA comm
|
||||
a\, and a colon: here
|
||||
LOCATION;LANGUAGE=it-CH:Zürich\, Hauptbahnhof
|
||||
ORGANIZER;CN="Rossi, Giulia (IT: Milano)":mailto:giulia.rossi@example.org
|
||||
CATEGORIES:Lezioni,Personale
|
||||
END:VEVENT
|
||||
END:VCALENDAR
|
||||
@@ -0,0 +1,682 @@
|
||||
//! The iCalendar round-trip, exercised against real server data.
|
||||
//!
|
||||
//! The corpus in `tests/golden/` is described in its own README. The contract
|
||||
//! asserted here is that **parse → write → parse is stable**: whatever the
|
||||
//! first parse understood, the second must understand identically. A file that
|
||||
//! fails is a defect in the parser or the writer, never a reason to edit the
|
||||
//! file.
|
||||
//!
|
||||
//! Stability alone is not enough, though — a parser that dropped `ATTENDEE`
|
||||
//! entirely would round-trip perfectly and still be wrong. So the structural
|
||||
//! checks below assert that nothing *disappears*, and the named tests pin the
|
||||
//! specific things real producers do that the previous iteration got wrong.
|
||||
|
||||
#![allow(clippy::unwrap_used, clippy::expect_used)]
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use runway_core::ical::{self, IcalError};
|
||||
use runway_core::model::*;
|
||||
use std::collections::BTreeMap;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
// ------------------------------------------------------------------ corpus --
|
||||
|
||||
fn golden_dir() -> PathBuf {
|
||||
Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/golden")
|
||||
}
|
||||
|
||||
/// Every `.ics` in the corpus, as (relative name, contents).
|
||||
fn corpus() -> Vec<(String, String)> {
|
||||
fn walk(dir: &Path, root: &Path, out: &mut Vec<(String, String)>) {
|
||||
let mut entries: Vec<_> = std::fs::read_dir(dir)
|
||||
.expect("golden corpus is readable")
|
||||
.filter_map(Result::ok)
|
||||
.map(|e| e.path())
|
||||
.collect();
|
||||
entries.sort();
|
||||
for path in entries {
|
||||
if path.is_dir() {
|
||||
walk(&path, root, out);
|
||||
} else if path.extension().is_some_and(|e| e == "ics") {
|
||||
let name = path
|
||||
.strip_prefix(root)
|
||||
.unwrap_or(&path)
|
||||
.to_string_lossy()
|
||||
.into_owned();
|
||||
out.push((name, std::fs::read_to_string(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
let root = golden_dir();
|
||||
let mut out = Vec::new();
|
||||
walk(&root, &root, &mut out);
|
||||
assert!(!out.is_empty(), "the golden corpus must not be empty");
|
||||
out
|
||||
}
|
||||
|
||||
fn load(name: &str) -> VCalendar {
|
||||
let path = golden_dir().join(name);
|
||||
let text = std::fs::read_to_string(&path).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
ical::parse(&text).unwrap_or_else(|e| panic!("{name} failed to parse: {e}"))
|
||||
}
|
||||
|
||||
/// Counts every property name in a document, at any depth.
|
||||
fn property_names(ics: &str) -> BTreeMap<String, usize> {
|
||||
let unfolded = ics.replace("\r\n ", "").replace("\n ", "");
|
||||
let mut counts = BTreeMap::new();
|
||||
for line in unfolded.lines() {
|
||||
let Some((head, _)) = line.split_once(':') else {
|
||||
continue;
|
||||
};
|
||||
let name = head.split(';').next().unwrap_or(head).to_uppercase();
|
||||
if name == "BEGIN" || name == "END" || name.is_empty() {
|
||||
continue;
|
||||
}
|
||||
*counts.entry(name).or_insert(0) += 1;
|
||||
}
|
||||
counts
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------- the round-trip --
|
||||
|
||||
#[test]
|
||||
fn every_golden_file_parses() {
|
||||
for (name, text) in corpus() {
|
||||
if let Err(e) = ical::parse(&text) {
|
||||
panic!("{name} failed to parse: {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_golden_file_survives_a_round_trip() {
|
||||
for (name, text) in corpus() {
|
||||
let first = ical::parse(&text).unwrap_or_else(|e| panic!("{name}: {e}"));
|
||||
let written = ical::write(&first);
|
||||
let second = ical::parse(&written)
|
||||
.unwrap_or_else(|e| panic!("{name} did not survive being written: {e}\n{written}"));
|
||||
assert_eq!(first, second, "{name} changed meaning on a round trip");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn writing_is_deterministic() {
|
||||
for (name, text) in corpus() {
|
||||
let parsed = ical::parse(&text).unwrap();
|
||||
assert_eq!(
|
||||
ical::write(&parsed),
|
||||
ical::write(&parsed),
|
||||
"{name}: the same calendar must always produce the same bytes, or \
|
||||
every read would look like a remote edit to CalDAV",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_property_is_dropped_on_a_round_trip() {
|
||||
for (name, text) in corpus() {
|
||||
let written = ical::write(&ical::parse(&text).unwrap());
|
||||
let (before, after) = (property_names(&text), property_names(&written));
|
||||
|
||||
for (property, count) in &before {
|
||||
// DTEND and DURATION are alternatives; a producer may send both and
|
||||
// we deliberately keep only the explicit end.
|
||||
if property == "DURATION" && before.contains_key("DTEND") {
|
||||
continue;
|
||||
}
|
||||
let kept = after.get(property).copied().unwrap_or(0);
|
||||
assert!(
|
||||
kept >= *count,
|
||||
"{name}: {property} appeared {count} time(s) in the source but \
|
||||
{kept} time(s) after a round trip -- a parse that silently \
|
||||
drops properties is exactly what produced the phantom \
|
||||
duplicates in the last iteration",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- folding --
|
||||
|
||||
#[test]
|
||||
fn output_is_folded_to_75_octets() {
|
||||
for (name, text) in corpus() {
|
||||
let written = ical::write(&ical::parse(&text).unwrap());
|
||||
for (n, line) in written.split("\r\n").enumerate() {
|
||||
assert!(
|
||||
line.len() <= 75,
|
||||
"{name} line {}: {} octets, over the RFC 5545 limit\n{line}",
|
||||
n + 1,
|
||||
line.len(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_uses_crlf_line_endings() {
|
||||
for (name, text) in corpus() {
|
||||
let written = ical::write(&ical::parse(&text).unwrap());
|
||||
assert!(
|
||||
!written.replace("\r\n", "").contains('\n'),
|
||||
"{name}: every line ending must be CRLF",
|
||||
);
|
||||
assert!(written.ends_with("END:VCALENDAR\r\n"), "{name}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_long_line_is_folded_and_reads_back_identically() {
|
||||
// v1 wrote this DESCRIPTION as a single 248-octet line, in violation of
|
||||
// RFC 5545 3.1, and those events are still on the server.
|
||||
let calendar = load("baikal/runway-v1-unfolded-line.ics");
|
||||
let description = calendar.events[0].description.clone().unwrap();
|
||||
assert!(description.len() > 200);
|
||||
|
||||
let written = ical::write(&calendar);
|
||||
assert!(
|
||||
written.contains("\r\n "),
|
||||
"a 248-octet value must come back folded",
|
||||
);
|
||||
let reparsed = ical::parse(&written).unwrap();
|
||||
assert_eq!(
|
||||
reparsed.events[0].description.as_deref(),
|
||||
Some(description.as_str()),
|
||||
"unfolding must reconstruct the value exactly",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn folding_never_splits_a_utf8_character() {
|
||||
let mut event = VEvent::new(CalendarDateTime::Utc {
|
||||
utc: chrono::Utc::now(),
|
||||
});
|
||||
event.description = Some("é".repeat(200));
|
||||
|
||||
let written = ical::write(&VCalendar::with_events(vec![event.clone()]));
|
||||
// Reaching this point at all means every fold landed on a boundary; a bad
|
||||
// split would have produced invalid UTF-8 rather than a String.
|
||||
for line in written.split("\r\n") {
|
||||
assert!(line.len() <= 75);
|
||||
}
|
||||
let reparsed = ical::parse(&written).unwrap();
|
||||
assert_eq!(reparsed.events[0].description, event.description);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- timezones --
|
||||
|
||||
#[test]
|
||||
fn windows_timezone_identifiers_are_preserved_verbatim() {
|
||||
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||
|
||||
let ids: Vec<&str> = calendar
|
||||
.timezones
|
||||
.iter()
|
||||
.map(|tz| tz.tzid.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
ids.contains(&"Pacific Standard Time"),
|
||||
"got {ids:?} -- Exchange names its zones in Windows form, and \
|
||||
normalising to IANA at parse time would make the document \
|
||||
unrepresentable; the mapping belongs at the point of use",
|
||||
);
|
||||
|
||||
let start = &calendar.events[0].dtstart;
|
||||
assert_eq!(
|
||||
start.tzid().map(TzId::as_str),
|
||||
Some("Pacific Standard Time"),
|
||||
);
|
||||
assert!(
|
||||
calendar.undefined_tzids().is_empty(),
|
||||
"the feed defines its own zones"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_timezone_definition_carries_its_transition_rules() {
|
||||
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||
let tz = calendar.timezone("Pacific Standard Time").unwrap();
|
||||
|
||||
assert!(tz.has_windows_style_id());
|
||||
assert_eq!(tz.rules.len(), 2, "a STANDARD and a DAYLIGHT block");
|
||||
|
||||
let standard = tz
|
||||
.rules
|
||||
.iter()
|
||||
.find(|r| r.kind == TimeZoneRuleKind::Standard)
|
||||
.unwrap();
|
||||
assert_eq!(standard.offset_from.seconds(), -7 * 3600);
|
||||
assert_eq!(standard.offset_to.seconds(), -8 * 3600);
|
||||
assert!(standard.rrule.is_some());
|
||||
assert_eq!(
|
||||
standard.dtstart.date().to_string(),
|
||||
"1601-01-01",
|
||||
"Exchange really does write the year 1601 here; a parser that \
|
||||
range-checks the year rejects real data",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_zoned_start_is_not_promoted_to_utc() {
|
||||
let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics");
|
||||
|
||||
assert_eq!(
|
||||
calendar.events[0].dtstart.tzid().map(TzId::as_str),
|
||||
Some("America/New_York"),
|
||||
"the previous parser commented 'if no TZID, treat as UTC' and applied \
|
||||
that reasoning widely enough to drift recurring events across DST",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_floating_time_stays_floating() {
|
||||
let calendar = load("synthetic/floating-time.ics");
|
||||
|
||||
assert!(
|
||||
matches!(
|
||||
calendar.events[0].dtstart,
|
||||
CalendarDateTime::Floating { .. }
|
||||
),
|
||||
"a value with neither Z nor TZID means local wall-clock time wherever \
|
||||
it is read, which is not the same as UTC",
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------- recurrence --
|
||||
|
||||
#[test]
|
||||
fn a_master_and_its_overrides_are_kept_apart() {
|
||||
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||
|
||||
let masters = calendar.events.iter().filter(|e| !e.is_override()).count();
|
||||
let overrides = calendar.overrides().count();
|
||||
assert!(overrides >= 4, "got {overrides} overrides");
|
||||
assert!(masters >= 2);
|
||||
|
||||
// Grouping by UID is the whole point: several events sharing a summary and
|
||||
// a UID are one series, not duplicates to be merged away.
|
||||
let series_uid = calendar
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| e.rrule.is_some())
|
||||
.map(|e| e.uid.clone())
|
||||
.unwrap();
|
||||
let in_series = calendar
|
||||
.events
|
||||
.iter()
|
||||
.filter(|e| e.uid == series_uid)
|
||||
.count();
|
||||
assert!(
|
||||
in_series > 1,
|
||||
"the same UID appearing repeatedly is a series with exceptions",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_can_move_its_occurrence_to_another_day() {
|
||||
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||
|
||||
let moved = calendar
|
||||
.overrides()
|
||||
.find(|e| {
|
||||
e.recurrence_id
|
||||
.as_ref()
|
||||
.is_some_and(|rid| rid.date() != e.dtstart.date())
|
||||
})
|
||||
.expect("the corpus contains a rescheduled occurrence");
|
||||
|
||||
assert_ne!(
|
||||
moved.recurrence_id.as_ref().map(CalendarDateTime::date),
|
||||
Some(moved.dtstart.date()),
|
||||
"RECURRENCE-ID identifies which occurrence is replaced, not when the \
|
||||
replacement happens -- conflating the two is how a moved meeting ends \
|
||||
up rendered on its original day",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_override_without_its_master_is_still_parsed() {
|
||||
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||
|
||||
let orphans: Vec<&VEvent> = calendar
|
||||
.overrides()
|
||||
.filter(|e| {
|
||||
!calendar
|
||||
.events
|
||||
.iter()
|
||||
.any(|m| m.uid == e.uid && !m.is_override())
|
||||
})
|
||||
.collect();
|
||||
|
||||
assert!(
|
||||
!orphans.is_empty(),
|
||||
"a published feed truncates series at the window edge, leaving \
|
||||
overrides whose master is outside it; those are real events and \
|
||||
discarding them loses data",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn every_exdate_on_one_line_is_kept() {
|
||||
let calendar = load("outlook/feed-overrides-windows-tz.ics");
|
||||
|
||||
let master = calendar
|
||||
.events
|
||||
.iter()
|
||||
.find(|e| !e.exdate.is_empty())
|
||||
.expect("the corpus has a series with exclusions");
|
||||
|
||||
assert!(
|
||||
master.exdate.len() >= 8,
|
||||
"got {} EXDATEs -- Exchange writes them comma-separated on a single \
|
||||
line, and a parser keyed on property name alone keeps only one",
|
||||
master.exdate.len(),
|
||||
);
|
||||
assert!(
|
||||
master.exdate.iter().all(|d| d.tzid().is_some()),
|
||||
"the TZID parameter applies to every value on the line",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rdate_and_exdate_coexist() {
|
||||
let calendar = load("synthetic/rdate-and-exdate.ics");
|
||||
let event = &calendar.events[0];
|
||||
|
||||
assert_eq!(event.rdate.len(), 1);
|
||||
assert_eq!(event.exdate.len(), 2);
|
||||
assert!(event.is_recurring());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_day_series_keeps_date_valued_exdates() {
|
||||
let calendar = load("synthetic/allday-exdate-value-date.ics");
|
||||
let event = &calendar.events[0];
|
||||
|
||||
assert!(event.is_all_day());
|
||||
assert_eq!(event.exdate.len(), 2);
|
||||
assert!(
|
||||
event.exdate.iter().all(CalendarDateTime::is_date_only),
|
||||
"an exclusion from an all-day series is a date, not a date-time",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_master_and_override_in_one_resource_stay_together() {
|
||||
let calendar = load("synthetic/series-master-with-override.ics");
|
||||
|
||||
assert_eq!(calendar.events.len(), 2);
|
||||
assert!(calendar.master().is_some());
|
||||
assert_eq!(calendar.overrides().count(), 1);
|
||||
assert_eq!(
|
||||
calendar.events[0].uid, calendar.events[1].uid,
|
||||
"one CalDAV resource, one UID",
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- content --
|
||||
|
||||
#[test]
|
||||
fn repeated_properties_are_all_kept() {
|
||||
let calendar = load("baikal/thunderbird-google-invite-attendees.ics");
|
||||
|
||||
assert_eq!(
|
||||
calendar.events[0].attendees.len(),
|
||||
3,
|
||||
"three ATTENDEE lines must produce three attendees; collecting \
|
||||
properties into a map keeps only the last, which is how the previous \
|
||||
backend ended up with a 'TODO: Parse attendees properly'",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn attendee_parameters_survive() {
|
||||
let calendar = load("baikal/thunderbird-google-invite-attendees.ics");
|
||||
let attendee = &calendar.events[0].attendees[0];
|
||||
|
||||
assert!(attendee.common_name.is_some());
|
||||
assert_eq!(attendee.role, Some(Role::ReqParticipant));
|
||||
assert_eq!(attendee.rsvp, Some(true));
|
||||
assert!(
|
||||
attendee
|
||||
.unknown_params
|
||||
.iter()
|
||||
.any(|p| p.name.eq_ignore_ascii_case("X-NUM-GUESTS")),
|
||||
"a parameter we do not model is still somebody's data",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_quoted_parameter_containing_a_comma_round_trips() {
|
||||
let calendar = load("baikal/outlook-invite-quoted-cn.ics");
|
||||
let organizer = calendar.events[0].organizer.as_ref().unwrap();
|
||||
|
||||
let name = organizer.common_name.as_deref().unwrap();
|
||||
let written = ical::write(&calendar);
|
||||
let reparsed = ical::parse(&written).unwrap();
|
||||
|
||||
assert_eq!(
|
||||
reparsed.events[0]
|
||||
.organizer
|
||||
.as_ref()
|
||||
.and_then(|o| o.common_name.as_deref()),
|
||||
Some(name),
|
||||
"an unquoted CN containing a comma reads back as two parameters",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn two_alarms_on_one_event_are_both_kept() {
|
||||
let calendar = load("baikal/davx5-zoned-two-alarms-vtimezone.ics");
|
||||
|
||||
assert_eq!(calendar.events[0].alarms.len(), 2);
|
||||
assert!(
|
||||
calendar.events[0]
|
||||
.alarms
|
||||
.iter()
|
||||
.all(|a| a.action == AlarmAction::Display)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_state_on_an_alarm_is_carried_through() {
|
||||
let calendar = load("baikal/evolution-x-lic-error.ics");
|
||||
let alarm = &calendar.events[0].alarms[0];
|
||||
|
||||
let names: Vec<&str> = alarm
|
||||
.unknown_properties
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n.eq_ignore_ascii_case("ACKNOWLEDGED")),
|
||||
"got {names:?} -- ACKNOWLEDGED records that a person dismissed this \
|
||||
alarm; dropping it makes the alarm fire again in their other client",
|
||||
);
|
||||
assert!(
|
||||
names
|
||||
.iter()
|
||||
.any(|n| n.eq_ignore_ascii_case("X-EVOLUTION-ALARM-UID"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_libical_error_marker_does_not_stop_the_parse() {
|
||||
// 22 objects on the real server carry these, written by Evolution.
|
||||
let calendar = load("baikal/evolution-x-lic-error.ics");
|
||||
|
||||
assert_eq!(calendar.events.len(), 1);
|
||||
let written = ical::write(&calendar);
|
||||
assert!(
|
||||
written.contains("X-LIC-ERROR"),
|
||||
"and it is carried, not swallowed"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn vendor_properties_on_an_event_are_carried_through() {
|
||||
let calendar = load("baikal/thunderbird-x-moz-props.ics");
|
||||
|
||||
let names: Vec<&str> = calendar.events[0]
|
||||
.unknown_properties
|
||||
.iter()
|
||||
.map(|p| p.name.as_str())
|
||||
.collect();
|
||||
assert!(
|
||||
names.iter().any(|n| n.starts_with("X-MOZ")),
|
||||
"got {names:?}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_alarm_relative_to_the_end_stays_relative_to_the_end() {
|
||||
let mut event = VEvent::new(CalendarDateTime::Utc {
|
||||
utc: chrono::Utc::now(),
|
||||
});
|
||||
event.alarms.push(VAlarm {
|
||||
trigger: AlarmTrigger::Relative {
|
||||
offset: IcalDuration::minutes(-15).unwrap(),
|
||||
related: TriggerRelation::End,
|
||||
},
|
||||
..VAlarm::display_before(IcalDuration::minutes(-15).unwrap(), "Wrap up")
|
||||
});
|
||||
|
||||
let written = ical::write(&VCalendar::with_events(vec![event]));
|
||||
assert!(written.contains("RELATED=END"), "{written}");
|
||||
|
||||
let reparsed = ical::parse(&written).unwrap();
|
||||
assert!(matches!(
|
||||
reparsed.events[0].alarms[0].trigger,
|
||||
AlarmTrigger::Relative {
|
||||
related: TriggerRelation::End,
|
||||
..
|
||||
}
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_escapes_and_non_ascii_survive() {
|
||||
let calendar = load("synthetic/text-escapes-and-utf8.ics");
|
||||
let event = &calendar.events[0];
|
||||
|
||||
let summary = event.summary.as_deref().unwrap();
|
||||
assert!(summary.contains('—') && summary.contains("caffè"));
|
||||
assert!(
|
||||
summary.contains(", cioccolato"),
|
||||
"an escaped comma is a comma"
|
||||
);
|
||||
|
||||
let description = event.description.as_deref().unwrap();
|
||||
assert!(description.contains('\n'), "\\n is a newline");
|
||||
assert!(description.contains(';'), "\\; is a semicolon");
|
||||
assert!(description.contains('\\'), "\\\\ is a backslash");
|
||||
|
||||
let reparsed = ical::parse(&ical::write(&calendar)).unwrap();
|
||||
assert_eq!(reparsed.events[0].summary.as_deref(), Some(summary));
|
||||
assert_eq!(reparsed.events[0].description.as_deref(), Some(description));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_value_text_property_is_not_escaped_into_one_value() {
|
||||
let mut event = VEvent::new(CalendarDateTime::Date {
|
||||
date: chrono::NaiveDate::from_ymd_opt(2026, 1, 5).unwrap(),
|
||||
});
|
||||
event.categories = vec!["Work".to_owned(), "Personal".to_owned()];
|
||||
|
||||
let written = ical::write(&VCalendar::with_events(vec![event]));
|
||||
|
||||
assert!(
|
||||
written.contains("CATEGORIES:Work,Personal"),
|
||||
"the separator must stay bare. Escaping the whole joined value would \
|
||||
still round-trip through our own parser, and every other client would \
|
||||
read one category named \"Work,Personal\"\n{written}",
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ timing --
|
||||
|
||||
#[test]
|
||||
fn a_duration_is_kept_as_a_duration() {
|
||||
let calendar = load("synthetic/duration-not-dtend.ics");
|
||||
let event = &calendar.events[0];
|
||||
|
||||
assert!(matches!(event.end, Some(EventEnd::Duration { .. })));
|
||||
assert_eq!(event.duration(), chrono::TimeDelta::minutes(90));
|
||||
assert!(
|
||||
ical::write(&calendar).contains("DURATION:PT1H30M"),
|
||||
"a producer that sent DURATION should get DURATION back",
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_all_day_value_stays_date_valued() {
|
||||
let calendar = load("baikal/davx5-allday-weekly-alarm.ics");
|
||||
let event = &calendar.events[0];
|
||||
|
||||
assert!(event.is_all_day());
|
||||
assert!(ical::write(&calendar).contains("DTSTART;VALUE=DATE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_utc_timestamp_missing_its_z_is_repaired_on_write() {
|
||||
// Runway v1 wrote CREATED without the Z that RFC 5545 requires. Reading it
|
||||
// as UTC and writing it back correctly repairs the record instead of
|
||||
// propagating the defect.
|
||||
let calendar = load("baikal/runway-v1-unfolded-line.ics");
|
||||
assert!(calendar.events[0].created.is_some());
|
||||
|
||||
let written = ical::write(&calendar);
|
||||
let created = written
|
||||
.lines()
|
||||
.find(|l| l.starts_with("CREATED:"))
|
||||
.expect("CREATED is written");
|
||||
assert!(created.ends_with('Z'), "{created}");
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------ errors --
|
||||
|
||||
#[test]
|
||||
fn a_missing_dtstart_is_an_error_not_a_guess() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:no-start\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
END:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
assert!(matches!(
|
||||
ical::parse(ics),
|
||||
Err(IcalError::MissingProperty {
|
||||
component: "VEVENT",
|
||||
property: "DTSTART"
|
||||
})
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_unreadable_datetime_names_the_property_and_the_value() {
|
||||
let ics = "BEGIN:VCALENDAR\r\nVERSION:2.0\r\nPRODID:-//x//x//EN\r\n\
|
||||
BEGIN:VEVENT\r\nUID:bad\r\nDTSTAMP:20260101T000000Z\r\n\
|
||||
DTSTART:not-a-date\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n";
|
||||
|
||||
match ical::parse(ics) {
|
||||
Err(IcalError::InvalidValue {
|
||||
property, value, ..
|
||||
}) => {
|
||||
assert_eq!(property, "DTSTART");
|
||||
assert_eq!(value, "not-a-date");
|
||||
}
|
||||
other => panic!("expected a typed error, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn input_without_a_calendar_is_reported_as_such() {
|
||||
// Prose is a syntax error; a well-formed component tree that simply has no
|
||||
// VCALENDAR in it is the NoCalendar case.
|
||||
assert!(matches!(
|
||||
ical::parse("not a calendar"),
|
||||
Err(IcalError::Syntax(_))
|
||||
));
|
||||
assert!(matches!(
|
||||
ical::parse("BEGIN:VTODO\r\nUID:x\r\nEND:VTODO\r\n"),
|
||||
Err(IcalError::NoCalendar),
|
||||
));
|
||||
}
|
||||
@@ -443,7 +443,7 @@ fn series_with_one_override() -> CalendarObject {
|
||||
href: "/calendars/connor/personal/shared-uid.ics".to_owned(),
|
||||
etag: Some("\"abc123\"".to_owned()),
|
||||
calendar_path: "/calendars/connor/personal/".to_owned(),
|
||||
events: vec![master, moved],
|
||||
calendar: VCalendar::with_events(vec![master, moved]),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -454,7 +454,7 @@ fn a_resource_separates_its_master_from_its_overrides() {
|
||||
assert!(object.master().is_some());
|
||||
assert_eq!(object.overrides().count(), 1);
|
||||
assert_eq!(
|
||||
object.events.len(),
|
||||
object.events().len(),
|
||||
2,
|
||||
"a master and its override are two VEVENTs in one resource, not a \
|
||||
duplicate to be deduplicated away",
|
||||
@@ -472,7 +472,7 @@ fn every_event_in_a_resource_shares_one_uid() {
|
||||
#[test]
|
||||
fn a_resource_with_mismatched_uids_is_detected() {
|
||||
let mut object = series_with_one_override();
|
||||
object.events[1].uid = "different".to_owned();
|
||||
object.calendar.events[1].uid = "different".to_owned();
|
||||
|
||||
assert!(
|
||||
!object.has_consistent_uid(),
|
||||
@@ -484,7 +484,7 @@ fn a_resource_with_mismatched_uids_is_detected() {
|
||||
#[test]
|
||||
fn a_resource_holding_only_overrides_has_no_master() {
|
||||
let mut object = series_with_one_override();
|
||||
object.events.retain(VEvent::is_override);
|
||||
object.calendar.events.retain(VEvent::is_override);
|
||||
|
||||
assert!(object.master().is_none());
|
||||
assert_eq!(object.overrides().count(), 1);
|
||||
|
||||
Reference in New Issue
Block a user