aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/test-calendar-sync--apply-recurrence-exceptions.el31
-rw-r--r--tests/test-calendar-sync--expand-monthly.el99
-rw-r--r--tests/test-calendar-sync--expand-yearly.el26
-rw-r--r--tests/test-calendar-sync--nth-weekday-of-month.el67
-rw-r--r--tests/test-calendar-sync--parse-byday-entry.el41
-rw-r--r--tests/test-calendar-sync--parse-event.el32
-rw-r--r--tests/test-calendar-sync--parse-exception-event.el28
-rw-r--r--tests/test-calendar-sync--parse-rrule.el21
8 files changed, 345 insertions, 0 deletions
diff --git a/tests/test-calendar-sync--apply-recurrence-exceptions.el b/tests/test-calendar-sync--apply-recurrence-exceptions.el
index 7711c5cb..999296fb 100644
--- a/tests/test-calendar-sync--apply-recurrence-exceptions.el
+++ b/tests/test-calendar-sync--apply-recurrence-exceptions.el
@@ -153,5 +153,36 @@ NEW-* values are the rescheduled time."
(and (= 1 (length result))
(= 9 (nth 3 (plist-get (car result) :start)))))))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-normal-cancelled-removes ()
+ "Normal: a cancelled exception removes its occurrence instead of overriding it."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 10 9 0 "Weekly Meeting")
+ (test-make-occurrence 2026 2 17 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Feb 10 is cancelled.
+ (puthash "test-event@google.com"
+ (list (append (test-make-exception-data 2026 2 10 9 0 2026 2 10 9 0)
+ '(:cancelled t)))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 2 (length result)))
+ ;; Feb 3 and Feb 17 remain; Feb 10 is gone.
+ (should (equal '(3 17)
+ (mapcar (lambda (occ) (nth 2 (plist-get occ :start))) result))))))
+
+(ert-deftest test-calendar-sync--apply-recurrence-exceptions-boundary-cancelled-no-match-keeps-all ()
+ "Boundary: a cancelled exception that matches nothing removes nothing."
+ (let* ((occurrences (list (test-make-occurrence 2026 2 3 9 0 "Weekly Meeting")))
+ (exceptions (make-hash-table :test 'equal)))
+ ;; Cancelled exception targets a different date.
+ (puthash "test-event@google.com"
+ (list (append (test-make-exception-data 2026 3 3 9 0 2026 3 3 9 0)
+ '(:cancelled t)))
+ exceptions)
+ (let ((result (calendar-sync--apply-recurrence-exceptions occurrences exceptions)))
+ (should (= 1 (length result))))))
+
(provide 'test-calendar-sync--apply-recurrence-exceptions)
;;; test-calendar-sync--apply-recurrence-exceptions.el ends here
diff --git a/tests/test-calendar-sync--expand-monthly.el b/tests/test-calendar-sync--expand-monthly.el
index 3dc1f2dc..d2d25a70 100644
--- a/tests/test-calendar-sync--expand-monthly.el
+++ b/tests/test-calendar-sync--expand-monthly.el
@@ -170,5 +170,104 @@
(occurrences (calendar-sync--expand-monthly base-event rrule range)))
(should (= (length occurrences) 3))))
+;;; BYDAY (nth weekday) Cases
+;;
+;; Fixed dates are deterministic here: the expansion range is an explicit
+;; parameter, not derived from the current time, so these never age out.
+
+(defun test-calendar-sync--expand-monthly-range-2026 ()
+ "Fixed expansion range covering calendar year 2026."
+ (list (encode-time 0 0 0 1 1 2026) (encode-time 0 0 0 31 12 2026)))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-second-wednesday ()
+ "Normal: BYDAY=2WE lands on the 2nd Wednesday of each month, not the
+day-of-month of DTSTART. This is the live Craig/Ryan series shape."
+ (let* ((base-event (list :summary "2nd Wednesday"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE")))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 14) (2 11) (3 11) (4 8) (5 13) (6 10)
+ (7 8) (8 12) (9 9) (10 14) (11 11) (12 9))))
+ ;; Every occurrence is a Wednesday (weekday 3), never a fixed day-of-month.
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= 3 (calendar-sync--date-weekday
+ (list (nth 0 s) (nth 1 s) (nth 2 s)))))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-last-tuesday ()
+ "Normal: BYDAY=-1TU lands on the last Tuesday of each month."
+ (let* ((base-event (list :summary "Last Tuesday"
+ :start '(2026 1 27 9 0)
+ :end '(2026 1 27 10 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("-1TU") :count 3))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 27) (2 24) (3 31))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-bysetpos-second-sunday ()
+ "Normal: BYDAY=SU with BYSETPOS=2 lands on the 2nd Sunday (Proton shape)."
+ (let* ((base-event (list :summary "2nd Sunday"
+ :start '(2026 1 11 8 0)
+ :end '(2026 1 11 9 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("SU") :bysetpos 2 :count 3))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ (should (equal days '((1 11) (2 8) (3 8))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-until-inclusive-and-reached ()
+ "Boundary: with BYDAY, UNTIL is inclusive and the series reaches it.
+Upper bound alone can't catch a dropped final occurrence -- assert both."
+ (let* ((base-event (list :summary "Bounded"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE")
+ :until '(2026 5 13)))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (last-start (plist-get (car (last occurrences)) :start)))
+ (should (= (length occurrences) 5))
+ ;; Reach: the occurrence landing exactly on UNTIL is kept.
+ (should (equal (list (nth 0 last-start) (nth 1 last-start) (nth 2 last-start))
+ '(2026 5 13)))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-count-limits ()
+ "Boundary: COUNT caps a BYDAY series."
+ (let* ((base-event (list :summary "Counted"
+ :start '(2026 1 14 10 0)
+ :end '(2026 1 14 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("2WE") :count 4))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 4))))
+
+(ert-deftest test-calendar-sync--expand-monthly-byday-fifth-weekday-skips-short-months ()
+ "Boundary: BYDAY=5WE only lands in months that have a 5th Wednesday."
+ (let* ((base-event (list :summary "5th Wednesday"
+ :start '(2026 4 29 10 0)
+ :end '(2026 4 29 11 0)))
+ (rrule (list :freq 'monthly :interval 1 :byday '("5WE")))
+ (range (test-calendar-sync--expand-monthly-range-2026))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 1 s) (nth 2 s))))
+ occurrences)))
+ ;; 2026 months (Apr on) with a 5th Wednesday: Apr 29, Jul 29, Sep 30, Dec 30.
+ (should (equal days '((4 29) (7 29) (9 30) (12 30))))))
+
(provide 'test-calendar-sync--expand-monthly)
;;; test-calendar-sync--expand-monthly.el ends here
diff --git a/tests/test-calendar-sync--expand-yearly.el b/tests/test-calendar-sync--expand-yearly.el
index ad9b8f27..c636e54a 100644
--- a/tests/test-calendar-sync--expand-yearly.el
+++ b/tests/test-calendar-sync--expand-yearly.el
@@ -175,5 +175,31 @@
(occurrences (calendar-sync--expand-yearly base-event rrule range)))
(should (= (length occurrences) 2))))
+;;; BYMONTH + BYDAY (nth weekday) Cases
+;;
+;; Fixed dates are deterministic here: the expansion range is an explicit
+;; parameter, not derived from the current time.
+
+(ert-deftest test-calendar-sync--expand-yearly-bymonth-byday-nth-weekday ()
+ "Normal: FREQ=YEARLY;BYMONTH=3;BYDAY=2SU tracks the 2nd Sunday of March
+each year (the DST clock-change shape), not DTSTART's calendar date."
+ (let* ((base-event (list :summary "Clocks change"
+ :start '(2026 3 8 2 0)
+ :end '(2026 3 8 3 0)))
+ (rrule (list :freq 'yearly :interval 1 :bymonth 3 :byday '("2SU")))
+ (range (list (encode-time 0 0 0 1 1 2026)
+ (encode-time 0 0 0 31 12 2027)))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range))
+ (days (mapcar (lambda (occ)
+ (let ((s (plist-get occ :start)))
+ (list (nth 0 s) (nth 1 s) (nth 2 s))))
+ occurrences)))
+ ;; 2nd Sunday of March: 2026-03-08, 2027-03-14 -- different day-of-month.
+ (should (equal days '((2026 3 8) (2027 3 14))))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= 7 (calendar-sync--date-weekday
+ (list (nth 0 s) (nth 1 s) (nth 2 s)))))))))
+
(provide 'test-calendar-sync--expand-yearly)
;;; test-calendar-sync--expand-yearly.el ends here
diff --git a/tests/test-calendar-sync--nth-weekday-of-month.el b/tests/test-calendar-sync--nth-weekday-of-month.el
new file mode 100644
index 00000000..afb0bd35
--- /dev/null
+++ b/tests/test-calendar-sync--nth-weekday-of-month.el
@@ -0,0 +1,67 @@
+;;; test-calendar-sync--nth-weekday-of-month.el --- Tests for calendar-sync--nth-weekday-of-month -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the nth-weekday-of-month helper backing monthly/yearly BYDAY
+;; expansion. Fixed dates are safe here: the function is pure calendar
+;; arithmetic with no relation to the current time.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-second-wednesday ()
+ "Normal: 2nd Wednesday of Jan 2026 is the 14th."
+ ;; Jan 2026: Jan 1 is a Thursday; Wednesdays fall on 7, 14, 21, 28.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 1 3 2) 14)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-first-monday ()
+ "Normal: 1st Monday of Feb 2026 is the 2nd."
+ ;; Feb 2026: Feb 1 is a Sunday; Mondays fall on 2, 9, 16, 23.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 2 1 1) 2)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-last-tuesday ()
+ "Normal: last Tuesday of Mar 2026 is the 31st (negative ordinal)."
+ ;; Mar 2026: Tuesdays fall on 3, 10, 17, 24, 31.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 3 2 -1) 31)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-normal-second-to-last-friday ()
+ "Normal: -2 ordinal picks the second-to-last Friday."
+ ;; May 2026: Fridays fall on 1, 8, 15, 22, 29.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 5 5 -2) 22)))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-fifth-occurrence-exists ()
+ "Boundary: 5th Friday exists in May 2026."
+ (should (= (calendar-sync--nth-weekday-of-month 2026 5 5 5) 29)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-fifth-occurrence-missing ()
+ "Boundary: 5th Wednesday of Feb 2026 does not exist -- returns nil."
+ ;; Feb 2026 has four Wednesdays (4, 11, 18, 25).
+ (should (null (calendar-sync--nth-weekday-of-month 2026 2 3 5))))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-first-day-is-target ()
+ "Boundary: the 1st of the month itself is the 1st occurrence."
+ ;; Apr 2026: Apr 1 is a Wednesday.
+ (should (= (calendar-sync--nth-weekday-of-month 2026 4 3 1) 1)))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-boundary-leap-february ()
+ "Boundary: leap-year February (2028) handled -- last Tuesday is the 29th."
+ ;; Feb 2028: Feb 29 exists and is a Tuesday.
+ (should (= (calendar-sync--nth-weekday-of-month 2028 2 2 -1) 29)))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-error-zero-ordinal-nil ()
+ "Error: ordinal 0 is meaningless -- returns nil."
+ (should (null (calendar-sync--nth-weekday-of-month 2026 1 3 0))))
+
+(ert-deftest test-calendar-sync--nth-weekday-of-month-error-out-of-range-negative-nil ()
+ "Error: -6th occurrence never exists in a month -- returns nil."
+ (should (null (calendar-sync--nth-weekday-of-month 2026 1 3 -6))))
+
+(provide 'test-calendar-sync--nth-weekday-of-month)
+;;; test-calendar-sync--nth-weekday-of-month.el ends here
diff --git a/tests/test-calendar-sync--parse-byday-entry.el b/tests/test-calendar-sync--parse-byday-entry.el
new file mode 100644
index 00000000..4a9b4ef5
--- /dev/null
+++ b/tests/test-calendar-sync--parse-byday-entry.el
@@ -0,0 +1,41 @@
+;;; test-calendar-sync--parse-byday-entry.el --- Tests for calendar-sync--parse-byday-entry -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for parsing a single RRULE BYDAY entry ("2WE", "-1TU", "SU") into
+;; an (ordinal . weekday-number) cons. Ordinal is nil for a bare weekday.
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-positive-ordinal ()
+ "Normal: \"2WE\" parses to ordinal 2, Wednesday (3)."
+ (should (equal (calendar-sync--parse-byday-entry "2WE") '(2 . 3))))
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-negative-ordinal ()
+ "Normal: \"-1TU\" parses to ordinal -1, Tuesday (2)."
+ (should (equal (calendar-sync--parse-byday-entry "-1TU") '(-1 . 2))))
+
+(ert-deftest test-calendar-sync--parse-byday-entry-normal-bare-weekday ()
+ "Normal: \"SU\" parses to nil ordinal, Sunday (7)."
+ (should (equal (calendar-sync--parse-byday-entry "SU") '(nil . 7))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-boundary-double-digit-ordinal ()
+ "Boundary: \"53MO\" (yearly-scale ordinal) parses without truncation."
+ (should (equal (calendar-sync--parse-byday-entry "53MO") '(53 . 1))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--parse-byday-entry-error-garbage-nil ()
+ "Error: an unrecognizable entry returns nil."
+ (should (null (calendar-sync--parse-byday-entry "XX")))
+ (should (null (calendar-sync--parse-byday-entry "")))
+ (should (null (calendar-sync--parse-byday-entry nil))))
+
+(provide 'test-calendar-sync--parse-byday-entry)
+;;; test-calendar-sync--parse-byday-entry.el ends here
diff --git a/tests/test-calendar-sync--parse-event.el b/tests/test-calendar-sync--parse-event.el
index 9c343db2..b3f58ba2 100644
--- a/tests/test-calendar-sync--parse-event.el
+++ b/tests/test-calendar-sync--parse-event.el
@@ -78,5 +78,37 @@
(let ((vevent "BEGIN:VEVENT\nSUMMARY:Orphan\nEND:VEVENT"))
(should (null (calendar-sync--parse-event vevent)))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--parse-event-error-cancelled-returns-nil ()
+ "Error: a STATUS:CANCELLED event returns nil -- cancelled events don't render."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Cancelled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:CANCELLED\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-boundary-cancelled-case-insensitive ()
+ "Boundary: STATUS value matching is case-insensitive."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Cancelled Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:Cancelled\n"
+ "END:VEVENT")))
+ (should (null (calendar-sync--parse-event vevent)))))
+
+(ert-deftest test-calendar-sync--parse-event-normal-confirmed-still-parses ()
+ "Normal: STATUS:CONFIRMED events still parse."
+ (let* ((start (test-calendar-sync-time-days-from-now 5 14 0))
+ (vevent (concat "BEGIN:VEVENT\n"
+ "SUMMARY:Confirmed Meeting\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "STATUS:CONFIRMED\n"
+ "END:VEVENT")))
+ (should (calendar-sync--parse-event vevent))))
+
(provide 'test-calendar-sync--parse-event)
;;; test-calendar-sync--parse-event.el ends here
diff --git a/tests/test-calendar-sync--parse-exception-event.el b/tests/test-calendar-sync--parse-exception-event.el
index a26a7418..1c9411f3 100644
--- a/tests/test-calendar-sync--parse-exception-event.el
+++ b/tests/test-calendar-sync--parse-exception-event.el
@@ -82,5 +82,33 @@ than a half-built plist."
"END:VEVENT")))
(should-not (calendar-sync--parse-exception-event event))))
+;;; STATUS:CANCELLED Cases
+
+(ert-deftest test-calendar-sync--parse-exception-event-normal-cancelled-flag ()
+ "Normal: a STATUS:CANCELLED override carries :cancelled t, so the
+matching occurrence can be removed rather than overridden."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (event (concat "BEGIN:VEVENT\n"
+ "UID:override@google.com\n"
+ "RECURRENCE-ID:20260203T090000Z\n"
+ "SUMMARY:Craig / Ryan\n"
+ "STATUS:CANCELLED\n"
+ "DTSTART:" (test-calendar-sync-ics-datetime start) "\n"
+ "DTEND:" (test-calendar-sync-ics-datetime end) "\n"
+ "END:VEVENT"))
+ (plist (calendar-sync--parse-exception-event event)))
+ (should plist)
+ (should (plist-get plist :cancelled))))
+
+(ert-deftest test-calendar-sync--parse-exception-event-boundary-no-status-not-cancelled ()
+ "Boundary: an override without STATUS is not cancelled."
+ (let* ((start (test-calendar-sync-time-days-from-now 7 10 0))
+ (end (test-calendar-sync-time-days-from-now 7 11 0))
+ (plist (calendar-sync--parse-exception-event
+ (test-cs-parse-exc--override-event start end))))
+ (should plist)
+ (should-not (plist-get plist :cancelled))))
+
(provide 'test-calendar-sync--parse-exception-event)
;;; test-calendar-sync--parse-exception-event.el ends here
diff --git a/tests/test-calendar-sync--parse-rrule.el b/tests/test-calendar-sync--parse-rrule.el
index 099e4e44..2668c1ac 100644
--- a/tests/test-calendar-sync--parse-rrule.el
+++ b/tests/test-calendar-sync--parse-rrule.el
@@ -206,5 +206,26 @@
(should (= (plist-get result :count) 10)))
(test-calendar-sync--parse-rrule-teardown)))
+;;; BYSETPOS / BYMONTH Cases
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-bysetpos-returns-number ()
+ "Normal: BYSETPOS parses to a number (Proton emits BYDAY=SU;BYSETPOS=2)."
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;BYDAY=SU;BYSETPOS=2")))
+ (should (eq (plist-get result :freq) 'monthly))
+ (should (equal (plist-get result :byday) '("SU")))
+ (should (= (plist-get result :bysetpos) 2))))
+
+(ert-deftest test-calendar-sync--parse-rrule-normal-bymonth-returns-number ()
+ "Normal: BYMONTH parses to a number (yearly nth-weekday rules carry it)."
+ (let ((result (calendar-sync--parse-rrule "FREQ=YEARLY;BYMONTH=3;BYDAY=2SU")))
+ (should (eq (plist-get result :freq) 'yearly))
+ (should (= (plist-get result :bymonth) 3))
+ (should (equal (plist-get result :byday) '("2SU")))))
+
+(ert-deftest test-calendar-sync--parse-rrule-boundary-negative-bysetpos ()
+ "Boundary: negative BYSETPOS (last matching day) parses."
+ (let ((result (calendar-sync--parse-rrule "FREQ=MONTHLY;BYDAY=FR;BYSETPOS=-1")))
+ (should (= (plist-get result :bysetpos) -1))))
+
(provide 'test-calendar-sync--parse-rrule)
;;; test-calendar-sync--parse-rrule.el ends here