summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/calendar-sync.el145
-rw-r--r--tests/test-calendar-sync--collect-exdates.el147
-rw-r--r--tests/test-calendar-sync--filter-exdates.el121
-rw-r--r--tests/test-calendar-sync--get-exdates.el121
-rw-r--r--tests/test-calendar-sync--parse-exdate.el80
-rw-r--r--tests/test-integration-calendar-sync-exdate.el207
6 files changed, 811 insertions, 10 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 944c5042..53a38f6f 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -208,7 +208,7 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
"Normalize line endings in CONTENT to Unix format (LF only).
Removes all carriage return characters (\\r) from CONTENT.
The iCalendar format (RFC 5545) uses CRLF line endings, but Emacs
-and org-mode expect LF only. This function ensures consistent line
+and 'org-mode' expect LF only. This function ensures consistent line
endings throughout the parsing pipeline.
Returns CONTENT with all \\r characters removed."
@@ -451,6 +451,124 @@ Returns new list with matching occurrences replaced by exception times."
occurrence)))))
occurrences)))
+;;; EXDATE (Excluded Date) Handling
+
+(defun calendar-sync--get-exdates (event-str)
+ "Extract all EXDATE values from EVENT-STR.
+Returns list of datetime strings (without TZID parameters), or nil if none found.
+Handles both simple values and values with parameters like TZID."
+ (when (and event-str (stringp event-str) (not (string-empty-p event-str)))
+ (let ((exdates '())
+ (pos 0))
+ ;; Find all EXDATE lines
+ (while (string-match "^EXDATE[^:\n]*:\\([^\n]+\\)" event-str pos)
+ (push (match-string 1 event-str) exdates)
+ (setq pos (match-end 0)))
+ (nreverse exdates))))
+
+(defun calendar-sync--get-exdate-line (event-str exdate-value)
+ "Find the full EXDATE line containing EXDATE-VALUE from EVENT-STR.
+Returns the complete line like 'EXDATE;TZID=America/New_York:20260210T130000'.
+Returns nil if not found."
+ (when (and event-str (stringp event-str) exdate-value)
+ (let ((pattern (format "^\\(EXDATE[^:]*:%s\\)" (regexp-quote exdate-value))))
+ (when (string-match pattern event-str)
+ (match-string 1 event-str)))))
+
+(defun calendar-sync--parse-exdate (exdate-value)
+ "Parse EXDATE-VALUE into (year month day hour minute) list.
+Returns nil for invalid input. For date-only values, returns (year month day nil nil)."
+ (when (and exdate-value
+ (stringp exdate-value)
+ (not (string-empty-p exdate-value)))
+ (cond
+ ;; DateTime format: 20260203T130000Z or 20260203T130000
+ ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" exdate-value)
+ (list (string-to-number (match-string 1 exdate-value))
+ (string-to-number (match-string 2 exdate-value))
+ (string-to-number (match-string 3 exdate-value))
+ (string-to-number (match-string 4 exdate-value))
+ (string-to-number (match-string 5 exdate-value))))
+ ;; Date-only format: 20260203
+ ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" exdate-value)
+ (list (string-to-number (match-string 1 exdate-value))
+ (string-to-number (match-string 2 exdate-value))
+ (string-to-number (match-string 3 exdate-value))
+ nil nil))
+ (t nil))))
+
+(defun calendar-sync--collect-exdates (event-str)
+ "Collect all excluded dates from EVENT-STR, handling timezone conversion.
+Returns list of parsed datetime lists (year month day hour minute).
+Converts TZID-qualified and UTC times to local time."
+ (if (or (null event-str)
+ (not (stringp event-str))
+ (string-empty-p event-str))
+ '()
+ (let ((exdate-values (calendar-sync--get-exdates event-str))
+ (result '()))
+ (dolist (exdate-value exdate-values)
+ (let* ((exdate-line (calendar-sync--get-exdate-line event-str exdate-value))
+ (exdate-tzid (and exdate-line (calendar-sync--extract-tzid exdate-line)))
+ (exdate-is-utc (and exdate-value (string-suffix-p "Z" exdate-value)))
+ (exdate-parsed (calendar-sync--parse-exdate exdate-value)))
+ (when exdate-parsed
+ (let ((local-exdate
+ (cond
+ ;; UTC time (Z suffix) - convert from UTC
+ (exdate-is-utc
+ (calendar-sync--convert-utc-to-local
+ (nth 0 exdate-parsed)
+ (nth 1 exdate-parsed)
+ (nth 2 exdate-parsed)
+ (or (nth 3 exdate-parsed) 0)
+ (or (nth 4 exdate-parsed) 0)
+ 0))
+ ;; TZID specified - convert from that timezone
+ (exdate-tzid
+ (or (calendar-sync--convert-tz-to-local
+ (nth 0 exdate-parsed)
+ (nth 1 exdate-parsed)
+ (nth 2 exdate-parsed)
+ (or (nth 3 exdate-parsed) 0)
+ (or (nth 4 exdate-parsed) 0)
+ exdate-tzid)
+ exdate-parsed))
+ ;; No timezone info - use as-is (local time)
+ (t exdate-parsed))))
+ (push local-exdate result)))))
+ (nreverse result))))
+
+(defun calendar-sync--exdate-matches-p (occurrence-start exdate)
+ "Check if OCCURRENCE-START matches EXDATE.
+OCCURRENCE-START is (year month day hour minute).
+EXDATE is (year month day hour minute) or (year month day nil nil) for date-only.
+Date-only EXDATE matches any time on that day."
+ (and occurrence-start exdate
+ (= (nth 0 occurrence-start) (nth 0 exdate)) ; year
+ (= (nth 1 occurrence-start) (nth 1 exdate)) ; month
+ (= (nth 2 occurrence-start) (nth 2 exdate)) ; day
+ ;; If EXDATE has nil hour/minute (date-only), match any time
+ (or (null (nth 3 exdate))
+ (and (nth 3 occurrence-start)
+ (= (nth 3 occurrence-start) (nth 3 exdate))
+ (= (or (nth 4 occurrence-start) 0) (or (nth 4 exdate) 0))))))
+
+(defun calendar-sync--filter-exdates (occurrences exdates)
+ "Filter OCCURRENCES list to remove entries matching EXDATES.
+OCCURRENCES is list of event plists with :start key.
+EXDATES is list of parsed datetime lists from `calendar-sync--collect-exdates'.
+Returns filtered list with excluded dates removed."
+ (if (or (null occurrences) (null exdates))
+ (or occurrences '())
+ (cl-remove-if
+ (lambda (occurrence)
+ (let ((occ-start (plist-get occurrence :start)))
+ (cl-some (lambda (exdate)
+ (calendar-sync--exdate-matches-p occ-start exdate))
+ exdates)))
+ occurrences)))
+
;;; .ics Parsing
(defun calendar-sync--split-events (ics-content)
@@ -784,20 +902,27 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
(defun calendar-sync--expand-recurring-event (event-str range)
"Expand recurring event EVENT-STR into individual occurrences within RANGE.
-Returns list of event plists, or nil if not a recurring event."
+Returns list of event plists, or nil if not a recurring event.
+Filters out dates excluded via EXDATE properties."
(let ((rrule (calendar-sync--get-property event-str "RRULE")))
(when rrule
(let* ((base-event (calendar-sync--parse-event event-str))
(parsed-rrule (calendar-sync--parse-rrule rrule))
- (freq (plist-get parsed-rrule :freq)))
+ (freq (plist-get parsed-rrule :freq))
+ (exdates (calendar-sync--collect-exdates event-str)))
(when base-event
- (pcase freq
- ('daily (calendar-sync--expand-daily base-event parsed-rrule range))
- ('weekly (calendar-sync--expand-weekly base-event parsed-rrule range))
- ('monthly (calendar-sync--expand-monthly base-event parsed-rrule range))
- ('yearly (calendar-sync--expand-yearly base-event parsed-rrule range))
- (_ (cj/log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq)
- nil)))))))
+ (let ((occurrences
+ (pcase freq
+ ('daily (calendar-sync--expand-daily base-event parsed-rrule range))
+ ('weekly (calendar-sync--expand-weekly base-event parsed-rrule range))
+ ('monthly (calendar-sync--expand-monthly base-event parsed-rrule range))
+ ('yearly (calendar-sync--expand-yearly base-event parsed-rrule range))
+ (_ (cj/log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq)
+ nil))))
+ ;; Filter out EXDATE occurrences
+ (if exdates
+ (calendar-sync--filter-exdates occurrences exdates)
+ occurrences)))))))
(defun calendar-sync--parse-event (event-str)
"Parse single VEVENT string EVENT-STR into plist.
diff --git a/tests/test-calendar-sync--collect-exdates.el b/tests/test-calendar-sync--collect-exdates.el
new file mode 100644
index 00000000..b70277e1
--- /dev/null
+++ b/tests/test-calendar-sync--collect-exdates.el
@@ -0,0 +1,147 @@
+;;; test-calendar-sync--collect-exdates.el --- Tests for EXDATE collection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--collect-exdates function.
+;; Tests collection of all excluded dates from an event, handling timezone conversion.
+;; Following quality-engineer.org guidelines: one function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--collect-exdates-normal-single-returns-list ()
+ "Test collecting single EXDATE returns list with one parsed value."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (listp result))
+ (should (= 1 (length result)))
+ (should (equal '(2026 2 10 13 0) (car result))))))
+
+(ert-deftest test-calendar-sync--collect-exdates-normal-multiple-returns-all ()
+ "Test collecting multiple EXDATEs returns all parsed values."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000
+EXDATE:20260217T130000
+EXDATE:20260224T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (= 3 (length result)))
+ (should (member '(2026 2 10 13 0) result))
+ (should (member '(2026 2 17 13 0) result))
+ (should (member '(2026 2 24 13 0) result)))))
+
+(ert-deftest test-calendar-sync--collect-exdates-normal-tzid-converts-to-local ()
+ "Test that TZID-qualified EXDATEs are converted to local time."
+ ;; Use a timezone different from local to verify conversion
+ ;; We'll use Europe/London and check that conversion happens
+ (let ((event "BEGIN:VEVENT
+DTSTART;TZID=Europe/London:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;TZID=Europe/London:20260210T130000
+SUMMARY:London Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (= 1 (length result)))
+ ;; Result should be a valid datetime list (conversion may differ based on local TZ)
+ (let ((parsed (car result)))
+ (should (= 5 (length parsed)))
+ (should (numberp (nth 0 parsed))) ; year
+ (should (numberp (nth 1 parsed))) ; month
+ (should (numberp (nth 2 parsed))) ; day
+ (should (numberp (nth 3 parsed))) ; hour
+ (should (numberp (nth 4 parsed))))))) ; minute
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--collect-exdates-boundary-no-exdates-returns-empty ()
+ "Test that event without EXDATE returns empty list."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (listp result))
+ (should (= 0 (length result))))))
+
+(ert-deftest test-calendar-sync--collect-exdates-boundary-utc-converts-to-local ()
+ "Test that UTC (Z suffix) EXDATEs are converted to local time."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T180000Z
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T180000Z
+SUMMARY:UTC Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (= 1 (length result)))
+ ;; Result should be converted to local time
+ (let ((parsed (car result)))
+ (should (= 5 (length parsed)))
+ ;; Date should be valid
+ (should (numberp (nth 0 parsed)))))))
+
+(ert-deftest test-calendar-sync--collect-exdates-boundary-mixed-formats-handles-all ()
+ "Test handling mix of TZID, UTC, and local time EXDATEs."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000
+EXDATE:20260217T180000Z
+SUMMARY:Mixed Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (= 2 (length result)))
+ ;; Both should be valid parsed datetimes
+ (dolist (parsed result)
+ (should (= 5 (length parsed)))
+ (should (numberp (nth 0 parsed)))))))
+
+(ert-deftest test-calendar-sync--collect-exdates-boundary-date-only-returns-date ()
+ "Test collecting all-day EXDATE returns date with nil for time."
+ (let ((event "BEGIN:VEVENT
+DTSTART;VALUE=DATE:20260203
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;VALUE=DATE:20260210
+SUMMARY:All Day Event
+END:VEVENT"))
+ (let ((result (calendar-sync--collect-exdates event)))
+ (should (= 1 (length result)))
+ (let ((parsed (car result)))
+ (should (equal '(2026 2 10 nil nil) parsed))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--collect-exdates-error-empty-string-returns-empty ()
+ "Test that empty string returns empty list."
+ (let ((result (calendar-sync--collect-exdates "")))
+ (should (listp result))
+ (should (= 0 (length result)))))
+
+(ert-deftest test-calendar-sync--collect-exdates-error-nil-returns-empty ()
+ "Test that nil input returns empty list."
+ (let ((result (calendar-sync--collect-exdates nil)))
+ (should (listp result))
+ (should (= 0 (length result)))))
+
+(ert-deftest test-calendar-sync--collect-exdates-error-malformed-returns-empty ()
+ "Test that malformed event returns empty list."
+ (let ((result (calendar-sync--collect-exdates "not a vevent")))
+ (should (listp result))
+ (should (= 0 (length result)))))
+
+(provide 'test-calendar-sync--collect-exdates)
+;;; test-calendar-sync--collect-exdates.el ends here
diff --git a/tests/test-calendar-sync--filter-exdates.el b/tests/test-calendar-sync--filter-exdates.el
new file mode 100644
index 00000000..b0f2d6a4
--- /dev/null
+++ b/tests/test-calendar-sync--filter-exdates.el
@@ -0,0 +1,121 @@
+;;; test-calendar-sync--filter-exdates.el --- Tests for EXDATE filtering -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--filter-exdates function.
+;; Tests filtering occurrences list to remove EXDATE matches.
+;; Following quality-engineer.org guidelines: one function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--filter-exdates-normal-single-match-removes-one ()
+ "Test that single matching EXDATE removes one occurrence."
+ (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting")
+ (list :start '(2026 2 10 13 0) :summary "Meeting")
+ (list :start '(2026 2 17 13 0) :summary "Meeting")))
+ (exdates '((2026 2 10 13 0))))
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ (should (= 2 (length result)))
+ ;; Feb 10 should be removed
+ (should-not (cl-find-if (lambda (occ)
+ (equal '(2026 2 10 13 0) (plist-get occ :start)))
+ result))
+ ;; Feb 3 and Feb 17 should remain
+ (should (cl-find-if (lambda (occ)
+ (equal '(2026 2 3 13 0) (plist-get occ :start)))
+ result))
+ (should (cl-find-if (lambda (occ)
+ (equal '(2026 2 17 13 0) (plist-get occ :start)))
+ result)))))
+
+(ert-deftest test-calendar-sync--filter-exdates-normal-multiple-matches-removes-all ()
+ "Test that multiple EXDATEs remove all matching occurrences."
+ (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting")
+ (list :start '(2026 2 10 13 0) :summary "Meeting")
+ (list :start '(2026 2 17 13 0) :summary "Meeting")
+ (list :start '(2026 2 24 13 0) :summary "Meeting")))
+ (exdates '((2026 2 10 13 0) (2026 2 24 13 0))))
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ (should (= 2 (length result)))
+ ;; Feb 10 and Feb 24 should be removed
+ (should-not (cl-find-if (lambda (occ)
+ (equal '(2026 2 10 13 0) (plist-get occ :start)))
+ result))
+ (should-not (cl-find-if (lambda (occ)
+ (equal '(2026 2 24 13 0) (plist-get occ :start)))
+ result)))))
+
+(ert-deftest test-calendar-sync--filter-exdates-normal-preserves-non-matches ()
+ "Test that non-matching occurrences are preserved."
+ (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting")
+ (list :start '(2026 2 10 13 0) :summary "Meeting")))
+ (exdates '((2026 3 15 13 0)))) ; No match
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ (should (= 2 (length result)))
+ ;; Both should remain
+ (should (cl-find-if (lambda (occ)
+ (equal '(2026 2 3 13 0) (plist-get occ :start)))
+ result))
+ (should (cl-find-if (lambda (occ)
+ (equal '(2026 2 10 13 0) (plist-get occ :start)))
+ result)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--filter-exdates-boundary-empty-exdates-returns-all ()
+ "Test that empty exdates list returns all occurrences."
+ (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting")
+ (list :start '(2026 2 10 13 0) :summary "Meeting")))
+ (exdates '()))
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ (should (= 2 (length result))))))
+
+(ert-deftest test-calendar-sync--filter-exdates-boundary-empty-occurrences-returns-empty ()
+ "Test that empty occurrences list returns empty."
+ (let ((occurrences '())
+ (exdates '((2026 2 10 13 0))))
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ (should (= 0 (length result))))))
+
+(ert-deftest test-calendar-sync--filter-exdates-boundary-all-excluded-returns-empty ()
+ "Test that when all occurrences are excluded, returns empty."
+ (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting")
+ (list :start '(2026 2 10 13 0) :summary "Meeting")))
+ (exdates '((2026 2 3 13 0) (2026 2 10 13 0))))
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ (should (= 0 (length result))))))
+
+(ert-deftest test-calendar-sync--filter-exdates-boundary-date-only-matches-any-time ()
+ "Test that date-only EXDATE (nil hour/minute) matches any time on that day."
+ (let ((occurrences (list (list :start '(2026 2 3 9 0) :summary "Morning")
+ (list :start '(2026 2 3 13 0) :summary "Afternoon")
+ (list :start '(2026 2 10 13 0) :summary "Next Week")))
+ (exdates '((2026 2 3 nil nil)))) ; Date-only exclusion
+ (let ((result (calendar-sync--filter-exdates occurrences exdates)))
+ ;; Both Feb 3 occurrences should be removed
+ (should (= 1 (length result)))
+ (should (equal '(2026 2 10 13 0) (plist-get (car result) :start))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--filter-exdates-error-nil-occurrences-handles-gracefully ()
+ "Test that nil occurrences handles gracefully."
+ (let ((result (calendar-sync--filter-exdates nil '((2026 2 10 13 0)))))
+ (should (listp result))
+ (should (= 0 (length result)))))
+
+(ert-deftest test-calendar-sync--filter-exdates-error-nil-exdates-returns-occurrences ()
+ "Test that nil exdates returns original occurrences."
+ (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting"))))
+ (let ((result (calendar-sync--filter-exdates occurrences nil)))
+ (should (= 1 (length result))))))
+
+(provide 'test-calendar-sync--filter-exdates)
+;;; test-calendar-sync--filter-exdates.el ends here
diff --git a/tests/test-calendar-sync--get-exdates.el b/tests/test-calendar-sync--get-exdates.el
new file mode 100644
index 00000000..3283bbae
--- /dev/null
+++ b/tests/test-calendar-sync--get-exdates.el
@@ -0,0 +1,121 @@
+;;; test-calendar-sync--get-exdates.el --- Tests for EXDATE extraction -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--get-exdates function.
+;; Tests extraction of EXDATE properties from VEVENT strings.
+;; Following quality-engineer.org guidelines: one function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--get-exdates-normal-single-returns-list ()
+ "Test extracting single EXDATE returns list with one value."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (listp result))
+ (should (= 1 (length result)))
+ (should (string= "20260210T130000" (car result))))))
+
+(ert-deftest test-calendar-sync--get-exdates-normal-multiple-returns-all ()
+ "Test extracting multiple EXDATEs returns all values."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T130000
+EXDATE:20260217T130000
+EXDATE:20260224T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 3 (length result)))
+ (should (member "20260210T130000" result))
+ (should (member "20260217T130000" result))
+ (should (member "20260224T130000" result)))))
+
+(ert-deftest test-calendar-sync--get-exdates-normal-with-tzid-returns-value ()
+ "Test extracting EXDATE with TZID parameter extracts value correctly."
+ (let ((event "BEGIN:VEVENT
+DTSTART;TZID=America/New_York:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;TZID=America/New_York:20260210T130000
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 1 (length result)))
+ (should (string= "20260210T130000" (car result))))))
+
+(ert-deftest test-calendar-sync--get-exdates-normal-with-z-suffix-returns-value ()
+ "Test extracting UTC EXDATE with Z suffix."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T180000Z
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE:20260210T180000Z
+SUMMARY:Weekly UTC Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 1 (length result)))
+ (should (string= "20260210T180000Z" (car result))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--get-exdates-boundary-no-exdate-returns-nil ()
+ "Test that event without EXDATE returns nil."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+SUMMARY:Weekly Meeting
+END:VEVENT"))
+ (should (null (calendar-sync--get-exdates event)))))
+
+(ert-deftest test-calendar-sync--get-exdates-boundary-date-only-returns-value ()
+ "Test extracting all-day EXDATE (date only, no time)."
+ (let ((event "BEGIN:VEVENT
+DTSTART;VALUE=DATE:20260203
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;VALUE=DATE:20260210
+SUMMARY:All Day Event
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 1 (length result)))
+ (should (string= "20260210" (car result))))))
+
+(ert-deftest test-calendar-sync--get-exdates-boundary-multiple-params-returns-value ()
+ "Test extracting EXDATE with VALUE=DATE-TIME and TZID parameters."
+ (let ((event "BEGIN:VEVENT
+DTSTART;TZID=America/Chicago:20260203T130000
+RRULE:FREQ=WEEKLY;BYDAY=TU
+EXDATE;VALUE=DATE-TIME;TZID=America/Chicago:20260210T130000
+SUMMARY:Multi-param Meeting
+END:VEVENT"))
+ (let ((result (calendar-sync--get-exdates event)))
+ (should (= 1 (length result)))
+ (should (string= "20260210T130000" (car result))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--get-exdates-error-empty-string-returns-nil ()
+ "Test that empty string returns nil."
+ (should (null (calendar-sync--get-exdates ""))))
+
+(ert-deftest test-calendar-sync--get-exdates-error-nil-input-returns-nil ()
+ "Test that nil input returns nil."
+ (should (null (calendar-sync--get-exdates nil))))
+
+(ert-deftest test-calendar-sync--get-exdates-error-malformed-returns-nil ()
+ "Test that malformed event string returns nil."
+ (should (null (calendar-sync--get-exdates "not a vevent"))))
+
+(provide 'test-calendar-sync--get-exdates)
+;;; test-calendar-sync--get-exdates.el ends here
diff --git a/tests/test-calendar-sync--parse-exdate.el b/tests/test-calendar-sync--parse-exdate.el
new file mode 100644
index 00000000..2be3d3a1
--- /dev/null
+++ b/tests/test-calendar-sync--parse-exdate.el
@@ -0,0 +1,80 @@
+;;; test-calendar-sync--parse-exdate.el --- Tests for EXDATE parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-exdate function.
+;; Tests parsing EXDATE values into (year month day hour minute) lists.
+;; Following quality-engineer.org guidelines: one function per file.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-exdate-normal-datetime-returns-list ()
+ "Test parsing standard datetime format returns correct list."
+ (let ((result (calendar-sync--parse-exdate "20260203T130000")))
+ (should (equal '(2026 2 3 13 0) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-normal-with-z-returns-list ()
+ "Test parsing UTC datetime with Z suffix returns correct list."
+ (let ((result (calendar-sync--parse-exdate "20260203T180000Z")))
+ (should (equal '(2026 2 3 18 0) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-normal-date-only-returns-list ()
+ "Test parsing date-only format returns list with nil for time."
+ (let ((result (calendar-sync--parse-exdate "20260203")))
+ (should (equal '(2026 2 3 nil nil) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-normal-with-seconds-returns-list ()
+ "Test parsing datetime ignores seconds."
+ (let ((result (calendar-sync--parse-exdate "20260203T130045")))
+ (should (equal '(2026 2 3 13 0) result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-exdate-boundary-midnight-returns-zero-hour ()
+ "Test parsing midnight time returns hour=0."
+ (let ((result (calendar-sync--parse-exdate "20260203T000000")))
+ (should (equal '(2026 2 3 0 0) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-boundary-end-of-day-returns-23 ()
+ "Test parsing end-of-day time returns hour=23."
+ (let ((result (calendar-sync--parse-exdate "20260203T235900")))
+ (should (equal '(2026 2 3 23 59) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-boundary-leap-year-feb29-returns-correct ()
+ "Test parsing Feb 29 on leap year."
+ (let ((result (calendar-sync--parse-exdate "20280229T120000")))
+ (should (equal '(2028 2 29 12 0) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-boundary-new-years-eve-returns-correct ()
+ "Test parsing Dec 31."
+ (let ((result (calendar-sync--parse-exdate "20261231T235900")))
+ (should (equal '(2026 12 31 23 59) result))))
+
+(ert-deftest test-calendar-sync--parse-exdate-boundary-jan-1-returns-correct ()
+ "Test parsing Jan 1."
+ (let ((result (calendar-sync--parse-exdate "20260101T000000")))
+ (should (equal '(2026 1 1 0 0) result))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-exdate-error-empty-returns-nil ()
+ "Test that empty string returns nil."
+ (should (null (calendar-sync--parse-exdate ""))))
+
+(ert-deftest test-calendar-sync--parse-exdate-error-nil-returns-nil ()
+ "Test that nil input returns nil."
+ (should (null (calendar-sync--parse-exdate nil))))
+
+(ert-deftest test-calendar-sync--parse-exdate-error-invalid-format-returns-nil ()
+ "Test that invalid format returns nil."
+ (should (null (calendar-sync--parse-exdate "not-a-date"))))
+
+(provide 'test-calendar-sync--parse-exdate)
+;;; test-calendar-sync--parse-exdate.el ends here
diff --git a/tests/test-integration-calendar-sync-exdate.el b/tests/test-integration-calendar-sync-exdate.el
new file mode 100644
index 00000000..779e0297
--- /dev/null
+++ b/tests/test-integration-calendar-sync-exdate.el
@@ -0,0 +1,207 @@
+;;; test-integration-calendar-sync-exdate.el --- Integration tests for EXDATE support -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests for end-to-end EXDATE filtering in calendar-sync.
+;; Verifies that excluded dates don't appear in org output.
+;; Following quality-engineer.org guidelines.
+
+;;; Code:
+
+(require 'ert)
+(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name)))
+(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name)))
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Helper Functions
+
+(defun test-integration-exdate--make-weekly-event-with-exdates (summary start exdates)
+ "Create a weekly recurring event with EXDATES.
+START is (year month day hour minute).
+EXDATES is list of (year month day hour minute) lists to exclude."
+ (let ((dtstart (test-calendar-sync-ics-datetime-local start))
+ (exdate-lines (mapconcat
+ (lambda (ex)
+ (format "EXDATE:%s" (test-calendar-sync-ics-datetime-local ex)))
+ exdates
+ "\n")))
+ (concat "BEGIN:VEVENT\n"
+ "UID:weekly-test@example.com\n"
+ "SUMMARY:" summary "\n"
+ "DTSTART:" dtstart "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local
+ (list (nth 0 start) (nth 1 start) (nth 2 start)
+ (1+ (nth 3 start)) (nth 4 start))) "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=4\n"
+ (when (> (length exdates) 0)
+ (concat exdate-lines "\n"))
+ "END:VEVENT")))
+
+(defun test-integration-exdate--date-in-org-output-p (org-output date)
+ "Check if DATE appears in ORG-OUTPUT.
+DATE is (year month day hour minute)."
+ (let ((date-str (format "%04d-%02d-%02d" (nth 0 date) (nth 1 date) (nth 2 date))))
+ (string-match-p (regexp-quote date-str) org-output)))
+
+;;; Normal Cases
+
+(ert-deftest test-integration-exdate-single-excluded-date-not-in-output ()
+ "Test that single excluded date doesn't appear in org output."
+ (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0))
+ (week2 (test-calendar-sync-time-days-from-now 14 13 0))
+ (week3 (test-calendar-sync-time-days-from-now 21 13 0))
+ (week4 (test-calendar-sync-time-days-from-now 28 13 0))
+ ;; Exclude week 2
+ (event (test-integration-exdate--make-weekly-event-with-exdates
+ "Weekly Sync"
+ base-start
+ (list week2)))
+ (ics-content (test-calendar-sync-make-ics event))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should org-output)
+ ;; Week 2 should NOT be in output
+ (should-not (test-integration-exdate--date-in-org-output-p org-output week2))
+ ;; Weeks 1, 3, 4 should be in output
+ (should (test-integration-exdate--date-in-org-output-p org-output base-start))
+ (should (test-integration-exdate--date-in-org-output-p org-output week3))
+ (should (test-integration-exdate--date-in-org-output-p org-output week4))))
+
+(ert-deftest test-integration-exdate-multiple-excluded-dates-filtered ()
+ "Test that multiple excluded dates are all filtered out."
+ (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0))
+ (week2 (test-calendar-sync-time-days-from-now 14 13 0))
+ (week3 (test-calendar-sync-time-days-from-now 21 13 0))
+ (week4 (test-calendar-sync-time-days-from-now 28 13 0))
+ ;; Exclude weeks 2 and 4
+ (event (test-integration-exdate--make-weekly-event-with-exdates
+ "Weekly Sync"
+ base-start
+ (list week2 week4)))
+ (ics-content (test-calendar-sync-make-ics event))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should org-output)
+ ;; Weeks 2 and 4 should NOT be in output
+ (should-not (test-integration-exdate--date-in-org-output-p org-output week2))
+ (should-not (test-integration-exdate--date-in-org-output-p org-output week4))
+ ;; Weeks 1 and 3 should be in output
+ (should (test-integration-exdate--date-in-org-output-p org-output base-start))
+ (should (test-integration-exdate--date-in-org-output-p org-output week3))))
+
+(ert-deftest test-integration-exdate-non-excluded-dates-preserved ()
+ "Test that non-excluded dates remain in output."
+ (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0))
+ (week2 (test-calendar-sync-time-days-from-now 14 13 0))
+ (week3 (test-calendar-sync-time-days-from-now 21 13 0))
+ (week4 (test-calendar-sync-time-days-from-now 28 13 0))
+ ;; Exclude only week 3
+ (event (test-integration-exdate--make-weekly-event-with-exdates
+ "Weekly Sync"
+ base-start
+ (list week3)))
+ (ics-content (test-calendar-sync-make-ics event))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should org-output)
+ ;; Week 3 should NOT be in output
+ (should-not (test-integration-exdate--date-in-org-output-p org-output week3))
+ ;; Weeks 1, 2, 4 should all be preserved
+ (should (test-integration-exdate--date-in-org-output-p org-output base-start))
+ (should (test-integration-exdate--date-in-org-output-p org-output week2))
+ (should (test-integration-exdate--date-in-org-output-p org-output week4))))
+
+;;; Boundary Cases
+
+(ert-deftest test-integration-exdate-no-exdates-all-occurrences-present ()
+ "Test that event without EXDATE shows all dates."
+ (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0))
+ (week2 (test-calendar-sync-time-days-from-now 14 13 0))
+ (week3 (test-calendar-sync-time-days-from-now 21 13 0))
+ (week4 (test-calendar-sync-time-days-from-now 28 13 0))
+ ;; No exclusions
+ (event (test-integration-exdate--make-weekly-event-with-exdates
+ "Weekly Sync"
+ base-start
+ '())) ; Empty exdates
+ (ics-content (test-calendar-sync-make-ics event))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should org-output)
+ ;; All weeks should be present
+ (should (test-integration-exdate--date-in-org-output-p org-output base-start))
+ (should (test-integration-exdate--date-in-org-output-p org-output week2))
+ (should (test-integration-exdate--date-in-org-output-p org-output week3))
+ (should (test-integration-exdate--date-in-org-output-p org-output week4))))
+
+(ert-deftest test-integration-exdate-with-recurrence-id-both-work ()
+ "Test that EXDATE and RECURRENCE-ID work together correctly."
+ ;; Create event with:
+ ;; - Week 2 excluded via EXDATE (completely removed)
+ ;; - Week 3 rescheduled via RECURRENCE-ID (time changed)
+ (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0))
+ (week2 (test-calendar-sync-time-days-from-now 14 13 0))
+ (week3-original (test-calendar-sync-time-days-from-now 21 13 0))
+ (week3-new (test-calendar-sync-time-days-from-now 21 15 0)) ; Moved to 3pm
+ (week4 (test-calendar-sync-time-days-from-now 28 13 0))
+ ;; Main event with EXDATE for week 2
+ (main-event (concat "BEGIN:VEVENT\n"
+ "UID:combined-test@example.com\n"
+ "SUMMARY:Combined Test\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local base-start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local
+ (list (nth 0 base-start) (nth 1 base-start) (nth 2 base-start)
+ (1+ (nth 3 base-start)) (nth 4 base-start))) "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=4\n"
+ "EXDATE:" (test-calendar-sync-ics-datetime-local week2) "\n"
+ "END:VEVENT"))
+ ;; Exception event rescheduling week 3
+ (exception-event (concat "BEGIN:VEVENT\n"
+ "UID:combined-test@example.com\n"
+ "RECURRENCE-ID:" (test-calendar-sync-ics-datetime-local week3-original) "\n"
+ "SUMMARY:Combined Test (Rescheduled)\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local week3-new) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local
+ (list (nth 0 week3-new) (nth 1 week3-new) (nth 2 week3-new)
+ (1+ (nth 3 week3-new)) (nth 4 week3-new))) "\n"
+ "END:VEVENT"))
+ (ics-content (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ main-event "\n"
+ exception-event "\n"
+ "END:VCALENDAR"))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should org-output)
+ ;; Week 2 should be completely absent (EXDATE)
+ (should-not (test-integration-exdate--date-in-org-output-p org-output week2))
+ ;; Week 3 should have the new time (15:00)
+ (should (string-match-p "15:00" org-output))
+ ;; Weeks 1 and 4 should be present
+ (should (test-integration-exdate--date-in-org-output-p org-output base-start))
+ (should (test-integration-exdate--date-in-org-output-p org-output week4))))
+
+(ert-deftest test-integration-exdate-tzid-conversion-matches-correctly ()
+ "Test that TZID-qualified EXDATE filters correctly after conversion."
+ ;; Use America/New_York timezone
+ (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0))
+ (week2 (test-calendar-sync-time-days-from-now 14 13 0))
+ (week3 (test-calendar-sync-time-days-from-now 21 13 0))
+ (dtstart-val (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 base-start) (nth 1 base-start) (nth 2 base-start)
+ (nth 3 base-start) (nth 4 base-start)))
+ (exdate-val (format "%04d%02d%02dT%02d%02d00"
+ (nth 0 week2) (nth 1 week2) (nth 2 week2)
+ (nth 3 week2) (nth 4 week2)))
+ (event (concat "BEGIN:VEVENT\n"
+ "UID:tzid-test@example.com\n"
+ "SUMMARY:TZID Test\n"
+ "DTSTART;TZID=America/New_York:" dtstart-val "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=3\n"
+ "EXDATE;TZID=America/New_York:" exdate-val "\n"
+ "END:VEVENT"))
+ (ics-content (test-calendar-sync-make-ics event))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should org-output)
+ ;; The EXDATE should have been converted to local time and filtered
+ ;; We can't check exact dates due to TZ conversion, but output should exist
+ ;; and have fewer occurrences than without EXDATE
+ (should (string-match-p "TZID Test" org-output))))
+
+(provide 'test-integration-calendar-sync-exdate)
+;;; test-integration-calendar-sync-exdate.el ends here