diff options
| author | Craig Jennings <c@cjennings.net> | 2026-02-03 08:09:30 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2026-02-03 08:09:30 -0600 |
| commit | 09cfcfd6826f9bc8b379dde88e1d9ca719c1bdb2 (patch) | |
| tree | 6f062ccc52cf5c334380340ca04d280da3b1df28 /tests | |
| parent | 03a9247ba4be4803b995510e0e6980254034d7ba (diff) | |
feat(calendar-sync): add EXDATE support for excluded recurring event dates
When someone deletes a single instance of a recurring meeting in Google
Calendar, the calendar exports an EXDATE property marking that date as
excluded. Previously, calendar-sync expanded the RRULE without filtering
out these excluded dates, causing deleted instances to appear in org output.
New functions:
- calendar-sync--get-exdates: Extract all EXDATE values from event
- calendar-sync--get-exdate-line: Get full EXDATE line with parameters
- calendar-sync--parse-exdate: Parse EXDATE into datetime list
- calendar-sync--collect-exdates: Collect excluded dates with TZ conversion
- calendar-sync--exdate-matches-p: Check if occurrence matches an EXDATE
- calendar-sync--filter-exdates: Filter out excluded dates from occurrences
Modified calendar-sync--expand-recurring-event to collect and filter
EXDATEs after RRULE expansion.
Includes 47 new tests covering extraction, parsing, collection, filtering,
and integration with RECURRENCE-ID exceptions.
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-calendar-sync--collect-exdates.el | 147 | ||||
| -rw-r--r-- | tests/test-calendar-sync--filter-exdates.el | 121 | ||||
| -rw-r--r-- | tests/test-calendar-sync--get-exdates.el | 121 | ||||
| -rw-r--r-- | tests/test-calendar-sync--parse-exdate.el | 80 | ||||
| -rw-r--r-- | tests/test-integration-calendar-sync-exdate.el | 207 |
5 files changed, 676 insertions, 0 deletions
diff --git a/tests/test-calendar-sync--collect-exdates.el b/tests/test-calendar-sync--collect-exdates.el new file mode 100644 index 00000000..b70277e1 --- /dev/null +++ b/tests/test-calendar-sync--collect-exdates.el @@ -0,0 +1,147 @@ +;;; test-calendar-sync--collect-exdates.el --- Tests for EXDATE collection -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--collect-exdates function. +;; Tests collection of all excluded dates from an event, handling timezone conversion. +;; Following quality-engineer.org guidelines: one function per file. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name))) +(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name))) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--collect-exdates-normal-single-returns-list () + "Test collecting single EXDATE returns list with one parsed value." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (listp result)) + (should (= 1 (length result))) + (should (equal '(2026 2 10 13 0) (car result)))))) + +(ert-deftest test-calendar-sync--collect-exdates-normal-multiple-returns-all () + "Test collecting multiple EXDATEs returns all parsed values." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T130000 +EXDATE:20260217T130000 +EXDATE:20260224T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (= 3 (length result))) + (should (member '(2026 2 10 13 0) result)) + (should (member '(2026 2 17 13 0) result)) + (should (member '(2026 2 24 13 0) result))))) + +(ert-deftest test-calendar-sync--collect-exdates-normal-tzid-converts-to-local () + "Test that TZID-qualified EXDATEs are converted to local time." + ;; Use a timezone different from local to verify conversion + ;; We'll use Europe/London and check that conversion happens + (let ((event "BEGIN:VEVENT +DTSTART;TZID=Europe/London:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;TZID=Europe/London:20260210T130000 +SUMMARY:London Meeting +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (= 1 (length result))) + ;; Result should be a valid datetime list (conversion may differ based on local TZ) + (let ((parsed (car result))) + (should (= 5 (length parsed))) + (should (numberp (nth 0 parsed))) ; year + (should (numberp (nth 1 parsed))) ; month + (should (numberp (nth 2 parsed))) ; day + (should (numberp (nth 3 parsed))) ; hour + (should (numberp (nth 4 parsed))))))) ; minute + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--collect-exdates-boundary-no-exdates-returns-empty () + "Test that event without EXDATE returns empty list." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (listp result)) + (should (= 0 (length result)))))) + +(ert-deftest test-calendar-sync--collect-exdates-boundary-utc-converts-to-local () + "Test that UTC (Z suffix) EXDATEs are converted to local time." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T180000Z +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T180000Z +SUMMARY:UTC Meeting +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (= 1 (length result))) + ;; Result should be converted to local time + (let ((parsed (car result))) + (should (= 5 (length parsed))) + ;; Date should be valid + (should (numberp (nth 0 parsed))))))) + +(ert-deftest test-calendar-sync--collect-exdates-boundary-mixed-formats-handles-all () + "Test handling mix of TZID, UTC, and local time EXDATEs." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T130000 +EXDATE:20260217T180000Z +SUMMARY:Mixed Meeting +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (= 2 (length result))) + ;; Both should be valid parsed datetimes + (dolist (parsed result) + (should (= 5 (length parsed))) + (should (numberp (nth 0 parsed))))))) + +(ert-deftest test-calendar-sync--collect-exdates-boundary-date-only-returns-date () + "Test collecting all-day EXDATE returns date with nil for time." + (let ((event "BEGIN:VEVENT +DTSTART;VALUE=DATE:20260203 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;VALUE=DATE:20260210 +SUMMARY:All Day Event +END:VEVENT")) + (let ((result (calendar-sync--collect-exdates event))) + (should (= 1 (length result))) + (let ((parsed (car result))) + (should (equal '(2026 2 10 nil nil) parsed)))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--collect-exdates-error-empty-string-returns-empty () + "Test that empty string returns empty list." + (let ((result (calendar-sync--collect-exdates ""))) + (should (listp result)) + (should (= 0 (length result))))) + +(ert-deftest test-calendar-sync--collect-exdates-error-nil-returns-empty () + "Test that nil input returns empty list." + (let ((result (calendar-sync--collect-exdates nil))) + (should (listp result)) + (should (= 0 (length result))))) + +(ert-deftest test-calendar-sync--collect-exdates-error-malformed-returns-empty () + "Test that malformed event returns empty list." + (let ((result (calendar-sync--collect-exdates "not a vevent"))) + (should (listp result)) + (should (= 0 (length result))))) + +(provide 'test-calendar-sync--collect-exdates) +;;; test-calendar-sync--collect-exdates.el ends here diff --git a/tests/test-calendar-sync--filter-exdates.el b/tests/test-calendar-sync--filter-exdates.el new file mode 100644 index 00000000..b0f2d6a4 --- /dev/null +++ b/tests/test-calendar-sync--filter-exdates.el @@ -0,0 +1,121 @@ +;;; test-calendar-sync--filter-exdates.el --- Tests for EXDATE filtering -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--filter-exdates function. +;; Tests filtering occurrences list to remove EXDATE matches. +;; Following quality-engineer.org guidelines: one function per file. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name))) +(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name))) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--filter-exdates-normal-single-match-removes-one () + "Test that single matching EXDATE removes one occurrence." + (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting") + (list :start '(2026 2 10 13 0) :summary "Meeting") + (list :start '(2026 2 17 13 0) :summary "Meeting"))) + (exdates '((2026 2 10 13 0)))) + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + (should (= 2 (length result))) + ;; Feb 10 should be removed + (should-not (cl-find-if (lambda (occ) + (equal '(2026 2 10 13 0) (plist-get occ :start))) + result)) + ;; Feb 3 and Feb 17 should remain + (should (cl-find-if (lambda (occ) + (equal '(2026 2 3 13 0) (plist-get occ :start))) + result)) + (should (cl-find-if (lambda (occ) + (equal '(2026 2 17 13 0) (plist-get occ :start))) + result))))) + +(ert-deftest test-calendar-sync--filter-exdates-normal-multiple-matches-removes-all () + "Test that multiple EXDATEs remove all matching occurrences." + (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting") + (list :start '(2026 2 10 13 0) :summary "Meeting") + (list :start '(2026 2 17 13 0) :summary "Meeting") + (list :start '(2026 2 24 13 0) :summary "Meeting"))) + (exdates '((2026 2 10 13 0) (2026 2 24 13 0)))) + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + (should (= 2 (length result))) + ;; Feb 10 and Feb 24 should be removed + (should-not (cl-find-if (lambda (occ) + (equal '(2026 2 10 13 0) (plist-get occ :start))) + result)) + (should-not (cl-find-if (lambda (occ) + (equal '(2026 2 24 13 0) (plist-get occ :start))) + result))))) + +(ert-deftest test-calendar-sync--filter-exdates-normal-preserves-non-matches () + "Test that non-matching occurrences are preserved." + (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting") + (list :start '(2026 2 10 13 0) :summary "Meeting"))) + (exdates '((2026 3 15 13 0)))) ; No match + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + (should (= 2 (length result))) + ;; Both should remain + (should (cl-find-if (lambda (occ) + (equal '(2026 2 3 13 0) (plist-get occ :start))) + result)) + (should (cl-find-if (lambda (occ) + (equal '(2026 2 10 13 0) (plist-get occ :start))) + result))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--filter-exdates-boundary-empty-exdates-returns-all () + "Test that empty exdates list returns all occurrences." + (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting") + (list :start '(2026 2 10 13 0) :summary "Meeting"))) + (exdates '())) + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + (should (= 2 (length result)))))) + +(ert-deftest test-calendar-sync--filter-exdates-boundary-empty-occurrences-returns-empty () + "Test that empty occurrences list returns empty." + (let ((occurrences '()) + (exdates '((2026 2 10 13 0)))) + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + (should (= 0 (length result)))))) + +(ert-deftest test-calendar-sync--filter-exdates-boundary-all-excluded-returns-empty () + "Test that when all occurrences are excluded, returns empty." + (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting") + (list :start '(2026 2 10 13 0) :summary "Meeting"))) + (exdates '((2026 2 3 13 0) (2026 2 10 13 0)))) + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + (should (= 0 (length result)))))) + +(ert-deftest test-calendar-sync--filter-exdates-boundary-date-only-matches-any-time () + "Test that date-only EXDATE (nil hour/minute) matches any time on that day." + (let ((occurrences (list (list :start '(2026 2 3 9 0) :summary "Morning") + (list :start '(2026 2 3 13 0) :summary "Afternoon") + (list :start '(2026 2 10 13 0) :summary "Next Week"))) + (exdates '((2026 2 3 nil nil)))) ; Date-only exclusion + (let ((result (calendar-sync--filter-exdates occurrences exdates))) + ;; Both Feb 3 occurrences should be removed + (should (= 1 (length result))) + (should (equal '(2026 2 10 13 0) (plist-get (car result) :start)))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--filter-exdates-error-nil-occurrences-handles-gracefully () + "Test that nil occurrences handles gracefully." + (let ((result (calendar-sync--filter-exdates nil '((2026 2 10 13 0))))) + (should (listp result)) + (should (= 0 (length result))))) + +(ert-deftest test-calendar-sync--filter-exdates-error-nil-exdates-returns-occurrences () + "Test that nil exdates returns original occurrences." + (let ((occurrences (list (list :start '(2026 2 3 13 0) :summary "Meeting")))) + (let ((result (calendar-sync--filter-exdates occurrences nil))) + (should (= 1 (length result)))))) + +(provide 'test-calendar-sync--filter-exdates) +;;; test-calendar-sync--filter-exdates.el ends here diff --git a/tests/test-calendar-sync--get-exdates.el b/tests/test-calendar-sync--get-exdates.el new file mode 100644 index 00000000..3283bbae --- /dev/null +++ b/tests/test-calendar-sync--get-exdates.el @@ -0,0 +1,121 @@ +;;; test-calendar-sync--get-exdates.el --- Tests for EXDATE extraction -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--get-exdates function. +;; Tests extraction of EXDATE properties from VEVENT strings. +;; Following quality-engineer.org guidelines: one function per file. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name))) +(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name))) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--get-exdates-normal-single-returns-list () + "Test extracting single EXDATE returns list with one value." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (listp result)) + (should (= 1 (length result))) + (should (string= "20260210T130000" (car result)))))) + +(ert-deftest test-calendar-sync--get-exdates-normal-multiple-returns-all () + "Test extracting multiple EXDATEs returns all values." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T130000 +EXDATE:20260217T130000 +EXDATE:20260224T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 3 (length result))) + (should (member "20260210T130000" result)) + (should (member "20260217T130000" result)) + (should (member "20260224T130000" result))))) + +(ert-deftest test-calendar-sync--get-exdates-normal-with-tzid-returns-value () + "Test extracting EXDATE with TZID parameter extracts value correctly." + (let ((event "BEGIN:VEVENT +DTSTART;TZID=America/New_York:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;TZID=America/New_York:20260210T130000 +SUMMARY:Weekly Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 1 (length result))) + (should (string= "20260210T130000" (car result)))))) + +(ert-deftest test-calendar-sync--get-exdates-normal-with-z-suffix-returns-value () + "Test extracting UTC EXDATE with Z suffix." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T180000Z +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE:20260210T180000Z +SUMMARY:Weekly UTC Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 1 (length result))) + (should (string= "20260210T180000Z" (car result)))))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--get-exdates-boundary-no-exdate-returns-nil () + "Test that event without EXDATE returns nil." + (let ((event "BEGIN:VEVENT +DTSTART:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +SUMMARY:Weekly Meeting +END:VEVENT")) + (should (null (calendar-sync--get-exdates event))))) + +(ert-deftest test-calendar-sync--get-exdates-boundary-date-only-returns-value () + "Test extracting all-day EXDATE (date only, no time)." + (let ((event "BEGIN:VEVENT +DTSTART;VALUE=DATE:20260203 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;VALUE=DATE:20260210 +SUMMARY:All Day Event +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 1 (length result))) + (should (string= "20260210" (car result)))))) + +(ert-deftest test-calendar-sync--get-exdates-boundary-multiple-params-returns-value () + "Test extracting EXDATE with VALUE=DATE-TIME and TZID parameters." + (let ((event "BEGIN:VEVENT +DTSTART;TZID=America/Chicago:20260203T130000 +RRULE:FREQ=WEEKLY;BYDAY=TU +EXDATE;VALUE=DATE-TIME;TZID=America/Chicago:20260210T130000 +SUMMARY:Multi-param Meeting +END:VEVENT")) + (let ((result (calendar-sync--get-exdates event))) + (should (= 1 (length result))) + (should (string= "20260210T130000" (car result)))))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--get-exdates-error-empty-string-returns-nil () + "Test that empty string returns nil." + (should (null (calendar-sync--get-exdates "")))) + +(ert-deftest test-calendar-sync--get-exdates-error-nil-input-returns-nil () + "Test that nil input returns nil." + (should (null (calendar-sync--get-exdates nil)))) + +(ert-deftest test-calendar-sync--get-exdates-error-malformed-returns-nil () + "Test that malformed event string returns nil." + (should (null (calendar-sync--get-exdates "not a vevent")))) + +(provide 'test-calendar-sync--get-exdates) +;;; test-calendar-sync--get-exdates.el ends here diff --git a/tests/test-calendar-sync--parse-exdate.el b/tests/test-calendar-sync--parse-exdate.el new file mode 100644 index 00000000..2be3d3a1 --- /dev/null +++ b/tests/test-calendar-sync--parse-exdate.el @@ -0,0 +1,80 @@ +;;; test-calendar-sync--parse-exdate.el --- Tests for EXDATE parsing -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for calendar-sync--parse-exdate function. +;; Tests parsing EXDATE values into (year month day hour minute) lists. +;; Following quality-engineer.org guidelines: one function per file. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name))) +(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name))) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Normal Cases + +(ert-deftest test-calendar-sync--parse-exdate-normal-datetime-returns-list () + "Test parsing standard datetime format returns correct list." + (let ((result (calendar-sync--parse-exdate "20260203T130000"))) + (should (equal '(2026 2 3 13 0) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-normal-with-z-returns-list () + "Test parsing UTC datetime with Z suffix returns correct list." + (let ((result (calendar-sync--parse-exdate "20260203T180000Z"))) + (should (equal '(2026 2 3 18 0) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-normal-date-only-returns-list () + "Test parsing date-only format returns list with nil for time." + (let ((result (calendar-sync--parse-exdate "20260203"))) + (should (equal '(2026 2 3 nil nil) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-normal-with-seconds-returns-list () + "Test parsing datetime ignores seconds." + (let ((result (calendar-sync--parse-exdate "20260203T130045"))) + (should (equal '(2026 2 3 13 0) result)))) + +;;; Boundary Cases + +(ert-deftest test-calendar-sync--parse-exdate-boundary-midnight-returns-zero-hour () + "Test parsing midnight time returns hour=0." + (let ((result (calendar-sync--parse-exdate "20260203T000000"))) + (should (equal '(2026 2 3 0 0) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-boundary-end-of-day-returns-23 () + "Test parsing end-of-day time returns hour=23." + (let ((result (calendar-sync--parse-exdate "20260203T235900"))) + (should (equal '(2026 2 3 23 59) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-boundary-leap-year-feb29-returns-correct () + "Test parsing Feb 29 on leap year." + (let ((result (calendar-sync--parse-exdate "20280229T120000"))) + (should (equal '(2028 2 29 12 0) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-boundary-new-years-eve-returns-correct () + "Test parsing Dec 31." + (let ((result (calendar-sync--parse-exdate "20261231T235900"))) + (should (equal '(2026 12 31 23 59) result)))) + +(ert-deftest test-calendar-sync--parse-exdate-boundary-jan-1-returns-correct () + "Test parsing Jan 1." + (let ((result (calendar-sync--parse-exdate "20260101T000000"))) + (should (equal '(2026 1 1 0 0) result)))) + +;;; Error Cases + +(ert-deftest test-calendar-sync--parse-exdate-error-empty-returns-nil () + "Test that empty string returns nil." + (should (null (calendar-sync--parse-exdate "")))) + +(ert-deftest test-calendar-sync--parse-exdate-error-nil-returns-nil () + "Test that nil input returns nil." + (should (null (calendar-sync--parse-exdate nil)))) + +(ert-deftest test-calendar-sync--parse-exdate-error-invalid-format-returns-nil () + "Test that invalid format returns nil." + (should (null (calendar-sync--parse-exdate "not-a-date")))) + +(provide 'test-calendar-sync--parse-exdate) +;;; test-calendar-sync--parse-exdate.el ends here diff --git a/tests/test-integration-calendar-sync-exdate.el b/tests/test-integration-calendar-sync-exdate.el new file mode 100644 index 00000000..779e0297 --- /dev/null +++ b/tests/test-integration-calendar-sync-exdate.el @@ -0,0 +1,207 @@ +;;; test-integration-calendar-sync-exdate.el --- Integration tests for EXDATE support -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for end-to-end EXDATE filtering in calendar-sync. +;; Verifies that excluded dates don't appear in org output. +;; Following quality-engineer.org guidelines. + +;;; Code: + +(require 'ert) +(add-to-list 'load-path (expand-file-name "." (file-name-directory load-file-name))) +(add-to-list 'load-path (expand-file-name "../modules" (file-name-directory load-file-name))) +(require 'testutil-calendar-sync) +(require 'calendar-sync) + +;;; Helper Functions + +(defun test-integration-exdate--make-weekly-event-with-exdates (summary start exdates) + "Create a weekly recurring event with EXDATES. +START is (year month day hour minute). +EXDATES is list of (year month day hour minute) lists to exclude." + (let ((dtstart (test-calendar-sync-ics-datetime-local start)) + (exdate-lines (mapconcat + (lambda (ex) + (format "EXDATE:%s" (test-calendar-sync-ics-datetime-local ex))) + exdates + "\n"))) + (concat "BEGIN:VEVENT\n" + "UID:weekly-test@example.com\n" + "SUMMARY:" summary "\n" + "DTSTART:" dtstart "\n" + "DTEND:" (test-calendar-sync-ics-datetime-local + (list (nth 0 start) (nth 1 start) (nth 2 start) + (1+ (nth 3 start)) (nth 4 start))) "\n" + "RRULE:FREQ=WEEKLY;COUNT=4\n" + (when (> (length exdates) 0) + (concat exdate-lines "\n")) + "END:VEVENT"))) + +(defun test-integration-exdate--date-in-org-output-p (org-output date) + "Check if DATE appears in ORG-OUTPUT. +DATE is (year month day hour minute)." + (let ((date-str (format "%04d-%02d-%02d" (nth 0 date) (nth 1 date) (nth 2 date)))) + (string-match-p (regexp-quote date-str) org-output))) + +;;; Normal Cases + +(ert-deftest test-integration-exdate-single-excluded-date-not-in-output () + "Test that single excluded date doesn't appear in org output." + (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0)) + (week2 (test-calendar-sync-time-days-from-now 14 13 0)) + (week3 (test-calendar-sync-time-days-from-now 21 13 0)) + (week4 (test-calendar-sync-time-days-from-now 28 13 0)) + ;; Exclude week 2 + (event (test-integration-exdate--make-weekly-event-with-exdates + "Weekly Sync" + base-start + (list week2))) + (ics-content (test-calendar-sync-make-ics event)) + (org-output (calendar-sync--parse-ics ics-content))) + (should org-output) + ;; Week 2 should NOT be in output + (should-not (test-integration-exdate--date-in-org-output-p org-output week2)) + ;; Weeks 1, 3, 4 should be in output + (should (test-integration-exdate--date-in-org-output-p org-output base-start)) + (should (test-integration-exdate--date-in-org-output-p org-output week3)) + (should (test-integration-exdate--date-in-org-output-p org-output week4)))) + +(ert-deftest test-integration-exdate-multiple-excluded-dates-filtered () + "Test that multiple excluded dates are all filtered out." + (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0)) + (week2 (test-calendar-sync-time-days-from-now 14 13 0)) + (week3 (test-calendar-sync-time-days-from-now 21 13 0)) + (week4 (test-calendar-sync-time-days-from-now 28 13 0)) + ;; Exclude weeks 2 and 4 + (event (test-integration-exdate--make-weekly-event-with-exdates + "Weekly Sync" + base-start + (list week2 week4))) + (ics-content (test-calendar-sync-make-ics event)) + (org-output (calendar-sync--parse-ics ics-content))) + (should org-output) + ;; Weeks 2 and 4 should NOT be in output + (should-not (test-integration-exdate--date-in-org-output-p org-output week2)) + (should-not (test-integration-exdate--date-in-org-output-p org-output week4)) + ;; Weeks 1 and 3 should be in output + (should (test-integration-exdate--date-in-org-output-p org-output base-start)) + (should (test-integration-exdate--date-in-org-output-p org-output week3)))) + +(ert-deftest test-integration-exdate-non-excluded-dates-preserved () + "Test that non-excluded dates remain in output." + (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0)) + (week2 (test-calendar-sync-time-days-from-now 14 13 0)) + (week3 (test-calendar-sync-time-days-from-now 21 13 0)) + (week4 (test-calendar-sync-time-days-from-now 28 13 0)) + ;; Exclude only week 3 + (event (test-integration-exdate--make-weekly-event-with-exdates + "Weekly Sync" + base-start + (list week3))) + (ics-content (test-calendar-sync-make-ics event)) + (org-output (calendar-sync--parse-ics ics-content))) + (should org-output) + ;; Week 3 should NOT be in output + (should-not (test-integration-exdate--date-in-org-output-p org-output week3)) + ;; Weeks 1, 2, 4 should all be preserved + (should (test-integration-exdate--date-in-org-output-p org-output base-start)) + (should (test-integration-exdate--date-in-org-output-p org-output week2)) + (should (test-integration-exdate--date-in-org-output-p org-output week4)))) + +;;; Boundary Cases + +(ert-deftest test-integration-exdate-no-exdates-all-occurrences-present () + "Test that event without EXDATE shows all dates." + (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0)) + (week2 (test-calendar-sync-time-days-from-now 14 13 0)) + (week3 (test-calendar-sync-time-days-from-now 21 13 0)) + (week4 (test-calendar-sync-time-days-from-now 28 13 0)) + ;; No exclusions + (event (test-integration-exdate--make-weekly-event-with-exdates + "Weekly Sync" + base-start + '())) ; Empty exdates + (ics-content (test-calendar-sync-make-ics event)) + (org-output (calendar-sync--parse-ics ics-content))) + (should org-output) + ;; All weeks should be present + (should (test-integration-exdate--date-in-org-output-p org-output base-start)) + (should (test-integration-exdate--date-in-org-output-p org-output week2)) + (should (test-integration-exdate--date-in-org-output-p org-output week3)) + (should (test-integration-exdate--date-in-org-output-p org-output week4)))) + +(ert-deftest test-integration-exdate-with-recurrence-id-both-work () + "Test that EXDATE and RECURRENCE-ID work together correctly." + ;; Create event with: + ;; - Week 2 excluded via EXDATE (completely removed) + ;; - Week 3 rescheduled via RECURRENCE-ID (time changed) + (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0)) + (week2 (test-calendar-sync-time-days-from-now 14 13 0)) + (week3-original (test-calendar-sync-time-days-from-now 21 13 0)) + (week3-new (test-calendar-sync-time-days-from-now 21 15 0)) ; Moved to 3pm + (week4 (test-calendar-sync-time-days-from-now 28 13 0)) + ;; Main event with EXDATE for week 2 + (main-event (concat "BEGIN:VEVENT\n" + "UID:combined-test@example.com\n" + "SUMMARY:Combined Test\n" + "DTSTART:" (test-calendar-sync-ics-datetime-local base-start) "\n" + "DTEND:" (test-calendar-sync-ics-datetime-local + (list (nth 0 base-start) (nth 1 base-start) (nth 2 base-start) + (1+ (nth 3 base-start)) (nth 4 base-start))) "\n" + "RRULE:FREQ=WEEKLY;COUNT=4\n" + "EXDATE:" (test-calendar-sync-ics-datetime-local week2) "\n" + "END:VEVENT")) + ;; Exception event rescheduling week 3 + (exception-event (concat "BEGIN:VEVENT\n" + "UID:combined-test@example.com\n" + "RECURRENCE-ID:" (test-calendar-sync-ics-datetime-local week3-original) "\n" + "SUMMARY:Combined Test (Rescheduled)\n" + "DTSTART:" (test-calendar-sync-ics-datetime-local week3-new) "\n" + "DTEND:" (test-calendar-sync-ics-datetime-local + (list (nth 0 week3-new) (nth 1 week3-new) (nth 2 week3-new) + (1+ (nth 3 week3-new)) (nth 4 week3-new))) "\n" + "END:VEVENT")) + (ics-content (concat "BEGIN:VCALENDAR\n" + "VERSION:2.0\n" + main-event "\n" + exception-event "\n" + "END:VCALENDAR")) + (org-output (calendar-sync--parse-ics ics-content))) + (should org-output) + ;; Week 2 should be completely absent (EXDATE) + (should-not (test-integration-exdate--date-in-org-output-p org-output week2)) + ;; Week 3 should have the new time (15:00) + (should (string-match-p "15:00" org-output)) + ;; Weeks 1 and 4 should be present + (should (test-integration-exdate--date-in-org-output-p org-output base-start)) + (should (test-integration-exdate--date-in-org-output-p org-output week4)))) + +(ert-deftest test-integration-exdate-tzid-conversion-matches-correctly () + "Test that TZID-qualified EXDATE filters correctly after conversion." + ;; Use America/New_York timezone + (let* ((base-start (test-calendar-sync-time-days-from-now 7 13 0)) + (week2 (test-calendar-sync-time-days-from-now 14 13 0)) + (week3 (test-calendar-sync-time-days-from-now 21 13 0)) + (dtstart-val (format "%04d%02d%02dT%02d%02d00" + (nth 0 base-start) (nth 1 base-start) (nth 2 base-start) + (nth 3 base-start) (nth 4 base-start))) + (exdate-val (format "%04d%02d%02dT%02d%02d00" + (nth 0 week2) (nth 1 week2) (nth 2 week2) + (nth 3 week2) (nth 4 week2))) + (event (concat "BEGIN:VEVENT\n" + "UID:tzid-test@example.com\n" + "SUMMARY:TZID Test\n" + "DTSTART;TZID=America/New_York:" dtstart-val "\n" + "RRULE:FREQ=WEEKLY;COUNT=3\n" + "EXDATE;TZID=America/New_York:" exdate-val "\n" + "END:VEVENT")) + (ics-content (test-calendar-sync-make-ics event)) + (org-output (calendar-sync--parse-ics ics-content))) + (should org-output) + ;; The EXDATE should have been converted to local time and filtered + ;; We can't check exact dates due to TZ conversion, but output should exist + ;; and have fewer occurrences than without EXDATE + (should (string-match-p "TZID Test" org-output)))) + +(provide 'test-integration-calendar-sync-exdate) +;;; test-integration-calendar-sync-exdate.el ends here |
