summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitignore1
-rw-r--r--init.el5
-rw-r--r--modules/calendar-sync.el188
-rw-r--r--modules/org-agenda-config.el4
-rw-r--r--modules/user-constants.el4
-rw-r--r--tests/test-calendar-sync--apply-recurrence-exceptions.el157
-rw-r--r--tests/test-calendar-sync--collect-recurrence-exceptions.el174
-rw-r--r--tests/test-calendar-sync--get-recurrence-id.el111
-rw-r--r--tests/test-calendar-sync--parse-recurrence-id.el99
-rw-r--r--tests/test-integration-calendar-sync-recurrence-exceptions.el166
10 files changed, 896 insertions, 13 deletions
diff --git a/.gitignore b/.gitignore
index 18d7586d..691845df 100644
--- a/.gitignore
+++ b/.gitignore
@@ -62,4 +62,5 @@ history
# Calendar sync generated data
/data/gcal.org
/data/pcal.org
+/data/dcal.org
/data/calendar-sync-state.el
diff --git a/init.el b/init.el
index 9757dc48..4006d05e 100644
--- a/init.el
+++ b/init.el
@@ -122,7 +122,10 @@
:file ,gcal-file)
(:name "proton"
:url "https://calendar.proton.me/api/calendar/v1/url/MpLtuwsUNoygyA_60GvJE5cz0hbREbrAPBEJoWDRpFEstnmzmEMDb7sjLzkY8kbkF10A7Be3wGKB1-vqaLf-pw==/calendar.ics?CacheKey=LrB9NG5Vfqp5p2sy90H13g%3D%3D&PassphraseKey=sURqFfACPM21d6AXSeaEXYCruimvSb8t0ce1vuxRAXk%3D"
- :file ,pcal-file)))
+ :file ,pcal-file)
+ (:name "deepsat"
+ :url "https://calendar.google.com/calendar/ical/craig.jennings%40deepsat.com/private-f0250a2c6752a5ca71d7b0636587a6d5/basic.ics"
+ :file ,dcal-file)))
(require 'calendar-sync)
(require 'org-refile-config) ;; refile org-branches
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 582c482d..944c5042 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -290,6 +290,167 @@ during fall-back transitions."
(decoded (decode-time new-time)))
(list (nth 5 decoded) (nth 4 decoded) (nth 3 decoded))))
+;;; RECURRENCE-ID Exception Handling
+
+(defun calendar-sync--get-recurrence-id (event-str)
+ "Extract RECURRENCE-ID value from EVENT-STR.
+Returns the datetime value (without TZID parameter), or nil if not found.
+Handles both simple values and values with parameters like TZID."
+ (when (and event-str (stringp event-str))
+ (calendar-sync--get-property event-str "RECURRENCE-ID")))
+
+(defun calendar-sync--get-recurrence-id-line (event-str)
+ "Extract full RECURRENCE-ID line from EVENT-STR, including parameters.
+Returns the complete line like 'RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000'.
+Returns nil if not found."
+ (when (and event-str (stringp event-str))
+ (calendar-sync--get-property-line event-str "RECURRENCE-ID")))
+
+(defun calendar-sync--parse-recurrence-id (recurrence-id-value)
+ "Parse RECURRENCE-ID-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 recurrence-id-value
+ (stringp recurrence-id-value)
+ (not (string-empty-p recurrence-id-value)))
+ (cond
+ ;; DateTime format: 20260203T090000Z or 20260203T090000
+ ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" recurrence-id-value)
+ (list (string-to-number (match-string 1 recurrence-id-value))
+ (string-to-number (match-string 2 recurrence-id-value))
+ (string-to-number (match-string 3 recurrence-id-value))
+ (string-to-number (match-string 4 recurrence-id-value))
+ (string-to-number (match-string 5 recurrence-id-value))))
+ ;; Date-only format: 20260203
+ ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" recurrence-id-value)
+ (list (string-to-number (match-string 1 recurrence-id-value))
+ (string-to-number (match-string 2 recurrence-id-value))
+ (string-to-number (match-string 3 recurrence-id-value))
+ nil nil))
+ (t nil))))
+
+(defun calendar-sync--collect-recurrence-exceptions (ics-content)
+ "Collect all RECURRENCE-ID events from ICS-CONTENT.
+Returns hash table mapping UID to list of exception event plists.
+Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, etc."
+ (let ((exceptions (make-hash-table :test 'equal)))
+ (when (and ics-content (stringp ics-content))
+ (let ((events (calendar-sync--split-events ics-content)))
+ (dolist (event-str events)
+ (let ((recurrence-id (calendar-sync--get-recurrence-id event-str))
+ (uid (calendar-sync--get-property event-str "UID")))
+ (when (and recurrence-id uid)
+ ;; Parse the exception event
+ (let* ((recurrence-id-line (calendar-sync--get-recurrence-id-line event-str))
+ (recurrence-id-tzid (calendar-sync--extract-tzid recurrence-id-line))
+ (recurrence-id-is-utc (and recurrence-id
+ (string-suffix-p "Z" recurrence-id)))
+ (recurrence-id-parsed (calendar-sync--parse-recurrence-id recurrence-id))
+ ;; Parse the new times from the exception
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND"))
+ (dtstart-line (calendar-sync--get-property-line event-str "DTSTART"))
+ (dtend-line (calendar-sync--get-property-line event-str "DTEND"))
+ (start-tzid (calendar-sync--extract-tzid dtstart-line))
+ (end-tzid (calendar-sync--extract-tzid dtend-line))
+ (start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid)))
+ (summary (calendar-sync--get-property event-str "SUMMARY"))
+ (description (calendar-sync--get-property event-str "DESCRIPTION"))
+ (location (calendar-sync--get-property event-str "LOCATION")))
+ (when (and recurrence-id-parsed start-parsed)
+ ;; Convert RECURRENCE-ID to local time
+ ;; Handle: UTC (Z suffix), TZID, or assume local
+ (let ((local-recurrence-id
+ (cond
+ ;; UTC time (Z suffix) - convert from UTC
+ (recurrence-id-is-utc
+ (calendar-sync--convert-utc-to-local
+ (nth 0 recurrence-id-parsed)
+ (nth 1 recurrence-id-parsed)
+ (nth 2 recurrence-id-parsed)
+ (or (nth 3 recurrence-id-parsed) 0)
+ (or (nth 4 recurrence-id-parsed) 0)
+ 0)) ; seconds
+ ;; TZID specified - convert from that timezone
+ (recurrence-id-tzid
+ (or (calendar-sync--convert-tz-to-local
+ (nth 0 recurrence-id-parsed)
+ (nth 1 recurrence-id-parsed)
+ (nth 2 recurrence-id-parsed)
+ (or (nth 3 recurrence-id-parsed) 0)
+ (or (nth 4 recurrence-id-parsed) 0)
+ recurrence-id-tzid)
+ recurrence-id-parsed))
+ ;; No timezone info - assume local
+ (t recurrence-id-parsed))))
+ (let ((exception-plist
+ (list :recurrence-id local-recurrence-id
+ :recurrence-id-raw recurrence-id
+ :start start-parsed
+ :end end-parsed
+ :summary summary
+ :description description
+ :location location)))
+ ;; Add to hash table
+ (let ((existing (gethash uid exceptions)))
+ (puthash uid (cons exception-plist existing) exceptions)))))))))))
+ exceptions))
+
+(defun calendar-sync--occurrence-matches-exception-p (occurrence exception)
+ "Check if OCCURRENCE matches EXCEPTION's recurrence-id.
+Compares year, month, day, hour, minute."
+ (let ((occ-start (plist-get occurrence :start))
+ (exc-recid (plist-get exception :recurrence-id)))
+ (and occ-start exc-recid
+ (= (nth 0 occ-start) (nth 0 exc-recid)) ; year
+ (= (nth 1 occ-start) (nth 1 exc-recid)) ; month
+ (= (nth 2 occ-start) (nth 2 exc-recid)) ; day
+ ;; Hour/minute check (handle nil for all-day events)
+ (or (and (null (nth 3 occ-start)) (null (nth 3 exc-recid)))
+ (and (nth 3 occ-start) (nth 3 exc-recid)
+ (= (nth 3 occ-start) (nth 3 exc-recid))
+ (= (or (nth 4 occ-start) 0) (or (nth 4 exc-recid) 0)))))))
+
+(defun calendar-sync--apply-single-exception (occurrence exception)
+ "Apply EXCEPTION to OCCURRENCE, returning modified occurrence."
+ (let ((result (copy-sequence occurrence)))
+ ;; Update time from exception
+ (plist-put result :start (plist-get exception :start))
+ (when (plist-get exception :end)
+ (plist-put result :end (plist-get exception :end)))
+ ;; Update summary if exception has one
+ (when (plist-get exception :summary)
+ (plist-put result :summary (plist-get exception :summary)))
+ ;; Update other fields
+ (when (plist-get exception :description)
+ (plist-put result :description (plist-get exception :description)))
+ (when (plist-get exception :location)
+ (plist-put result :location (plist-get exception :location)))
+ result))
+
+(defun calendar-sync--apply-recurrence-exceptions (occurrences exceptions)
+ "Apply EXCEPTIONS to OCCURRENCES list.
+OCCURRENCES is list of event plists from RRULE expansion.
+EXCEPTIONS is hash table from `calendar-sync--collect-recurrence-exceptions'.
+Returns new list with matching occurrences replaced by exception times."
+ (if (or (null occurrences) (null exceptions))
+ occurrences
+ (mapcar
+ (lambda (occurrence)
+ (let* ((uid (plist-get occurrence :uid))
+ (uid-exceptions (and uid (gethash uid exceptions))))
+ (if (null uid-exceptions)
+ occurrence
+ ;; Check if any exception matches this occurrence
+ (let ((matching-exception
+ (cl-find-if (lambda (exc)
+ (calendar-sync--occurrence-matches-exception-p occurrence exc))
+ uid-exceptions)))
+ (if matching-exception
+ (calendar-sync--apply-single-exception occurrence matching-exception)
+ occurrence)))))
+ occurrences)))
+
;;; .ics Parsing
(defun calendar-sync--split-events (ics-content)
@@ -640,13 +801,15 @@ Returns list of event plists, or nil if not a recurring event."
(defun calendar-sync--parse-event (event-str)
"Parse single VEVENT string EVENT-STR into plist.
-Returns plist with :summary :description :location :start :end.
+Returns plist with :uid :summary :description :location :start :end.
Returns nil if event lacks required fields (DTSTART, SUMMARY).
-Skips events with RECURRENCE-ID (individual instances of recurring events).
+Skips events with RECURRENCE-ID (individual instances of recurring events
+are handled separately via exception collection).
Handles TZID-qualified timestamps by converting to local time."
- ;; Skip individual instances of recurring events (they're handled by RRULE expansion)
+ ;; Skip individual instances of recurring events (they're collected as exceptions)
(unless (calendar-sync--get-property event-str "RECURRENCE-ID")
- (let* ((summary (calendar-sync--get-property event-str "SUMMARY"))
+ (let* ((uid (calendar-sync--get-property event-str "UID"))
+ (summary (calendar-sync--get-property event-str "SUMMARY"))
(description (calendar-sync--get-property event-str "DESCRIPTION"))
(location (calendar-sync--get-property event-str "LOCATION"))
;; Get raw property values
@@ -661,7 +824,8 @@ Handles TZID-qualified timestamps by converting to local time."
(let ((start-parsed (calendar-sync--parse-timestamp dtstart start-tzid))
(end-parsed (and dtend (calendar-sync--parse-timestamp dtend end-tzid))))
(when start-parsed
- (list :summary summary
+ (list :uid uid
+ :summary summary
:description description
:location location
:start start-parsed
@@ -702,10 +866,13 @@ Returns time value suitable for comparison, or 0 if no start time."
"Parse ICS-CONTENT and return org-formatted string.
Returns nil if parsing fails.
Events are sorted chronologically by start time.
-Recurring events are expanded into individual occurrences."
+Recurring events are expanded into individual occurrences.
+RECURRENCE-ID exceptions are applied to override specific occurrences."
(condition-case err
(let* ((range (calendar-sync--get-date-range))
(events (calendar-sync--split-events ics-content))
+ ;; First pass: collect all RECURRENCE-ID exceptions
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics-content))
(parsed-events '())
(max-events 5000) ; Safety limit to prevent Emacs from hanging
(events-generated 0))
@@ -714,10 +881,11 @@ Recurring events are expanded into individual occurrences."
(when (< events-generated max-events)
(let ((expanded (calendar-sync--expand-recurring-event event-str range)))
(if expanded
- ;; Recurring event - add all occurrences
- (progn
- (setq parsed-events (append parsed-events expanded))
- (setq events-generated (+ events-generated (length expanded))))
+ ;; Recurring event - add all occurrences with exceptions applied
+ (let ((with-exceptions (calendar-sync--apply-recurrence-exceptions
+ expanded exceptions)))
+ (setq parsed-events (append parsed-events with-exceptions))
+ (setq events-generated (+ events-generated (length with-exceptions))))
;; Non-recurring event - parse normally
(let ((parsed (calendar-sync--parse-event event-str)))
(when (and parsed
diff --git a/modules/org-agenda-config.el b/modules/org-agenda-config.el
index 4be4db9e..df95f31e 100644
--- a/modules/org-agenda-config.el
+++ b/modules/org-agenda-config.el
@@ -142,7 +142,7 @@ improves performance from several seconds to instant."
(setq cj/org-agenda-files-building t)
(let ((start-time (current-time)))
;; Reset org-agenda-files to base files
- (setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file))
+ (setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file dcal-file))
;; Check all projects for scheduled tasks
(cj/add-files-to-org-agenda-files-list projects-dir)
@@ -335,7 +335,7 @@ This allows a line to show in an agenda without being scheduled or a deadline."
:init
;; Initialize org-agenda-files with base files before chime loads
;; The full list will be built asynchronously later
- (setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file))
+ (setq org-agenda-files (list inbox-file schedule-file gcal-file pcal-file dcal-file))
;; Debug mode (keep set to nil, but available for troubleshooting)
(setq chime-debug nil)
diff --git a/modules/user-constants.el b/modules/user-constants.el
index 3b248ddd..85890f97 100644
--- a/modules/user-constants.el
+++ b/modules/user-constants.el
@@ -135,6 +135,10 @@ Stored in .emacs.d/data/ so each machine syncs independently from Google Calenda
"The location of the org file containing Proton Calendar information.
Stored in .emacs.d/data/ so each machine syncs independently from Proton Calendar.")
+(defvar dcal-file (expand-file-name "data/dcal.org" user-emacs-directory)
+ "The location of the org file containing DeepSat Calendar information.
+Stored in .emacs.d/data/ so each machine syncs independently from Google Calendar.")
+
(defvar reference-file (expand-file-name "reference.org" org-dir)
"The location of the org file containing reference information.")
diff --git a/tests/test-calendar-sync--apply-recurrence-exceptions.el b/tests/test-calendar-sync--apply-recurrence-exceptions.el
new file mode 100644
index 00000000..7711c5cb
--- /dev/null
+++ b/tests/test-calendar-sync--apply-recurrence-exceptions.el
@@ -0,0 +1,157 @@
+;;; test-calendar-sync--apply-recurrence-exceptions.el --- Tests for applying exceptions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--apply-recurrence-exceptions function.
+;; Tests applying RECURRENCE-ID exceptions to expanded occurrences.
+;; 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)
+
+;;; Test Data Helpers
+
+(defun test-make-occurrence (year month day hour minute summary &optional uid)
+ "Create an occurrence plist for testing.
+Mirrors the format used by calendar-sync expansion functions.
+Uses :start (year month day hour minute) format."
+ (list :start (list year month day hour minute)
+ :end (list year month day (1+ hour) minute)
+ :summary summary
+ :uid (or uid "test-event@google.com")))
+
+(defun test-make-exception-data (rec-year rec-month rec-day rec-hour rec-minute
+ new-year new-month new-day new-hour new-minute
+ &optional summary)
+ "Create exception data plist for testing.
+REC-* are the original occurrence date/time (recurrence-id).
+NEW-* values are the rescheduled time."
+ (list :recurrence-id (list rec-year rec-month rec-day rec-hour rec-minute)
+ :start (list new-year new-month new-day new-hour new-minute)
+ :end (list new-year new-month new-day (1+ new-hour) new-minute)
+ :summary (or summary "Rescheduled")))
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-normal-single-replacement ()
+ "Test replacing single occurrence with exception."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Exception: Feb 3 at 9:00 → Feb 3 at 10:00
+ (puthash "test-event@google.com"
+ (list (test-make-exception-data 2026 2 3 9 0 2026 2 3 10 0))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 1 (length result)))
+ ;; Time should be updated to 10:00
+ (should (= 10 (nth 3 (plist-get (car result) :start)))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-normal-multiple-some-replaced ()
+ "Test multiple occurrences with one exception."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 10 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 17 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Only Feb 10 is rescheduled
+ (puthash "test-event@google.com"
+ (list (test-make-exception-data 2026 2 10 9 0 2026 2 10 11 0))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 3 (length result)))
+ ;; Feb 3: unchanged (9:00)
+ (should (= 9 (nth 3 (plist-get (nth 0 result) :start))))
+ ;; Feb 10: rescheduled (11:00)
+ (should (= 11 (nth 3 (plist-get (nth 1 result) :start))))
+ ;; Feb 17: unchanged (9:00)
+ (should (= 9 (nth 3 (plist-get (nth 2 result) :start)))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-normal-date-change ()
+ "Test exception that changes the date, not just time."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Feb 3 rescheduled to Feb 4
+ (puthash "test-event@google.com"
+ (list (test-make-exception-data 2026 2 3 9 0 2026 2 4 10 0))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 1 (length result)))
+ (let ((new-start (plist-get (car result) :start)))
+ (should (= 4 (nth 2 new-start))) ; day
+ (should (= 10 (nth 3 new-start))))))) ; hour
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-no-exceptions ()
+ "Test occurrences returned unchanged when no exceptions."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 10 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 2 (length result)))
+ (should (= 9 (nth 3 (plist-get (nth 0 result) :start))))
+ (should (= 9 (nth 3 (plist-get (nth 1 result) :start)))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-exception-no-match ()
+ "Test exception for different UID doesn't affect occurrences."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting" "event-a@google.com")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Exception is for different UID
+ (puthash "event-b@google.com"
+ (list (test-make-exception-data 2026 2 3 9 0 2026 2 3 11 0))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 1 (length result)))
+ ;; Should remain at 9:00, not 11:00
+ (should (= 9 (nth 3 (plist-get (car result) :start)))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-multiple-uids ()
+ "Test exceptions for multiple different recurring events."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Meeting A" "event-a@google.com")
+ (test-make-occurrence 2026 2 3 14 0 "Meeting B" "event-b@google.com")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Different exceptions for each UID
+ (puthash "event-a@google.com"
+ (list (test-make-exception-data 2026 2 3 9 0 2026 2 3 10 0))
+ exceptions)
+ (puthash "event-b@google.com"
+ (list (test-make-exception-data 2026 2 3 14 0 2026 2 3 15 0))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 2 (length result)))
+ ;; Each should have its respective exception applied
+ (should (= 10 (nth 3 (plist-get (nth 0 result) :start))))
+ (should (= 15 (nth 3 (plist-get (nth 1 result) :start)))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-empty-occurrences ()
+ "Test empty occurrences list returns empty."
+ (let* ((occurrences '())
+ (exceptions (make-hash-table :test 'equal)))
+ (puthash "test@google.com"
+ (list (test-make-exception-data 2026 2 3 9 0 2026 2 3 10 0))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 0 (length result))))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-error-nil-occurrences ()
+ "Test nil occurrences handled gracefully."
+ (let ((exceptions (make-hash-table :test 'equal)))
+ (let ((result (calendar-sync--apply-recurrence-exceptions nil exceptions)))
+ (should (or (null result) (= 0 (length result)))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-error-nil-exceptions ()
+ "Test nil exceptions handled gracefully."
+ (let ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Meeting"))))
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences nil)))
+ ;; Should return occurrences unchanged or handle nil
+ (should (or (null result)
+ (and (= 1 (length result))
+ (= 9 (nth 3 (plist-get (car result) :start)))))))))
+
+(provide 'test-calendar-sync--apply-recurrence-exceptions)
+;;; test-calendar-sync--apply-recurrence-exceptions.el ends here
diff --git a/tests/test-calendar-sync--collect-recurrence-exceptions.el b/tests/test-calendar-sync--collect-recurrence-exceptions.el
new file mode 100644
index 00000000..d2567b3a
--- /dev/null
+++ b/tests/test-calendar-sync--collect-recurrence-exceptions.el
@@ -0,0 +1,174 @@
+;;; test-calendar-sync--collect-recurrence-exceptions.el --- Tests for exception collection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--collect-recurrence-exceptions function.
+;; Tests collecting all RECURRENCE-ID events from ICS content.
+;; 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)
+
+;;; Test Data Helpers
+
+(defun test-make-exception-event (uid recurrence-id summary start end)
+ "Create a VEVENT with RECURRENCE-ID for testing.
+UID is the base event UID. RECURRENCE-ID is the original occurrence date.
+SUMMARY, START, END define the exception event."
+ (concat "BEGIN:VEVENT\n"
+ "UID:" uid "\n"
+ "RECURRENCE-ID:" recurrence-id "\n"
+ "SUMMARY:" summary "\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+
+(defun test-make-base-event-with-rrule (uid summary start end rrule)
+ "Create a recurring VEVENT with RRULE for testing."
+ (concat "BEGIN:VEVENT\n"
+ "UID:" uid "\n"
+ "SUMMARY:" summary "\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "RRULE:" rrule "\n"
+ "END:VEVENT"))
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-normal-single-exception ()
+ "Test collecting single exception event."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (ics (test-calendar-sync-make-ics
+ (test-make-exception-event "event123@google.com"
+ "20260203T090000Z"
+ "Rescheduled Meeting"
+ start end)))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics)))
+ ;; Should have one exception keyed by UID
+ (should (hash-table-p exceptions))
+ (should (gethash "event123@google.com" exceptions))))
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-normal-multiple-same-uid ()
+ "Test collecting multiple exceptions for same recurring event."
+ (let* ((start1 (test-calendar-sync-time-days-from-now 7 10 0))
+ (end1 (test-calendar-sync-time-days-from-now 7 11 0))
+ (start2 (test-calendar-sync-time-days-from-now 14 11 0))
+ (end2 (test-calendar-sync-time-days-from-now 14 12 0))
+ (ics (test-calendar-sync-make-ics
+ (test-make-exception-event "weekly123@google.com"
+ "20260203T090000Z"
+ "Week 1 Rescheduled"
+ start1 end1)
+ (test-make-exception-event "weekly123@google.com"
+ "20260210T090000Z"
+ "Week 2 Rescheduled"
+ start2 end2)))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics)))
+ ;; Should have list of exceptions under single UID
+ (should (hash-table-p exceptions))
+ (let ((uid-exceptions (gethash "weekly123@google.com" exceptions)))
+ (should uid-exceptions)
+ (should (= 2 (length uid-exceptions))))))
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-normal-multiple-uids ()
+ "Test collecting exceptions for different recurring events."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (ics (test-calendar-sync-make-ics
+ (test-make-exception-event "event-a@google.com"
+ "20260203T090000Z"
+ "Event A Rescheduled"
+ start end)
+ (test-make-exception-event "event-b@google.com"
+ "20260203T140000Z"
+ "Event B Rescheduled"
+ start end)))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics)))
+ ;; Should have two UIDs
+ (should (hash-table-p exceptions))
+ (should (gethash "event-a@google.com" exceptions))
+ (should (gethash "event-b@google.com" exceptions))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-boundary-no-exceptions ()
+ "Test ICS with no RECURRENCE-ID events returns empty hash."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (ics (test-calendar-sync-make-ics
+ (test-calendar-sync-make-vevent "Regular Event" start end)))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics)))
+ (should (hash-table-p exceptions))
+ (should (= 0 (hash-table-count exceptions)))))
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-boundary-mixed-events ()
+ "Test ICS with both regular and exception events."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (base-start (test-calendar-sync-time-days-from-now 1 9 0))
+ (base-end (test-calendar-sync-time-days-from-now 1 10 0))
+ (ics (test-calendar-sync-make-ics
+ ;; Regular event (no RECURRENCE-ID)
+ (test-calendar-sync-make-vevent "Normal Meeting" base-start base-end)
+ ;; Base recurring event with RRULE
+ (test-make-base-event-with-rrule "recurring@google.com"
+ "Weekly Sync"
+ base-start base-end
+ "FREQ=WEEKLY;COUNT=10")
+ ;; Exception to the recurring event
+ (test-make-exception-event "recurring@google.com"
+ "20260210T090000Z"
+ "Weekly Sync (Rescheduled)"
+ start end)))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics)))
+ ;; Should only collect the exception, not regular or base events
+ (should (hash-table-p exceptions))
+ (should (= 1 (hash-table-count exceptions)))
+ (should (gethash "recurring@google.com" exceptions))))
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-boundary-tzid-exception ()
+ "Test exception event with TZID-qualified RECURRENCE-ID."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (concat "BEGIN:VEVENT\n"
+ "UID:tzid-event@google.com\n"
+ "RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000\n"
+ "SUMMARY:Tallinn Meeting Rescheduled\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+ (ics (test-calendar-sync-make-ics event))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics)))
+ (should (hash-table-p exceptions))
+ (should (gethash "tzid-event@google.com" exceptions))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-error-empty-string ()
+ "Test empty ICS content returns empty hash."
+ (let ((exceptions (calendar-sync--collect-recurrence-exceptions "")))
+ (should (hash-table-p exceptions))
+ (should (= 0 (hash-table-count exceptions)))))
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-error-nil-input ()
+ "Test nil input returns empty hash or nil."
+ (let ((exceptions (calendar-sync--collect-recurrence-exceptions nil)))
+ (should (or (null exceptions)
+ (and (hash-table-p exceptions)
+ (= 0 (hash-table-count exceptions)))))))
+
+(ert-deftest test-calendar-sync--collect-recurrence-exceptions-error-malformed-ics ()
+ "Test malformed ICS handles gracefully."
+ (let ((exceptions (calendar-sync--collect-recurrence-exceptions "not valid ics content")))
+ ;; Should not crash, return empty hash
+ (should (or (null exceptions)
+ (and (hash-table-p exceptions)
+ (= 0 (hash-table-count exceptions)))))))
+
+(provide 'test-calendar-sync--collect-recurrence-exceptions)
+;;; test-calendar-sync--collect-recurrence-exceptions.el ends here
diff --git a/tests/test-calendar-sync--get-recurrence-id.el b/tests/test-calendar-sync--get-recurrence-id.el
new file mode 100644
index 00000000..4ff09e34
--- /dev/null
+++ b/tests/test-calendar-sync--get-recurrence-id.el
@@ -0,0 +1,111 @@
+;;; test-calendar-sync--get-recurrence-id.el --- Tests for RECURRENCE-ID extraction -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--get-recurrence-id function.
+;; Tests extraction of RECURRENCE-ID property 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-recurrence-id-normal-simple-returns-value ()
+ "Test extracting simple RECURRENCE-ID without TZID."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260210T090000
+RECURRENCE-ID:20260203T090000
+SUMMARY:Rescheduled Meeting
+END:VEVENT"))
+ (should (string= "20260203T090000"
+ (calendar-sync--get-recurrence-id event)))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-normal-with-z-suffix-returns-value ()
+ "Test extracting RECURRENCE-ID with UTC Z suffix."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260210T170000Z
+RECURRENCE-ID:20260203T170000Z
+SUMMARY:UTC Event
+END:VEVENT"))
+ (should (string= "20260203T170000Z"
+ (calendar-sync--get-recurrence-id event)))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-normal-with-tzid-returns-value ()
+ "Test extracting RECURRENCE-ID with TZID parameter.
+The TZID parameter should be ignored, only value returned."
+ (let ((event "BEGIN:VEVENT
+DTSTART;TZID=Europe/Tallinn:20260210T170000
+RECURRENCE-ID;TZID=Europe/Tallinn:20260203T170000
+SUMMARY:Tallinn Meeting
+END:VEVENT"))
+ (should (string= "20260203T170000"
+ (calendar-sync--get-recurrence-id event)))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-normal-date-only-returns-value ()
+ "Test extracting date-only RECURRENCE-ID for all-day event exceptions."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260210
+RECURRENCE-ID:20260203
+SUMMARY:All Day Exception
+END:VEVENT"))
+ (should (string= "20260203"
+ (calendar-sync--get-recurrence-id event)))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--get-recurrence-id-boundary-no-recurrence-id-returns-nil ()
+ "Test that event without RECURRENCE-ID returns nil."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260203T090000
+SUMMARY:Regular Event
+END:VEVENT"))
+ (should (null (calendar-sync--get-recurrence-id event)))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-boundary-recurrence-id-multiline-returns-partial ()
+ "Test RECURRENCE-ID with continuation line (RFC 5545 folding).
+Note: Current implementation returns partial value for folded lines.
+Full continuation line support is a future enhancement."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260210T090000
+RECURRENCE-ID;TZID=America/Los_Angeles;VALUE=DATE-TIME:2026
+ 0203T090000
+SUMMARY:Folded Line
+END:VEVENT"))
+ ;; Current implementation returns partial value before continuation
+ ;; This is acceptable as folded RECURRENCE-ID lines are rare in practice
+ (let ((result (calendar-sync--get-recurrence-id event)))
+ (should result)
+ ;; At minimum, should capture the year portion
+ (should (string-match-p "2026" result)))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-boundary-multiple-params-returns-value ()
+ "Test RECURRENCE-ID with multiple parameters."
+ (let ((event "BEGIN:VEVENT
+DTSTART:20260210T090000
+RECURRENCE-ID;TZID=America/Chicago;VALUE=DATE-TIME:20260203T090000
+SUMMARY:Multi-param
+END:VEVENT"))
+ (should (string= "20260203T090000"
+ (calendar-sync--get-recurrence-id event)))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--get-recurrence-id-error-empty-string-returns-nil ()
+ "Test that empty event string returns nil."
+ (should (null (calendar-sync--get-recurrence-id ""))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-error-nil-input-returns-nil ()
+ "Test that nil input returns nil."
+ (should (null (calendar-sync--get-recurrence-id nil))))
+
+(ert-deftest test-calendar-sync--get-recurrence-id-error-malformed-returns-nil ()
+ "Test that malformed event without proper structure returns nil."
+ (should (null (calendar-sync--get-recurrence-id "not a vevent"))))
+
+(provide 'test-calendar-sync--get-recurrence-id)
+;;; test-calendar-sync--get-recurrence-id.el ends here
diff --git a/tests/test-calendar-sync--parse-recurrence-id.el b/tests/test-calendar-sync--parse-recurrence-id.el
new file mode 100644
index 00000000..6bc67344
--- /dev/null
+++ b/tests/test-calendar-sync--parse-recurrence-id.el
@@ -0,0 +1,99 @@
+;;; test-calendar-sync--parse-recurrence-id.el --- Tests for RECURRENCE-ID parsing -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-recurrence-id function.
+;; Tests parsing RECURRENCE-ID 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-recurrence-id-normal-simple-datetime-returns-list ()
+ "Test parsing simple RECURRENCE-ID datetime without suffix."
+ (let ((result (calendar-sync--parse-recurrence-id "20260203T090000")))
+ (should (equal '(2026 2 3 9 0) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-normal-with-z-suffix-returns-list ()
+ "Test parsing RECURRENCE-ID datetime with UTC Z suffix."
+ (let ((result (calendar-sync--parse-recurrence-id "20260203T170000Z")))
+ (should (equal '(2026 2 3 17 0) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-normal-date-only-returns-list ()
+ "Test parsing date-only RECURRENCE-ID for all-day events.
+Returns list with nil for hour/minute."
+ (let ((result (calendar-sync--parse-recurrence-id "20260203")))
+ (should (equal '(2026 2 3 nil nil) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-normal-with-seconds-returns-list ()
+ "Test parsing datetime with non-zero seconds (seconds ignored)."
+ (let ((result (calendar-sync--parse-recurrence-id "20260203T091530")))
+ (should (equal '(2026 2 3 9 15) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-normal-afternoon-time-returns-list ()
+ "Test parsing afternoon time correctly."
+ (let ((result (calendar-sync--parse-recurrence-id "20260515T143000")))
+ (should (equal '(2026 5 15 14 30) result))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-boundary-midnight-returns-list ()
+ "Test parsing midnight time."
+ (let ((result (calendar-sync--parse-recurrence-id "20260203T000000")))
+ (should (equal '(2026 2 3 0 0) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-boundary-end-of-day-returns-list ()
+ "Test parsing end-of-day time (23:59)."
+ (let ((result (calendar-sync--parse-recurrence-id "20260203T235900")))
+ (should (equal '(2026 2 3 23 59) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-boundary-leap-year-returns-list ()
+ "Test parsing leap year date (Feb 29)."
+ (let ((result (calendar-sync--parse-recurrence-id "20280229T120000")))
+ (should (equal '(2028 2 29 12 0) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-boundary-new-years-eve-returns-list ()
+ "Test parsing New Year's Eve date."
+ (let ((result (calendar-sync--parse-recurrence-id "20261231T235900")))
+ (should (equal '(2026 12 31 23 59) result))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-boundary-january-first-returns-list ()
+ "Test parsing January 1st."
+ (let ((result (calendar-sync--parse-recurrence-id "20260101T000000")))
+ (should (equal '(2026 1 1 0 0) result))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-error-empty-string-returns-nil ()
+ "Test that empty string returns nil."
+ (should (null (calendar-sync--parse-recurrence-id ""))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-error-nil-input-returns-nil ()
+ "Test that nil input returns nil."
+ (should (null (calendar-sync--parse-recurrence-id nil))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-error-invalid-format-returns-nil ()
+ "Test that invalid format returns nil."
+ (should (null (calendar-sync--parse-recurrence-id "not-a-date"))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-error-incomplete-date-returns-nil ()
+ "Test that incomplete date returns nil."
+ (should (null (calendar-sync--parse-recurrence-id "2026"))))
+
+(ert-deftest test-calendar-sync--parse-recurrence-id-error-partial-datetime-returns-nil ()
+ "Test that partial datetime (missing time) still parses as date-only."
+ ;; A date without time component should parse as date-only
+ (let ((result (calendar-sync--parse-recurrence-id "20260203T")))
+ ;; Could return nil or partial - implementation decides
+ ;; But shouldn't crash
+ (should (or (null result)
+ (and (listp result) (= (length result) 5))))))
+
+(provide 'test-calendar-sync--parse-recurrence-id)
+;;; test-calendar-sync--parse-recurrence-id.el ends here
diff --git a/tests/test-integration-calendar-sync-recurrence-exceptions.el b/tests/test-integration-calendar-sync-recurrence-exceptions.el
new file mode 100644
index 00000000..0a9b5af1
--- /dev/null
+++ b/tests/test-integration-calendar-sync-recurrence-exceptions.el
@@ -0,0 +1,166 @@
+;;; test-integration-calendar-sync-recurrence-exceptions.el --- Integration tests -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests for RECURRENCE-ID exception handling workflow.
+;; Tests the complete pipeline: ICS parsing → RRULE expansion → exception application.
+;; Tests verify the final org output contains correct relative times.
+
+;;; 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)
+
+;;; Test Data - Using local times (no Z suffix) to avoid timezone conversion
+
+(defun test-integration-make-recurring-event-with-exception-local ()
+ "Create ICS content with local times (no Z suffix).
+Weekly meeting with one instance rescheduled from 09:00 to 10:00."
+ (let* ((base-start (test-calendar-sync-time-days-from-now 0 9 0))
+ (base-end (test-calendar-sync-time-days-from-now 0 10 0))
+ ;; Exception: week 2 moved from 9:00 to 10:00
+ (exception-start (test-calendar-sync-time-days-from-now 7 10 0))
+ (exception-end (test-calendar-sync-time-days-from-now 7 11 0))
+ (recurrence-id-date (test-calendar-sync-time-days-from-now 7 9 0)))
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "PRODID:-//Test//Test//EN\n"
+ ;; Base recurring event - use local time (no Z)
+ "BEGIN:VEVENT\n"
+ "UID:weekly-meeting@google.com\n"
+ "SUMMARY:Weekly Team Sync\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local base-start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local base-end) "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=4\n"
+ "END:VEVENT\n"
+ ;; Exception event - local time
+ "BEGIN:VEVENT\n"
+ "UID:weekly-meeting@google.com\n"
+ "RECURRENCE-ID:" (test-calendar-sync-ics-datetime-local recurrence-id-date) "\n"
+ "SUMMARY:Weekly Team Sync (Rescheduled)\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local exception-start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local exception-end) "\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR")))
+
+;;; Integration Tests
+
+(ert-deftest test-integration-recurrence-exception-org-output ()
+ "Test that parse-ics returns valid org content with exception summary."
+ (let* ((ics-content (test-integration-make-recurring-event-with-exception-local))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ ;; Should return string with org content
+ (should (stringp org-output))
+ (should (string-match-p "Weekly Team Sync" org-output))
+ ;; Should contain the rescheduled event with correct summary
+ (should (string-match-p "Rescheduled" org-output))))
+
+(ert-deftest test-integration-recurrence-exception-times-in-output ()
+ "Test that rescheduled event shows different time than regular occurrences."
+ (let* ((ics-content (test-integration-make-recurring-event-with-exception-local))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ ;; The org output should contain both 09:00 (regular) and 10:00 (rescheduled)
+ (should (string-match-p "09:00" org-output))
+ (should (string-match-p "10:00" org-output))))
+
+(ert-deftest test-integration-recurrence-no-exceptions-org-output ()
+ "Test recurring event without exceptions produces correct org output."
+ (let* ((base-start (test-calendar-sync-time-days-from-now 0 9 0))
+ (base-end (test-calendar-sync-time-days-from-now 0 10 0))
+ (ics-content (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ "BEGIN:VEVENT\n"
+ "UID:simple-weekly@google.com\n"
+ "SUMMARY:Simple Weekly\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local base-start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local base-end) "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=3\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR"))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ ;; Should have org content
+ (should (stringp org-output))
+ ;; Should have the event title
+ (should (string-match-p "Simple Weekly" org-output))
+ ;; All occurrences should be at 09:00 (no exceptions)
+ (should (string-match-p "09:00-10:00" org-output))))
+
+(ert-deftest test-integration-collect-exceptions-from-ics ()
+ "Test that collect-recurrence-exceptions correctly extracts exceptions."
+ (let* ((ics-content (test-integration-make-recurring-event-with-exception-local))
+ (exceptions (calendar-sync--collect-recurrence-exceptions ics-content)))
+ ;; Should have collected one exception
+ (should (hash-table-p exceptions))
+ (should (gethash "weekly-meeting@google.com" exceptions))
+ ;; The exception should exist
+ (let ((exc-list (gethash "weekly-meeting@google.com" exceptions)))
+ (should (= 1 (length exc-list)))
+ ;; The exception should have the rescheduled time (10:00)
+ (let* ((exc (car exc-list))
+ (exc-start (plist-get exc :start)))
+ (should (= 10 (nth 3 exc-start)))))))
+
+(ert-deftest test-integration-multiple-events-with-exceptions ()
+ "Test multiple recurring events each with their own exceptions."
+ (let* ((start-a (test-calendar-sync-time-days-from-now 0 9 0))
+ (end-a (test-calendar-sync-time-days-from-now 0 10 0))
+ (start-b (test-calendar-sync-time-days-from-now 0 14 0))
+ (end-b (test-calendar-sync-time-days-from-now 0 15 0))
+ ;; Exceptions for week 2: A moves from 9:00→10:00, B moves from 14:00→15:00
+ (exc-a-start (test-calendar-sync-time-days-from-now 7 10 0))
+ (exc-a-end (test-calendar-sync-time-days-from-now 7 11 0))
+ (exc-b-start (test-calendar-sync-time-days-from-now 7 15 0))
+ (exc-b-end (test-calendar-sync-time-days-from-now 7 16 0))
+ (rec-id-a (test-calendar-sync-time-days-from-now 7 9 0))
+ (rec-id-b (test-calendar-sync-time-days-from-now 7 14 0))
+ (ics-content
+ (concat "BEGIN:VCALENDAR\n"
+ "VERSION:2.0\n"
+ ;; Event A base
+ "BEGIN:VEVENT\n"
+ "UID:event-a@google.com\n"
+ "SUMMARY:Morning Standup\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local start-a) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local end-a) "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=2\n"
+ "END:VEVENT\n"
+ ;; Event A exception
+ "BEGIN:VEVENT\n"
+ "UID:event-a@google.com\n"
+ "RECURRENCE-ID:" (test-calendar-sync-ics-datetime-local rec-id-a) "\n"
+ "SUMMARY:Morning Standup (Moved)\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local exc-a-start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local exc-a-end) "\n"
+ "END:VEVENT\n"
+ ;; Event B base
+ "BEGIN:VEVENT\n"
+ "UID:event-b@google.com\n"
+ "SUMMARY:Afternoon Review\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local start-b) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local end-b) "\n"
+ "RRULE:FREQ=WEEKLY;COUNT=2\n"
+ "END:VEVENT\n"
+ ;; Event B exception
+ "BEGIN:VEVENT\n"
+ "UID:event-b@google.com\n"
+ "RECURRENCE-ID:" (test-calendar-sync-ics-datetime-local rec-id-b) "\n"
+ "SUMMARY:Afternoon Review (Moved)\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime-local exc-b-start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime-local exc-b-end) "\n"
+ "END:VEVENT\n"
+ "END:VCALENDAR"))
+ (org-output (calendar-sync--parse-ics ics-content)))
+ (should (stringp org-output))
+ ;; Should have both events
+ (should (string-match-p "Morning Standup" org-output))
+ (should (string-match-p "Afternoon Review" org-output))
+ ;; Should have the "(Moved)" versions
+ (should (string-match-p "Moved" org-output))
+ ;; Should have rescheduled times (10:00 and 15:00)
+ (should (string-match-p "10:00" org-output))
+ (should (string-match-p "15:00" org-output))))
+
+(provide 'test-integration-calendar-sync-recurrence-exceptions)
+;;; test-integration-calendar-sync-recurrence-exceptions.el ends here