From 84a02097bf842e96f7f4dd4e4ac39e78faf64989 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 3 Feb 2026 07:39:50 -0600 Subject: feat(calendar-sync): add RECURRENCE-ID exception handling for recurring events Handle rescheduled instances of recurring calendar events by processing RECURRENCE-ID properties from ICS files. When someone reschedules a single instance of a recurring meeting in Google Calendar, the calendar-sync module now shows the rescheduled time instead of the original RRULE time. New functions: - calendar-sync--get-recurrence-id: Extract RECURRENCE-ID from event - calendar-sync--get-recurrence-id-line: Get full line with TZID params - calendar-sync--parse-recurrence-id: Parse into (year month day hour minute) - calendar-sync--collect-recurrence-exceptions: Collect all exceptions by UID - calendar-sync--occurrence-matches-exception-p: Match occurrences to exceptions - calendar-sync--apply-single-exception: Apply exception data to occurrence - calendar-sync--apply-recurrence-exceptions: Apply all exceptions to occurrences Also adds DeepSat calendar configuration (dcal-file) to user-constants, init.el, and org-agenda-config. 48 unit and integration tests added covering normal, boundary, and error cases. --- .gitignore | 1 + init.el | 5 +- modules/calendar-sync.el | 188 +++++++++++++++++++-- modules/org-agenda-config.el | 4 +- modules/user-constants.el | 4 + ...t-calendar-sync--apply-recurrence-exceptions.el | 157 +++++++++++++++++ ...calendar-sync--collect-recurrence-exceptions.el | 174 +++++++++++++++++++ tests/test-calendar-sync--get-recurrence-id.el | 111 ++++++++++++ tests/test-calendar-sync--parse-recurrence-id.el | 99 +++++++++++ ...egration-calendar-sync-recurrence-exceptions.el | 166 ++++++++++++++++++ 10 files changed, 896 insertions(+), 13 deletions(-) create mode 100644 tests/test-calendar-sync--apply-recurrence-exceptions.el create mode 100644 tests/test-calendar-sync--collect-recurrence-exceptions.el create mode 100644 tests/test-calendar-sync--get-recurrence-id.el create mode 100644 tests/test-calendar-sync--parse-recurrence-id.el create mode 100644 tests/test-integration-calendar-sync-recurrence-exceptions.el 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 -- cgit v1.2.3