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 +++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 tests/test-calendar-sync--apply-recurrence-exceptions.el (limited to 'tests/test-calendar-sync--apply-recurrence-exceptions.el') 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 -- cgit v1.2.3