summaryrefslogtreecommitdiff
path: root/tests
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 /tests
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>
Diffstat (limited to 'tests')
-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
5 files changed, 1165 insertions, 0 deletions
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