diff options
| author | Craig Jennings <c@cjennings.net> | 2025-11-18 12:54:25 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-11-18 12:54:25 -0600 |
| commit | 2da0e8001ae390920f31f1db5e56aa2ed68bf1be (patch) | |
| tree | b9075be4442a0528f3eb64869fd4e585c7804219 | |
| parent | 342e7df14b688ca995c831a68531550ad59e2cc2 (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.el | 420 | ||||
| -rw-r--r-- | tests/test-calendar-sync--expand-weekly.el | 272 | ||||
| -rw-r--r-- | tests/test-calendar-sync--get-property.el | 180 | ||||
| -rw-r--r-- | tests/test-calendar-sync--helpers.el | 157 | ||||
| -rw-r--r-- | tests/test-calendar-sync--parse-rrule.el | 209 | ||||
| -rw-r--r-- | tests/test-integration-recurring-events.el | 347 |
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 |
