From 09cfcfd6826f9bc8b379dde88e1d9ca719c1bdb2 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Tue, 3 Feb 2026 08:09:30 -0600 Subject: 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. --- tests/test-calendar-sync--filter-exdates.el | 121 ++++++++++++++++++++++++++++ 1 file changed, 121 insertions(+) create mode 100644 tests/test-calendar-sync--filter-exdates.el (limited to 'tests/test-calendar-sync--filter-exdates.el') 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 -- cgit v1.2.3