summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-11-18 12:54:25 -0600
committerCraig Jennings <c@cjennings.net>2025-11-18 12:54:25 -0600
commit2da0e8001ae390920f31f1db5e56aa2ed68bf1be (patch)
treeb9075be4442a0528f3eb64869fd4e585c7804219
parent342e7df14b688ca995c831a68531550ad59e2cc2 (diff)
feat(calendar-sync): Add RRULE support and refactor expansion functions
Implements complete recurring event (RRULE) expansion for Google Calendar with rolling window approach and comprehensive test coverage. Features: - RRULE expansion for DAILY, WEEKLY, MONTHLY, YEARLY frequencies - Support for INTERVAL, BYDAY, UNTIL, and COUNT parameters - Rolling window: -3 months to +12 months from current date - Fixed COUNT parameter bug (events no longer appear beyond their limit) - Fixed TZID parameter parsing (supports timezone-specific timestamps) - Replaced debug messages with cj/log-silently Refactoring: - Extracted helper functions to eliminate code duplication: - calendar-sync--date-to-time: Date to time conversion - calendar-sync--before-date-p: Date comparison - calendar-sync--create-occurrence: Event occurrence creation - Refactored all expansion functions to use helper functions - Reduced code duplication across daily/weekly/monthly/yearly expansion Testing: - 68 tests total across 5 test files - Unit tests for RRULE parsing, property extraction, weekly expansion - Integration tests for complete RRULE workflow - Tests for helper functions validating refactored code - All tests passing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
-rw-r--r--modules/calendar-sync.el420
-rw-r--r--tests/test-calendar-sync--expand-weekly.el272
-rw-r--r--tests/test-calendar-sync--get-property.el180
-rw-r--r--tests/test-calendar-sync--helpers.el157
-rw-r--r--tests/test-calendar-sync--parse-rrule.el209
-rw-r--r--tests/test-integration-recurring-events.el347
6 files changed, 1550 insertions, 35 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el
index 69059b7f..8b276333 100644
--- a/modules/calendar-sync.el
+++ b/modules/calendar-sync.el
@@ -11,11 +11,44 @@
;;
;; Features:
;; - Pure Emacs Lisp .ics parser (no external dependencies)
-;; - Timer-based automatic sync (every 15 minutes, configurable)
+;; - Recurring event support (RRULE expansion)
+;; - Timer-based automatic sync (every 60 minutes, configurable)
;; - Self-contained in .emacs.d (no cron, portable across machines)
;; - Read-only (can't corrupt Google Calendar)
;; - Works with chime.el for event notifications
;;
+;; Recurring Events (RRULE):
+;;
+;; Google Calendar recurring events are defined once with an RRULE
+;; (recurrence rule) rather than as individual event instances. This
+;; module expands recurring events into individual org entries.
+;;
+;; Expansion uses a rolling window approach:
+;; - Past: 3 months before today
+;; - Future: 12 months after today
+;;
+;; Every sync regenerates the entire file based on the current date,
+;; so the window automatically advances as time passes. Old events
+;; naturally fall off after 3 months, and new future events appear
+;; as you approach them.
+;;
+;; Example: If today is 2025-11-18, events are expanded from
+;; 2025-08-18 to 2026-11-18. When you sync on 2026-01-01, the
+;; window shifts to 2025-10-01 to 2027-01-01 automatically.
+;;
+;; This approach requires no state tracking and naturally handles
+;; the "year boundary" problem - there is no boundary to cross,
+;; the window just moves forward with each sync.
+;;
+;; Supported RRULE patterns:
+;; - FREQ=DAILY: Daily events
+;; - FREQ=WEEKLY;BYDAY=MO,WE,FR: Weekly on specific days
+;; - FREQ=MONTHLY: Monthly events (same day each month)
+;; - FREQ=YEARLY: Yearly events (anniversaries, birthdays)
+;; - INTERVAL: Repeat every N periods (e.g., every 2 weeks)
+;; - UNTIL: End date for recurrence
+;; - COUNT: Maximum occurrences (combined with date range limit)
+;;
;; Setup:
;; 1. Get your Google Calendar private .ics URL:
;; - Open Google Calendar → Settings → Your Calendar → Integrate calendar
@@ -59,6 +92,14 @@ Defaults to gcal-file from user-constants.")
If non-nil, sync starts automatically when calendar-sync is loaded.
If nil, user must manually call `calendar-sync-start'.")
+(defvar calendar-sync-past-months 3
+ "Number of months in the past to include when expanding recurring events.
+Default: 3 months. This keeps recent history visible in org-agenda.")
+
+(defvar calendar-sync-future-months 12
+ "Number of months in the future to include when expanding recurring events.
+Default: 12 months. This provides a full year of future events.")
+
;;; Internal state
(defvar calendar-sync--timer nil
@@ -134,7 +175,7 @@ Example: -21600 → 'UTC-6' or 'UTC-6:00'."
(setq calendar-sync--last-sync-time
(alist-get 'last-sync-time state))))
(error
- (message "calendar-sync: Error loading state: %s" (error-message-string err))))))
+ (cj/log-silently "calendar-sync: Error loading state: %s" (error-message-string err))))))
;;; Line Ending Normalization
@@ -150,6 +191,77 @@ Returns CONTENT with all \\r characters removed."
content
(replace-regexp-in-string "\r" "" content)))
+;;; Date Utilities
+
+(defun calendar-sync--add-months (date months)
+ "Add MONTHS to DATE.
+DATE is (year month day), returns new (year month day)."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (total-months (+ (* year 12) month -1 months))
+ (new-year (/ total-months 12))
+ (new-month (1+ (mod total-months 12))))
+ (list new-year new-month day)))
+
+(defun calendar-sync--get-date-range ()
+ "Get date range for event expansion as (start-time end-time).
+Returns time values for -3 months and +12 months from today."
+ (let* ((now (decode-time))
+ (today (list (nth 5 now) (nth 4 now) (nth 3 now)))
+ (start-date (calendar-sync--add-months today (- calendar-sync-past-months)))
+ (end-date (calendar-sync--add-months today calendar-sync-future-months))
+ (start-time (apply #'encode-time 0 0 0 (reverse start-date)))
+ (end-time (apply #'encode-time 0 0 0 (reverse end-date))))
+ (list start-time end-time)))
+
+(defun calendar-sync--date-in-range-p (date range)
+ "Check if DATE is within RANGE.
+DATE is (year month day hour minute), RANGE is (start-time end-time)."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (date-time (encode-time 0 0 0 day month year))
+ (start-time (nth 0 range))
+ (end-time (nth 1 range)))
+ (and (time-less-p start-time date-time)
+ (time-less-p date-time end-time))))
+
+(defun calendar-sync--weekday-to-number (weekday)
+ "Convert WEEKDAY string (MO, TU, etc.) to number (1-7).
+Monday = 1, Sunday = 7."
+ (pcase weekday
+ ("MO" 1)
+ ("TU" 2)
+ ("WE" 3)
+ ("TH" 4)
+ ("FR" 5)
+ ("SA" 6)
+ ("SU" 7)
+ (_ nil)))
+
+(defun calendar-sync--date-weekday (date)
+ "Get weekday number for DATE (year month day).
+Monday = 1, Sunday = 7."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (time (encode-time 0 0 0 day month year))
+ (decoded (decode-time time))
+ (dow (nth 6 decoded))) ; 0 = Sunday, 1 = Monday, etc.
+ (if (= dow 0) 7 dow))) ; Convert to 1-7 with Monday=1
+
+(defun calendar-sync--add-days (date days)
+ "Add DAYS to DATE (year month day).
+Returns new (year month day)."
+ (let* ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (time (encode-time 0 0 0 day month year))
+ (new-time (time-add time (days-to-time days)))
+ (decoded (decode-time new-time)))
+ (list (nth 5 decoded) (nth 4 decoded) (nth 3 decoded))))
+
;;; .ics Parsing
(defun calendar-sync--split-events (ics-content)
@@ -164,9 +276,18 @@ Returns list of strings, each containing one VEVENT block."
(defun calendar-sync--get-property (event property)
"Extract PROPERTY value from EVENT string.
+Handles property parameters (e.g., DTSTART;TZID=America/Chicago:value).
+Handles multi-line values (lines starting with space).
Returns nil if property not found."
- (when (string-match (format "^%s:\\(.*\\)$" property) event)
- (match-string 1 event)))
+ (when (string-match (format "^%s[^:\n]*:\\(.*\\)$" (regexp-quote property)) event)
+ (let ((value (match-string 1 event))
+ (start (match-end 0)))
+ ;; Handle continuation lines (start with space or tab)
+ (while (and (< start (length event))
+ (string-match "^\n[ \t]\\(.*\\)$" event start))
+ (setq value (concat value (match-string 1 event)))
+ (setq start (match-end 0)))
+ value)))
(defun calendar-sync--convert-utc-to-local (year month day hour minute second)
"Convert UTC datetime to local time.
@@ -223,24 +344,230 @@ Returns string like '<2025-11-16 Sun 14:00-15:00>' or '<2025-11-16 Sun>'."
start-hour start-min end-hour end-min))))
(concat date-str time-str ">")))
+;;; RRULE Parsing and Expansion
+
+;;; Helper Functions
+
+(defun calendar-sync--date-to-time (date)
+ "Convert DATE (year month day) to time value for comparison.
+DATE should be a list like (year month day)."
+ (apply #'encode-time 0 0 0 (reverse date)))
+
+(defun calendar-sync--before-date-p (date1 date2)
+ "Return t if DATE1 is before DATE2.
+Both dates should be lists like (year month day)."
+ (time-less-p (calendar-sync--date-to-time date1)
+ (calendar-sync--date-to-time date2)))
+
+(defun calendar-sync--create-occurrence (base-event occurrence-date)
+ "Create an occurrence from BASE-EVENT with OCCURRENCE-DATE.
+OCCURRENCE-DATE should be a list (year month day hour minute second)."
+ (let* ((occurrence (copy-sequence base-event))
+ (end (plist-get base-event :end)))
+ (plist-put occurrence :start occurrence-date)
+ (when end
+ ;; Use the date from occurrence-date but keep the time from the original end
+ (let ((date-only (list (nth 0 occurrence-date)
+ (nth 1 occurrence-date)
+ (nth 2 occurrence-date))))
+ (plist-put occurrence :end (append date-only (nthcdr 3 end)))))
+ occurrence))
+
+(defun calendar-sync--parse-rrule (rrule-str)
+ "Parse RRULE string into plist.
+Returns plist with :freq :interval :byday :until :count."
+ (let ((parts (split-string rrule-str ";"))
+ (result '()))
+ (dolist (part parts)
+ (when (string-match "\\([^=]+\\)=\\(.+\\)" part)
+ (let ((key (match-string 1 part))
+ (value (match-string 2 part)))
+ (pcase key
+ ("FREQ" (setq result (plist-put result :freq (intern (downcase value)))))
+ ("INTERVAL" (setq result (plist-put result :interval (string-to-number value))))
+ ("BYDAY" (setq result (plist-put result :byday (split-string value ","))))
+ ("UNTIL" (setq result (plist-put result :until (calendar-sync--parse-timestamp value))))
+ ("COUNT" (setq result (plist-put result :count (string-to-number value))))))))
+ ;; Set defaults
+ (unless (plist-get result :interval)
+ (setq result (plist-put result :interval 1)))
+ result))
+
+(defun calendar-sync--expand-daily (base-event rrule range)
+ "Expand daily recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range)))
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not until) (calendar-sync--before-date-p current-date until))
+ (or (not count) (< num-generated count)))
+ (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))
+ (setq current-date (calendar-sync--add-days current-date interval)))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-weekly (base-event rrule range)
+ "Expand weekly recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (end (plist-get base-event :end))
+ (interval (plist-get rrule :interval))
+ (byday (plist-get rrule :byday))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range))
+ (max-iterations 1000) ;; Safety: prevent infinite loops
+ (iterations 0)
+ (weekdays (if byday
+ (mapcar #'calendar-sync--weekday-to-number byday)
+ (list (calendar-sync--date-weekday current-date)))))
+ ;; Validate interval
+ (when (<= interval 0)
+ (error "Invalid RRULE interval: %s (must be > 0)" interval))
+ ;; Start from the first week
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (< iterations max-iterations)
+ (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not count) (< num-generated count))
+ (or (not until) (calendar-sync--before-date-p current-date until)))
+ (setq iterations (1+ iterations))
+ ;; Generate occurrences for each weekday in this week
+ (dolist (weekday weekdays)
+ (let* ((current-weekday (calendar-sync--date-weekday current-date))
+ (days-ahead (mod (- weekday current-weekday) 7))
+ (occurrence-date (calendar-sync--add-days current-date days-ahead))
+ (occurrence-datetime (append occurrence-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p occurrence-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences))))))
+ ;; Move to next interval week
+ (setq current-date (calendar-sync--add-days current-date (* 7 interval))))
+ (when (>= iterations max-iterations)
+ (cj/log-silently "calendar-sync: WARNING: Hit max iterations (%d) expanding weekly event" max-iterations))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-monthly (base-event rrule range)
+ "Expand monthly recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range)))
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not until) (calendar-sync--before-date-p current-date until))
+ (or (not count) (< num-generated count)))
+ (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))
+ (setq current-date (calendar-sync--add-months current-date interval)))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-yearly (base-event rrule range)
+ "Expand yearly recurring event.
+BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range."
+ (let* ((start (plist-get base-event :start))
+ (interval (plist-get rrule :interval))
+ (until (plist-get rrule :until))
+ (count (plist-get rrule :count))
+ (occurrences '())
+ (current-date (list (nth 0 start) (nth 1 start) (nth 2 start)))
+ (num-generated 0)
+ (range-end-time (cadr range)))
+ ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance
+ ;; For COUNT, generate all occurrences from start regardless of range
+ (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time))
+ (or (not until) (calendar-sync--before-date-p current-date until))
+ (or (not count) (< num-generated count)))
+ (let ((occurrence-datetime (append current-date (nthcdr 3 start))))
+ ;; Check UNTIL date first
+ (when (or (not until) (calendar-sync--before-date-p current-date until))
+ ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start
+ (when (or (not count) (< num-generated count))
+ (setq num-generated (1+ num-generated))
+ ;; Only add to output if within date range
+ (when (calendar-sync--date-in-range-p occurrence-datetime range)
+ (push (calendar-sync--create-occurrence base-event occurrence-datetime)
+ occurrences)))))
+ (setq current-date (calendar-sync--add-months current-date (* 12 interval))))
+ (nreverse occurrences)))
+
+(defun calendar-sync--expand-recurring-event (event-str range)
+ "Expand recurring event EVENT-STR into individual occurrences within RANGE.
+Returns list of event plists, or nil if not a recurring event."
+ (let ((rrule (calendar-sync--get-property event-str "RRULE")))
+ (when rrule
+ (let* ((base-event (calendar-sync--parse-event event-str))
+ (parsed-rrule (calendar-sync--parse-rrule rrule))
+ (freq (plist-get parsed-rrule :freq)))
+ (when base-event
+ (pcase freq
+ ('daily (calendar-sync--expand-daily base-event parsed-rrule range))
+ ('weekly (calendar-sync--expand-weekly base-event parsed-rrule range))
+ ('monthly (calendar-sync--expand-monthly base-event parsed-rrule range))
+ ('yearly (calendar-sync--expand-yearly base-event parsed-rrule range))
+ (_ (cj/log-silently "calendar-sync: Unsupported RRULE frequency: %s" freq)
+ nil)))))))
+
(defun calendar-sync--parse-event (event-str)
"Parse single VEVENT string EVENT-STR into plist.
Returns plist with :summary :description :location :start :end.
-Returns nil if event lacks required fields (DTSTART, SUMMARY)."
- (let ((summary (calendar-sync--get-property event-str "SUMMARY"))
- (description (calendar-sync--get-property event-str "DESCRIPTION"))
- (location (calendar-sync--get-property event-str "LOCATION"))
- (dtstart (calendar-sync--get-property event-str "DTSTART"))
- (dtend (calendar-sync--get-property event-str "DTEND")))
- (when (and summary dtstart)
- (let ((start-parsed (calendar-sync--parse-timestamp dtstart))
- (end-parsed (and dtend (calendar-sync--parse-timestamp dtend))))
- (when start-parsed
- (list :summary summary
- :description description
- :location location
- :start start-parsed
- :end end-parsed))))))
+Returns nil if event lacks required fields (DTSTART, SUMMARY).
+Skips events with RECURRENCE-ID (individual instances of recurring events)."
+ ;; Skip individual instances of recurring events (they're handled by RRULE expansion)
+ (unless (calendar-sync--get-property event-str "RECURRENCE-ID")
+ (let ((summary (calendar-sync--get-property event-str "SUMMARY"))
+ (description (calendar-sync--get-property event-str "DESCRIPTION"))
+ (location (calendar-sync--get-property event-str "LOCATION"))
+ (dtstart (calendar-sync--get-property event-str "DTSTART"))
+ (dtend (calendar-sync--get-property event-str "DTEND")))
+ (when (and summary dtstart)
+ (let ((start-parsed (calendar-sync--parse-timestamp dtstart))
+ (end-parsed (and dtend (calendar-sync--parse-timestamp dtend))))
+ (when start-parsed
+ (list :summary summary
+ :description description
+ :location location
+ :start start-parsed
+ :end end-parsed)))))))
(defun calendar-sync--event-to-org (event)
"Convert parsed EVENT plist to org entry string."
@@ -276,23 +603,46 @@ Returns time value suitable for comparison, or 0 if no start time."
(defun calendar-sync--parse-ics (ics-content)
"Parse ICS-CONTENT and return org-formatted string.
Returns nil if parsing fails.
-Events are sorted chronologically by start time."
+Events are sorted chronologically by start time.
+Recurring events are expanded into individual occurrences."
(condition-case err
- (let* ((events (calendar-sync--split-events ics-content))
- (parsed-events (delq nil (mapcar #'calendar-sync--parse-event events)))
- (sorted-events (sort parsed-events
- (lambda (a b)
- (time-less-p (calendar-sync--event-start-time a)
- (calendar-sync--event-start-time b)))))
- (org-entries (mapcar #'calendar-sync--event-to-org sorted-events)))
- (if org-entries
- (concat "# Google Calendar Events\n\n"
- (string-join org-entries "\n\n")
- "\n")
- nil))
+ (let* ((range (calendar-sync--get-date-range))
+ (events (calendar-sync--split-events ics-content))
+ (parsed-events '())
+ (max-events 5000) ; Safety limit to prevent Emacs from hanging
+ (events-generated 0))
+ ;; Process each event
+ (dolist (event-str events)
+ (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))))
+ ;; Non-recurring event - parse normally
+ (let ((parsed (calendar-sync--parse-event event-str)))
+ (when (and parsed
+ (calendar-sync--date-in-range-p (plist-get parsed :start) range))
+ (push parsed parsed-events)
+ (setq events-generated (1+ events-generated))))))))
+ (when (>= events-generated max-events)
+ (cj/log-silently "calendar-sync: WARNING: Hit max events limit (%d), some events may be missing" max-events))
+ (cj/log-silently "calendar-sync: Processing %d events..." (length parsed-events))
+ ;; Sort and convert to org format
+ (let* ((sorted-events (sort parsed-events
+ (lambda (a b)
+ (time-less-p (calendar-sync--event-start-time a)
+ (calendar-sync--event-start-time b)))))
+ (org-entries (mapcar #'calendar-sync--event-to-org sorted-events)))
+ (if org-entries
+ (concat "# Google Calendar Events\n\n"
+ (string-join org-entries "\n\n")
+ "\n")
+ nil)))
(error
(setq calendar-sync--last-error (error-message-string err))
- (message "calendar-sync: Parse error: %s" calendar-sync--last-error)
+ (cj/log-silently "calendar-sync: Parse error: %s" calendar-sync--last-error)
nil)))
;;; Sync functions
@@ -320,13 +670,13 @@ invoked when the fetch completes, either successfully or with an error."
(calendar-sync--normalize-line-endings (buffer-string))
(setq calendar-sync--last-error
(format "curl failed: %s" (string-trim event)))
- (message "calendar-sync: Fetch error: %s" calendar-sync--last-error)
+ (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error)
nil)))
(kill-buffer (process-buffer process))
(funcall callback content)))))))
(error
(setq calendar-sync--last-error (error-message-string err))
- (message "calendar-sync: Fetch error: %s" calendar-sync--last-error)
+ (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error)
(funcall callback nil))))
(defun calendar-sync--write-file (content)
diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el
new file mode 100644
index 00000000..e4e5b738
--- /dev/null
+++ b/tests/test-calendar-sync--expand-weekly.el
@@ -0,0 +1,272 @@
+;;; test-calendar-sync--expand-weekly.el --- Tests for calendar-sync--expand-weekly -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--expand-weekly function.
+;; Tests expansion of weekly recurring events into individual occurrences.
+;; Uses dynamic timestamps to avoid hardcoded dates.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+(require 'testutil-calendar-sync)
+
+;;; Setup and Teardown
+
+(defun test-calendar-sync--expand-weekly-setup ()
+ "Setup for calendar-sync--expand-weekly tests."
+ nil)
+
+(defun test-calendar-sync--expand-weekly-teardown ()
+ "Teardown for calendar-sync--expand-weekly tests."
+ nil)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--expand-weekly-normal-saturday-returns-occurrences ()
+ "Test expanding weekly event on Saturday (GTFO use case)."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 30))
+ (end-date (test-calendar-sync-time-days-from-now 1 11 0))
+ (base-event (list :summary "GTFO"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("SA")
+ :interval 1))
+ ;; Date range: 90 days past to 365 days future
+ (range (list (time-subtract (current-time) (* 90 24 3600))
+ (time-add (current-time) (* 365 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should generate ~52 Saturday occurrences in a year
+ (should (> (length occurrences) 40))
+ (should (< (length occurrences) 60))
+ ;; Each occurrence should be a Saturday
+ (dolist (occurrence occurrences)
+ (let* ((start (plist-get occurrence :start))
+ (weekday (calendar-sync--date-weekday (list (nth 0 start) (nth 1 start) (nth 2 start)))))
+ (should (= weekday 6))))) ; Saturday = 6
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-normal-multiple-days-returns-occurrences ()
+ "Test expanding weekly event on multiple weekdays."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 9 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Standup"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("MO" "WE" "FR")
+ :interval 1))
+ (range (list (time-subtract (current-time) (* 30 24 3600))
+ (time-add (current-time) (* 90 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should generate 3 occurrences per week for ~4 months
+ (should (> (length occurrences) 30))
+ (should (< (length occurrences) 60))
+ ;; Each occurrence should be Mon, Wed, or Fri
+ (dolist (occurrence occurrences)
+ (let* ((start (plist-get occurrence :start))
+ (weekday (calendar-sync--date-weekday (list (nth 0 start) (nth 1 start) (nth 2 start)))))
+ (should (member weekday '(1 3 5)))))) ; Mon=1, Wed=3, Fri=5
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-normal-interval-two-returns-occurrences ()
+ "Test expanding bi-weekly event."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 14 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 15 0))
+ (base-event (list :summary "Bi-weekly Meeting"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("TU")
+ :interval 2))
+ (range (list (time-subtract (current-time) (* 30 24 3600))
+ (time-add (current-time) (* 180 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should generate ~13 occurrences (26 weeks = 13 bi-weekly)
+ (should (> (length occurrences) 10))
+ (should (< (length occurrences) 20)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--expand-weekly-boundary-with-count-returns-limited-occurrences ()
+ "Test expanding weekly event with count limit."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 11 0))
+ (base-event (list :summary "Limited Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("MO")
+ :interval 1
+ :count 5))
+ (range (list (time-subtract (current-time) (* 30 24 3600))
+ (time-add (current-time) (* 365 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should generate exactly 5 occurrences
+ (should (= (length occurrences) 5)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-boundary-with-until-returns-limited-occurrences ()
+ "Test expanding weekly event with end date."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 11 0))
+ (until-date (test-calendar-sync-time-days-from-now 60 0 0))
+ (base-event (list :summary "Time-Limited Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("WE")
+ :interval 1
+ :until until-date))
+ (range (list (time-subtract (current-time) (* 30 24 3600))
+ (time-add (current-time) (* 365 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should generate ~8 Wednesday occurrences in 60 days
+ (should (> (length occurrences) 6))
+ (should (< (length occurrences) 12)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-boundary-no-byday-uses-start-day ()
+ "Test expanding weekly event without BYDAY uses start date weekday."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 7 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 7 11 0))
+ (start-weekday (calendar-sync--date-weekday (list (nth 0 start-date) (nth 1 start-date) (nth 2 start-date))))
+ (base-event (list :summary "No BYDAY Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :interval 1))
+ (range (list (time-subtract (current-time) (* 30 24 3600))
+ (time-add (current-time) (* 90 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should generate occurrences
+ (should (> (length occurrences) 8))
+ ;; All occurrences should be on the same weekday as start
+ (dolist (occurrence occurrences)
+ (let* ((start (plist-get occurrence :start))
+ (weekday (calendar-sync--date-weekday (list (nth 0 start) (nth 1 start) (nth 2 start)))))
+ (should (= weekday start-weekday)))))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-boundary-max-iterations-prevents-infinite-loop ()
+ "Test that max iterations safety check prevents infinite loops."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 11 0))
+ (base-event (list :summary "Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("MO")
+ :interval 1))
+ ;; Very large date range that would generate >1000 occurrences
+ (range (list (time-subtract (current-time) (* 365 24 3600))
+ (time-add (current-time) (* 3650 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should stop at max iterations (1000)
+ (should (<= (length occurrences) 1000)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-boundary-respects-date-range ()
+ "Test that expansion respects date range boundaries."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 11 0))
+ (base-event (list :summary "Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("TH")
+ :interval 1))
+ ;; Narrow date range: only 30 days
+ (range (list (current-time)
+ (time-add (current-time) (* 30 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range))
+ (range-start (nth 0 range))
+ (range-end (nth 1 range)))
+ ;; Should only generate ~4 Thursday occurrences in 30 days
+ (should (>= (length occurrences) 3))
+ (should (<= (length occurrences) 5))
+ ;; All occurrences should be within range
+ (dolist (occurrence occurrences)
+ (let* ((start (plist-get occurrence :start))
+ (occ-time (apply #'encode-time 0 0 0 (reverse (list (nth 0 start) (nth 1 start) (nth 2 start))))))
+ (should (time-less-p range-start occ-time))
+ (should (time-less-p occ-time range-end)))))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--expand-weekly-error-empty-base-event-returns-empty ()
+ "Test expanding with minimal base event."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :start start-date))
+ (rrule (list :freq 'weekly
+ :interval 1))
+ (range (list (current-time)
+ (time-add (current-time) (* 30 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should still generate occurrences even without end time
+ (should (> (length occurrences) 0)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-error-zero-interval-returns-empty ()
+ "Test that zero interval doesn't cause infinite loop."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 11 0))
+ (base-event (list :summary "Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("MO")
+ :interval 0)) ; Invalid!
+ (range (list (current-time)
+ (time-add (current-time) (* 30 24 3600)))))
+ ;; Should either return empty or handle gracefully
+ ;; Zero interval would cause infinite loop if not handled
+ (should-error (calendar-sync--expand-weekly base-event rrule range)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(ert-deftest test-calendar-sync--expand-weekly-error-past-until-returns-empty ()
+ "Test expanding event with UNTIL date in the past."
+ (test-calendar-sync--expand-weekly-setup)
+ (unwind-protect
+ (let* ((start-date (test-calendar-sync-time-days-ago 100 10 0))
+ (end-date (test-calendar-sync-time-days-ago 100 11 0))
+ (until-date (test-calendar-sync-time-days-ago 50 0 0))
+ (base-event (list :summary "Past Event"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'weekly
+ :byday '("MO")
+ :interval 1
+ :until until-date))
+ (range (list (time-subtract (current-time) (* 30 24 3600))
+ (time-add (current-time) (* 365 24 3600))))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ ;; Should return empty list (all occurrences before range)
+ (should (= (length occurrences) 0)))
+ (test-calendar-sync--expand-weekly-teardown)))
+
+(provide 'test-calendar-sync--expand-weekly)
+;;; test-calendar-sync--expand-weekly.el ends here
diff --git a/tests/test-calendar-sync--get-property.el b/tests/test-calendar-sync--get-property.el
new file mode 100644
index 00000000..79fefc8f
--- /dev/null
+++ b/tests/test-calendar-sync--get-property.el
@@ -0,0 +1,180 @@
+;;; test-calendar-sync--get-property.el --- Tests for calendar-sync--get-property -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--get-property function.
+;; Tests property extraction from iCalendar event strings,
+;; especially with property parameters like TZID.
+;;
+;; Critical: This function had a bug where it couldn't parse
+;; properties with parameters (e.g., DTSTART;TZID=America/Chicago:...)
+;; These tests prevent regression of that bug.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Setup and Teardown
+
+(defun test-calendar-sync--get-property-setup ()
+ "Setup for calendar-sync--get-property tests."
+ nil)
+
+(defun test-calendar-sync--get-property-teardown ()
+ "Teardown for calendar-sync--get-property tests."
+ nil)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--get-property-normal-simple-property-returns-value ()
+ "Test extracting simple property without parameters."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nSUMMARY:Test Event\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "SUMMARY") "Test Event")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-normal-dtstart-without-tzid-returns-value ()
+ "Test extracting DTSTART without timezone parameter."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDTSTART:20251118T140000Z\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000Z")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-normal-dtstart-with-tzid-returns-value ()
+ "Test extracting DTSTART with TZID parameter (the bug we fixed)."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDTSTART;TZID=America/Chicago:20251118T140000\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-normal-location-returns-value ()
+ "Test extracting LOCATION property."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nLOCATION:Conference Room A\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "LOCATION") "Conference Room A")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-normal-description-returns-value ()
+ "Test extracting DESCRIPTION property."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDESCRIPTION:This is a test event\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DESCRIPTION") "This is a test event")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-normal-rrule-returns-value ()
+ "Test extracting RRULE property."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nRRULE:FREQ=WEEKLY;BYDAY=SA\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "RRULE") "FREQ=WEEKLY;BYDAY=SA")))
+ (test-calendar-sync--get-property-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--get-property-boundary-value-param-with-multiple-params-returns-value ()
+ "Test extracting property with multiple parameters."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDTSTART;TZID=America/Chicago;VALUE=DATE-TIME:20251118T140000\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-complex-tzid-returns-value ()
+ "Test extracting property with complex timezone ID."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDTSTART;TZID=America/Argentina/Buenos_Aires:20251118T140000\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DTSTART") "20251118T140000")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-empty-value-returns-empty-string ()
+ "Test extracting property with empty value."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDESCRIPTION:\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DESCRIPTION") "")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-property-at-start-returns-value ()
+ "Test extracting property when it's the first line."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "SUMMARY:First Property\nDTSTART:20251118T140000Z"))
+ (should (equal (calendar-sync--get-property event "SUMMARY") "First Property")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-property-at-end-returns-value ()
+ "Test extracting property when it's the last line."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "DTSTART:20251118T140000Z\nSUMMARY:Last Property"))
+ (should (equal (calendar-sync--get-property event "SUMMARY") "Last Property")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-value-with-special-chars-returns-value ()
+ "Test extracting property value with special characters."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nLOCATION:Room 123, Building A (Main Campus)\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "LOCATION") "Room 123, Building A (Main Campus)")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-value-with-semicolons-returns-value ()
+ "Test extracting property value containing semicolons."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDESCRIPTION:Tasks: setup; review; deploy\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "DESCRIPTION") "Tasks: setup; review; deploy")))
+ (test-calendar-sync--get-property-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--get-property-error-missing-property-returns-nil ()
+ "Test extracting non-existent property returns nil."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nSUMMARY:Test Event\nEND:VEVENT"))
+ (should (null (calendar-sync--get-property event "LOCATION"))))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-error-empty-event-returns-nil ()
+ "Test extracting property from empty event string."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (should (null (calendar-sync--get-property "" "SUMMARY")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-error-malformed-property-returns-nil ()
+ "Test extracting property with missing colon.
+Malformed properties without colons should not match."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nSUMMARY Test Event\nEND:VEVENT"))
+ ;; Space instead of colon - should not match
+ (should (null (calendar-sync--get-property event "SUMMARY"))))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-boundary-case-insensitive-returns-value ()
+ "Test that property matching is case-insensitive per RFC 5545.
+iCalendar spec requires property names to be case-insensitive."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nsummary:Test Event\nEND:VEVENT"))
+ (should (equal (calendar-sync--get-property event "SUMMARY") "Test Event")))
+ (test-calendar-sync--get-property-teardown)))
+
+(ert-deftest test-calendar-sync--get-property-error-property-in-value-not-matched ()
+ "Test that property name in another property's value is not matched."
+ (test-calendar-sync--get-property-setup)
+ (unwind-protect
+ (let ((event "BEGIN:VEVENT\nDESCRIPTION:SUMMARY: Not a real summary\nEND:VEVENT"))
+ (should (null (calendar-sync--get-property event "SUMMARY"))))
+ (test-calendar-sync--get-property-teardown)))
+
+(provide 'test-calendar-sync--get-property)
+;;; test-calendar-sync--get-property.el ends here
diff --git a/tests/test-calendar-sync--helpers.el b/tests/test-calendar-sync--helpers.el
new file mode 100644
index 00000000..eb868952
--- /dev/null
+++ b/tests/test-calendar-sync--helpers.el
@@ -0,0 +1,157 @@
+;;; test-calendar-sync--helpers.el --- Tests for calendar-sync helper functions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for refactored helper functions.
+;; Tests the helper functions that simplify RRULE expansion logic.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Setup and Teardown
+
+(defun test-calendar-sync--helpers-setup ()
+ "Setup for helper function tests."
+ nil)
+
+(defun test-calendar-sync--helpers-teardown ()
+ "Teardown for helper function tests."
+ nil)
+
+;;; calendar-sync--date-to-time Tests
+
+(ert-deftest test-calendar-sync--date-to-time-converts-date-to-time ()
+ "Test converting date to time value."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let* ((date '(2025 11 18)) ; Nov 18, 2025
+ (time-val (calendar-sync--date-to-time date)))
+ ;; Should return a valid time value
+ (should (numberp (time-convert time-val 'integer))))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--date-to-time-handles-different-dates ()
+ "Test date-to-time with various dates."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let ((date1 '(2025 1 1))
+ (date2 '(2025 12 31)))
+ ;; Different dates should produce different time values
+ (should (not (equal (calendar-sync--date-to-time date1)
+ (calendar-sync--date-to-time date2)))))
+ (test-calendar-sync--helpers-teardown)))
+
+;;; calendar-sync--before-date-p Tests
+
+(ert-deftest test-calendar-sync--before-date-p-returns-true-for-earlier-date ()
+ "Test that earlier dates return true."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let ((earlier '(2025 11 17))
+ (later '(2025 11 18)))
+ (should (calendar-sync--before-date-p earlier later)))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--before-date-p-returns-false-for-later-date ()
+ "Test that later dates return false."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let ((earlier '(2025 11 17))
+ (later '(2025 11 18)))
+ (should-not (calendar-sync--before-date-p later earlier)))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--before-date-p-returns-false-for-same-date ()
+ "Test that same dates return false."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let ((date '(2025 11 18)))
+ (should-not (calendar-sync--before-date-p date date)))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--before-date-p-handles-month-boundaries ()
+ "Test date comparison across month boundaries."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let ((november '(2025 11 30))
+ (december '(2025 12 1)))
+ (should (calendar-sync--before-date-p november december))
+ (should-not (calendar-sync--before-date-p december november)))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--before-date-p-handles-year-boundaries ()
+ "Test date comparison across year boundaries."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let ((dec-2025 '(2025 12 31))
+ (jan-2026 '(2026 1 1)))
+ (should (calendar-sync--before-date-p dec-2025 jan-2026))
+ (should-not (calendar-sync--before-date-p jan-2026 dec-2025)))
+ (test-calendar-sync--helpers-teardown)))
+
+;;; calendar-sync--create-occurrence Tests
+
+(ert-deftest test-calendar-sync--create-occurrence-creates-new-event ()
+ "Test creating occurrence from base event."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let* ((base-event '(:summary "Test Event"
+ :start (2025 11 1 10 0 0)
+ :end (2025 11 1 11 0 0)))
+ (new-date '(2025 11 15 10 0 0))
+ (occurrence (calendar-sync--create-occurrence base-event new-date)))
+ ;; Should have same summary
+ (should (equal (plist-get occurrence :summary) "Test Event"))
+ ;; Should have new start date
+ (should (equal (plist-get occurrence :start) new-date))
+ ;; Should have end date with same day as start
+ (let ((end (plist-get occurrence :end)))
+ (should (= (nth 0 end) 2025))
+ (should (= (nth 1 end) 11))
+ (should (= (nth 2 end) 15))))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--create-occurrence-preserves-time ()
+ "Test that occurrence preserves time from base event."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let* ((base-event '(:summary "Morning Meeting"
+ :start (2025 11 1 9 30 0)
+ :end (2025 11 1 10 30 0)))
+ (new-date '(2025 11 15 9 30 0))
+ (occurrence (calendar-sync--create-occurrence base-event new-date)))
+ ;; End time should preserve hours/minutes from base event
+ (let ((end (plist-get occurrence :end)))
+ (should (= (nth 3 end) 10)) ; hour
+ (should (= (nth 4 end) 30)))) ; minute
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--create-occurrence-handles-no-end-time ()
+ "Test creating occurrence when base event has no end time."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let* ((base-event '(:summary "All Day Event"
+ :start (2025 11 1 0 0 0)))
+ (new-date '(2025 11 15 0 0 0))
+ (occurrence (calendar-sync--create-occurrence base-event new-date)))
+ ;; Should have start but no end
+ (should (equal (plist-get occurrence :start) new-date))
+ (should (null (plist-get occurrence :end))))
+ (test-calendar-sync--helpers-teardown)))
+
+(ert-deftest test-calendar-sync--create-occurrence-does-not-modify-original ()
+ "Test that creating occurrence doesn't modify base event."
+ (test-calendar-sync--helpers-setup)
+ (unwind-protect
+ (let* ((original-start '(2025 11 1 10 0 0))
+ (base-event (list :summary "Test"
+ :start original-start))
+ (new-date '(2025 11 15 10 0 0)))
+ (calendar-sync--create-occurrence base-event new-date)
+ ;; Original should be unchanged
+ (should (equal (plist-get base-event :start) original-start)))
+ (test-calendar-sync--helpers-teardown)))
+
+(provide 'test-calendar-sync--helpers)
+;;; test-calendar-sync--helpers.el ends here
diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el
new file mode 100644
index 00000000..123caa5c
--- /dev/null
+++ b/tests/test-calendar-sync--parse-rrule.el
@@ -0,0 +1,209 @@
+;;; test-calendar-sync--parse-rrule.el --- Tests for calendar-sync--parse-rrule -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for calendar-sync--parse-rrule function.
+;; Tests parsing of iCalendar RRULE strings into plist format.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Setup and Teardown
+
+(defun test-calendar-sync--parse-rrule-setup ()
+ "Setup for calendar-sync--parse-rrule tests."
+ ;; No setup required for pure parsing tests
+ nil)
+
+(defun test-calendar-sync--parse-rrule-teardown ()
+ "Teardown for calendar-sync--parse-rrule tests."
+ ;; No teardown required
+ nil)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-weekly-returns-plist ()
+ "Test parsing simple weekly recurrence rule."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY")))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-weekly-with-byday-returns-plist ()
+ "Test parsing weekly recurrence with specific weekdays."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;BYDAY=SA")))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (equal (plist-get result :byday) '("SA")))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-daily-returns-plist ()
+ "Test parsing daily recurrence rule."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=DAILY")))
+ (should (eq (plist-get result :freq) 'daily))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-monthly-returns-plist ()
+ "Test parsing monthly recurrence rule."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY")))
+ (should (eq (plist-get result :freq) 'monthly))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-yearly-returns-plist ()
+ "Test parsing yearly recurrence rule."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=YEARLY")))
+ (should (eq (plist-get result :freq) 'yearly))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-with-interval-returns-plist ()
+ "Test parsing recurrence rule with custom interval."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;INTERVAL=2")))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (= (plist-get result :interval) 2)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-with-count-returns-plist ()
+ "Test parsing recurrence rule with count limit."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;COUNT=10")))
+ (should (eq (plist-get result :freq) 'daily))
+ (should (= (plist-get result :count) 10)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-with-until-returns-plist ()
+ "Test parsing recurrence rule with end date."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let* ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;UNTIL=20261118T120000Z"))
+ (until (plist-get result :until)))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (listp until))
+ (should (= (nth 0 until) 2026)) ; year
+ (should (= (nth 1 until) 11)) ; month
+ ;; Day might be 17 or 18 depending on timezone conversion
+ (should (member (nth 2 until) '(17 18))))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-multiple-byday-returns-list ()
+ "Test parsing BYDAY with multiple weekdays."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;BYDAY=MO,WE,FR")))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (equal (plist-get result :byday) '("MO" "WE" "FR"))))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-all-parameters-returns-plist ()
+ "Test parsing RRULE with all supported parameters."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;INTERVAL=2;BYDAY=SA;UNTIL=20261118T000000Z;COUNT=52")))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (= (plist-get result :interval) 2))
+ (should (equal (plist-get result :byday) '("SA")))
+ (should (plist-get result :until))
+ (should (= (plist-get result :count) 52)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-interval-one-returns-default ()
+ "Test that default interval is 1 when not specified."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=DAILY")))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-large-interval-returns-number ()
+ "Test parsing RRULE with large interval value."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;INTERVAL=12")))
+ (should (= (plist-get result :interval) 12)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-large-count-returns-number ()
+ "Test parsing RRULE with large count value."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;COUNT=365")))
+ (should (= (plist-get result :count) 365)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-rrule-error-empty-string-returns-plist ()
+ "Test parsing empty RRULE string returns plist with defaults."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "")))
+ (should (listp result))
+ (should (= (plist-get result :interval) 1)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-error-unsupported-freq-returns-symbol ()
+ "Test parsing RRULE with unsupported frequency."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=HOURLY")))
+ (should (eq (plist-get result :freq) 'hourly)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-error-invalid-until-returns-nil ()
+ "Test parsing RRULE with malformed UNTIL date."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;UNTIL=invalid")))
+ (should (eq (plist-get result :freq) 'daily))
+ (should (null (plist-get result :until))))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-error-invalid-count-returns-zero ()
+ "Test parsing RRULE with non-numeric COUNT."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=DAILY;COUNT=abc")))
+ (should (eq (plist-get result :freq) 'daily))
+ (should (= (plist-get result :count) 0)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-error-invalid-interval-returns-zero ()
+ "Test parsing RRULE with non-numeric INTERVAL."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "FREQ=WEEKLY;INTERVAL=xyz")))
+ (should (eq (plist-get result :freq) 'weekly))
+ (should (= (plist-get result :interval) 0)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(ert-deftest test-calendar-sync--parse-rrule-error-missing-freq-returns-plist ()
+ "Test parsing RRULE without FREQ parameter."
+ (test-calendar-sync--parse-rrule-setup)
+ (unwind-protect
+ (let ((result (calendar-sync--parse-rrule "INTERVAL=2;COUNT=10")))
+ (should (listp result))
+ (should (null (plist-get result :freq)))
+ (should (= (plist-get result :interval) 2))
+ (should (= (plist-get result :count) 10)))
+ (test-calendar-sync--parse-rrule-teardown)))
+
+(provide 'test-calendar-sync--parse-rrule)
+;;; test-calendar-sync--parse-rrule.el ends here
diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el
new file mode 100644
index 00000000..0d32d9e0
--- /dev/null
+++ b/tests/test-integration-recurring-events.el
@@ -0,0 +1,347 @@
+;;; test-integration-recurring-events.el --- Integration tests for recurring events -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests for the complete recurring event (RRULE) workflow.
+;; Tests the full pipeline: ICS parsing → RRULE expansion → org formatting.
+;;
+;; Components integrated:
+;; - calendar-sync--split-events (ICS event extraction)
+;; - calendar-sync--get-property (property extraction with TZID)
+;; - calendar-sync--parse-rrule (RRULE parsing)
+;; - calendar-sync--expand-weekly/daily/monthly/yearly (event expansion)
+;; - calendar-sync--parse-event (event parsing)
+;; - calendar-sync--event-to-org (org formatting)
+;; - calendar-sync--parse-ics (complete pipeline orchestration)
+;;
+;; This validates that the entire RRULE system works together correctly,
+;; from raw ICS input to final org-mode output.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+(require 'testutil-calendar-sync)
+
+;;; Setup and Teardown
+
+(defun test-integration-recurring-events-setup ()
+ "Setup for recurring events integration tests."
+ nil)
+
+(defun test-integration-recurring-events-teardown ()
+ "Teardown for recurring events integration tests."
+ nil)
+
+;;; Test Data
+
+(defconst test-integration-recurring-events--weekly-ics
+ "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VEVENT
+DTSTART;TZID=America/Chicago:20251118T103000
+DTEND;TZID=America/Chicago:20251118T110000
+RRULE:FREQ=WEEKLY;BYDAY=SA
+SUMMARY:GTFO
+UID:test-weekly@example.com
+END:VEVENT
+END:VCALENDAR"
+ "Test ICS with weekly recurring event (GTFO use case).")
+
+(defconst test-integration-recurring-events--daily-with-count-ics
+ "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VEVENT
+DTSTART:20251120T090000Z
+DTEND:20251120T100000Z
+RRULE:FREQ=DAILY;COUNT=5
+SUMMARY:Daily Standup
+UID:test-daily@example.com
+END:VEVENT
+END:VCALENDAR"
+ "Test ICS with daily recurring event limited by COUNT.")
+
+(defconst test-integration-recurring-events--mixed-ics
+ "BEGIN:VCALENDAR
+VERSION:2.0
+PRODID:-//Test//Test//EN
+BEGIN:VEVENT
+DTSTART:20251125T140000Z
+DTEND:20251125T150000Z
+SUMMARY:One-time Meeting
+UID:test-onetime@example.com
+END:VEVENT
+BEGIN:VEVENT
+DTSTART;TZID=America/Chicago:20251201T093000
+DTEND;TZID=America/Chicago:20251201T103000
+RRULE:FREQ=WEEKLY;BYDAY=MO,WE,FR
+SUMMARY:Recurring Standup
+UID:test-recurring@example.com
+END:VEVENT
+END:VCALENDAR"
+ "Test ICS with mix of recurring and non-recurring events.")
+
+;;; Normal Cases - Complete Workflow
+
+(ert-deftest test-integration-recurring-events-weekly-complete-workflow ()
+ "Test complete workflow for weekly recurring event.
+
+Components integrated:
+- calendar-sync--split-events (extract VEVENT blocks)
+- calendar-sync--get-property (extract DTSTART, DTEND, RRULE with TZID)
+- calendar-sync--parse-rrule (parse FREQ=WEEKLY;BYDAY=SA)
+- calendar-sync--expand-weekly (generate Saturday occurrences)
+- calendar-sync--event-to-org (format as org entries)
+- calendar-sync--parse-ics (orchestrate complete pipeline)
+
+Validates:
+- TZID parameters handled correctly
+- RRULE expansion generates correct dates
+- Multiple occurrences created from single event
+- Org output is properly formatted with timestamps"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics)))
+ ;; Should generate org-formatted output
+ (should (stringp org-output))
+ (should (string-match-p "^# Google Calendar Events" org-output))
+
+ ;; Should contain multiple GTFO entries
+ (let ((gtfo-count (with-temp-buffer
+ (insert org-output)
+ (goto-char (point-min))
+ (how-many "^\\* GTFO"))))
+ (should (> gtfo-count 40)) ; ~52 weeks in a year
+ (should (< gtfo-count 60)))
+
+ ;; Should have properly formatted Saturday timestamps
+ (should (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\} Sat 10:30-11:00>" org-output)))
+ (test-integration-recurring-events-teardown)))
+
+(ert-deftest test-integration-recurring-events-daily-with-count-workflow ()
+ "Test complete workflow for daily recurring event with COUNT limit.
+
+Components integrated:
+- calendar-sync--parse-rrule (with COUNT parameter)
+- calendar-sync--expand-daily (respects COUNT=5)
+- calendar-sync--parse-ics (complete pipeline)
+
+Validates:
+- COUNT parameter limits expansion correctly
+- Daily recurrence generates consecutive days
+- Exactly 5 occurrences created"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--daily-with-count-ics)))
+ (should (stringp org-output))
+
+ ;; Should generate exactly 5 Daily Standup entries
+ (let ((standup-count (with-temp-buffer
+ (insert org-output)
+ (goto-char (point-min))
+ (how-many "^\\* Daily Standup"))))
+ (should (= standup-count 5))))
+ (test-integration-recurring-events-teardown)))
+
+(ert-deftest test-integration-recurring-events-mixed-recurring-and-onetime ()
+ "Test workflow with mixed recurring and non-recurring events.
+
+Components integrated:
+- calendar-sync--split-events (handles multiple VEVENT blocks)
+- calendar-sync--expand-recurring-event (detects RRULE vs non-recurring)
+- calendar-sync--parse-event (handles both types)
+- calendar-sync--parse-ics (processes both event types)
+
+Validates:
+- Non-recurring events included once
+- Recurring events expanded correctly
+- Both types appear in output
+- Events are sorted chronologically"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--mixed-ics)))
+ (should (stringp org-output))
+
+ ;; Should have one-time meeting
+ (should (string-match-p "^\\* One-time Meeting" org-output))
+
+ ;; Should have multiple recurring standup entries
+ (let ((standup-count (with-temp-buffer
+ (insert org-output)
+ (goto-char (point-min))
+ (how-many "^\\* Recurring Standup"))))
+ (should (> standup-count 10))) ; ~3 days/week for 4 months
+
+ ;; Events should be sorted by date (one-time comes before recurring)
+ (should (< (string-match "One-time Meeting" org-output)
+ (string-match "Recurring Standup" org-output))))
+ (test-integration-recurring-events-teardown)))
+
+;;; Boundary Cases - Date Range Handling
+
+(ert-deftest test-integration-recurring-events-respects-rolling-window ()
+ "Test that RRULE expansion respects rolling window boundaries.
+
+Components integrated:
+- calendar-sync--get-date-range (calculates -3 months to +12 months)
+- calendar-sync--date-in-range-p (filters occurrences)
+- calendar-sync--expand-weekly (respects range)
+- calendar-sync--parse-ics (applies range to all events)
+
+Validates:
+- Events outside date range are excluded
+- Rolling window is applied consistently
+- Past events (> 3 months) excluded
+- Future events (> 12 months) excluded"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let* ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics))
+ (now (current-time))
+ (three-months-ago (time-subtract now (* 90 24 3600)))
+ (twelve-months-future (time-add now (* 365 24 3600))))
+ (should (stringp org-output))
+
+ ;; Parse all dates from output
+ (with-temp-buffer
+ (insert org-output)
+ (goto-char (point-min))
+ (let ((all-dates-in-range t))
+ (while (re-search-forward "<\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)" nil t)
+ (let* ((year (string-to-number (match-string 1)))
+ (month (string-to-number (match-string 2)))
+ (day (string-to-number (match-string 3)))
+ (event-time (encode-time 0 0 0 day month year)))
+ ;; All dates should be within window
+ (when (or (time-less-p event-time three-months-ago)
+ (time-less-p twelve-months-future event-time))
+ (setq all-dates-in-range nil))))
+ (should all-dates-in-range))))
+ (test-integration-recurring-events-teardown)))
+
+(ert-deftest test-integration-recurring-events-tzid-conversion ()
+ "Test that TZID timestamps are handled correctly throughout pipeline.
+
+Components integrated:
+- calendar-sync--get-property (extracts DTSTART;TZID=America/Chicago:...)
+- calendar-sync--parse-timestamp (converts to local time)
+- calendar-sync--format-timestamp (formats for org-mode)
+- calendar-sync--event-to-org (includes formatted timestamp)
+
+Validates:
+- TZID parameter doesn't break parsing (regression test)
+- Timestamps are correctly formatted in org output
+- Time values are preserved through pipeline"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let ((org-output (calendar-sync--parse-ics test-integration-recurring-events--weekly-ics)))
+ (should (stringp org-output))
+
+ ;; Should have timestamps with time range
+ (should (string-match-p "Sat 10:30-11:00" org-output))
+
+ ;; Should NOT have TZID in output (converted to org format)
+ (should-not (string-match-p "TZID" org-output)))
+ (test-integration-recurring-events-teardown)))
+
+;;; Edge Cases - Error Handling
+
+(ert-deftest test-integration-recurring-events-empty-ics-returns-nil ()
+ "Test that empty ICS content is handled gracefully.
+
+Components integrated:
+- calendar-sync--parse-ics (top-level error handling)
+
+Validates:
+- Empty input doesn't crash
+- Returns nil for empty content"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let ((org-output (calendar-sync--parse-ics "")))
+ (should (null org-output)))
+ (test-integration-recurring-events-teardown)))
+
+(ert-deftest test-integration-recurring-events-malformed-ics-returns-nil ()
+ "Test that malformed ICS content is handled gracefully.
+
+Components integrated:
+- calendar-sync--parse-ics (error handling)
+
+Validates:
+- Malformed input doesn't crash
+- Error is caught and logged
+- Returns nil for malformed content"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let ((org-output (calendar-sync--parse-ics "INVALID ICS DATA")))
+ ;; Should handle error gracefully
+ (should (null org-output)))
+ (test-integration-recurring-events-teardown)))
+
+(ert-deftest test-integration-recurring-events-missing-required-fields ()
+ "Test handling of events missing required fields.
+
+Components integrated:
+- calendar-sync--parse-event (validates required fields)
+- calendar-sync--parse-ics (filters invalid events)
+
+Validates:
+- Events without SUMMARY are excluded
+- Events without DTSTART are excluded
+- Valid events still processed"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let* ((incomplete-ics "BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+DTSTART:20251201T100000Z
+RRULE:FREQ=DAILY;COUNT=2
+END:VEVENT
+BEGIN:VEVENT
+SUMMARY:Valid Event
+DTSTART:20251201T110000Z
+DTEND:20251201T120000Z
+END:VEVENT
+END:VCALENDAR")
+ (org-output (calendar-sync--parse-ics incomplete-ics)))
+ ;; Should still generate output (for valid event)
+ (should (stringp org-output))
+ (should (string-match-p "Valid Event" org-output))
+
+ ;; Invalid event (no SUMMARY) should be excluded
+ (should-not (string-match-p "VEVENT" org-output)))
+ (test-integration-recurring-events-teardown)))
+
+(ert-deftest test-integration-recurring-events-unsupported-freq-skipped ()
+ "Test that events with unsupported FREQ are handled gracefully.
+
+Components integrated:
+- calendar-sync--parse-rrule (parses unsupported FREQ)
+- calendar-sync--expand-recurring-event (detects unsupported FREQ)
+- calendar-sync--parse-ics (continues processing other events)
+
+Validates:
+- Unsupported FREQ doesn't crash pipeline
+- Warning message is logged
+- Other events still processed"
+ (test-integration-recurring-events-setup)
+ (unwind-protect
+ (let* ((unsupported-ics "BEGIN:VCALENDAR
+VERSION:2.0
+BEGIN:VEVENT
+DTSTART:20251201T100000Z
+DTEND:20251201T110000Z
+RRULE:FREQ=HOURLY;COUNT=5
+SUMMARY:Unsupported Hourly Event
+UID:unsupported@example.com
+END:VEVENT
+END:VCALENDAR")
+ (org-output (calendar-sync--parse-ics unsupported-ics)))
+ ;; Should handle gracefully (may return nil or skip the event)
+ ;; The key is it shouldn't crash
+ (should (or (null org-output)
+ (stringp org-output))))
+ (test-integration-recurring-events-teardown)))
+
+(provide 'test-integration-recurring-events)
+;;; test-integration-recurring-events.el ends here