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. --- ...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 ++++++++++++++++++++ 5 files changed, 707 insertions(+) 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 (limited to 'tests') 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