summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-12-02 07:55:21 -0600
committerCraig Jennings <c@cjennings.net>2025-12-02 07:55:21 -0600
commitfac78a8fc92b27c37e678ee70824eb5f70ceee8b (patch)
tree570e67b7915d50c0831c3ce74ddfee873151b6d0 /tests
parent031b55dc59717dc437c9e46376c56344848c863f (diff)
feat(calendar-sync): multi-calendar support with property testsHEADmain
Added multi-URL calendar sync supporting Google and Proton calendars. Each calendar syncs to separate file with per-calendar state tracking. Added 13 property-based tests for RRULE expansion. Total: 150 tests passing.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-calendar-sync--expand-weekly.el6
-rw-r--r--tests/test-calendar-sync-properties.el239
-rw-r--r--tests/test-calendar-sync.el79
-rw-r--r--tests/test-integration-recurring-events.el2
-rw-r--r--tests/testutil-calendar-sync.el90
5 files changed, 398 insertions, 18 deletions
diff --git a/tests/test-calendar-sync--expand-weekly.el b/tests/test-calendar-sync--expand-weekly.el
index e4e5b738..fe333c98 100644
--- a/tests/test-calendar-sync--expand-weekly.el
+++ b/tests/test-calendar-sync--expand-weekly.el
@@ -122,7 +122,8 @@
(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))
+ ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
+ (until-date (test-calendar-sync-time-date-only 60))
(base-event (list :summary "Time-Limited Event"
:start start-date
:end end-date))
@@ -253,7 +254,8 @@
(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))
+ ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
+ (until-date (test-calendar-sync-time-date-only-ago 50))
(base-event (list :summary "Past Event"
:start start-date
:end end-date))
diff --git a/tests/test-calendar-sync-properties.el b/tests/test-calendar-sync-properties.el
new file mode 100644
index 00000000..6054fc5e
--- /dev/null
+++ b/tests/test-calendar-sync-properties.el
@@ -0,0 +1,239 @@
+;;; test-calendar-sync-properties.el --- Property-based tests for calendar-sync -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Property-based tests for RRULE expansion functions.
+;; These tests verify invariants hold across randomly generated inputs,
+;; complementing the example-based tests in other test files.
+;;
+;; Each test runs multiple trials with random parameters to explore
+;; the input space and find edge cases that example-based tests miss.
+;;
+;; Properties tested:
+;; 1. COUNT always limits total occurrences
+;; 2. UNTIL date bounds all occurrences
+;; 3. BYDAY constrains weekly occurrences to specified weekdays
+;; 4. INTERVAL creates correct spacing between occurrences
+;; 5. All occurrences fall within the date range
+;; 6. Expansion is deterministic (same inputs → same outputs)
+
+;;; Code:
+
+(require 'ert)
+(require 'calendar-sync)
+(require 'testutil-calendar-sync)
+
+(defconst test-calendar-sync-property-trials 30
+ "Number of random trials to run for each property test.
+Higher values give more confidence but slower tests.")
+
+;;; Property 1: COUNT Ceiling
+
+(ert-deftest test-calendar-sync-property-count-limits-daily ()
+ "Property: COUNT parameter limits daily occurrences.
+For any COUNT value N, expansion never produces more than N occurrences."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((count (1+ (random 20)))
+ (start-date (test-calendar-sync-random-future-date))
+ (base-event (list :summary "Daily Test" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :count count))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (<= (length occurrences) count)))))
+
+(ert-deftest test-calendar-sync-property-count-limits-weekly ()
+ "Property: COUNT parameter limits weekly occurrences.
+For any COUNT value N, expansion never produces more than N occurrences."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((count (1+ (random 20)))
+ (weekdays (test-calendar-sync-random-weekday-subset))
+ (start-date (test-calendar-sync-random-future-date))
+ (base-event (list :summary "Weekly Test" :start start-date))
+ (rrule (list :freq 'weekly :byday weekdays :interval 1 :count count))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ (should (<= (length occurrences) count)))))
+
+(ert-deftest test-calendar-sync-property-count-limits-monthly ()
+ "Property: COUNT parameter limits monthly occurrences."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((count (1+ (random 15)))
+ (start-date (test-calendar-sync-random-future-date))
+ (base-event (list :summary "Monthly Test" :start start-date))
+ (rrule (list :freq 'monthly :interval 1 :count count))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (<= (length occurrences) count)))))
+
+(ert-deftest test-calendar-sync-property-count-limits-yearly ()
+ "Property: COUNT parameter limits yearly occurrences."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((count (1+ (random 5)))
+ (start-date (test-calendar-sync-random-future-date))
+ (base-event (list :summary "Yearly Test" :start start-date))
+ (rrule (list :freq 'yearly :interval 1 :count count))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (should (<= (length occurrences) count)))))
+
+;;; Property 2: UNTIL Boundary
+
+(ert-deftest test-calendar-sync-property-until-bounds-daily ()
+ "Property: No daily occurrence starts on or after UNTIL date."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-days (+ 10 (random 60)))
+ ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
+ (until-date (test-calendar-sync-time-date-only until-days))
+ (base-event (list :summary "Until Test" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (dolist (occ occurrences)
+ (let ((occ-start (plist-get occ :start)))
+ (should (calendar-sync--before-date-p
+ (list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))
+ until-date)))))))
+
+(ert-deftest test-calendar-sync-property-until-bounds-weekly ()
+ "Property: No weekly occurrence starts on or after UNTIL date."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (until-days (+ 14 (random 60)))
+ ;; UNTIL must be date-only (3 elements) for calendar-sync--before-date-p
+ (until-date (test-calendar-sync-time-date-only until-days))
+ (weekdays (test-calendar-sync-random-weekday-subset))
+ (base-event (list :summary "Until Test" :start start-date))
+ (rrule (list :freq 'weekly :byday weekdays :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ (dolist (occ occurrences)
+ (let ((occ-start (plist-get occ :start)))
+ (should (calendar-sync--before-date-p
+ (list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start))
+ until-date)))))))
+
+;;; Property 3: BYDAY Constraint
+
+(ert-deftest test-calendar-sync-property-byday-constrains-weekdays ()
+ "Property: Weekly occurrences only fall on BYDAY weekdays.
+Every generated occurrence must be on one of the specified weekdays."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((weekdays (test-calendar-sync-random-weekday-subset))
+ (weekday-nums (mapcar #'calendar-sync--weekday-to-number weekdays))
+ (start-date (test-calendar-sync-random-future-date))
+ (base-event (list :summary "BYDAY Test" :start start-date))
+ (rrule (list :freq 'weekly :byday weekdays :interval 1))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ (dolist (occ occurrences)
+ (let* ((occ-start (plist-get occ :start))
+ (occ-weekday (calendar-sync--date-weekday
+ (list (nth 0 occ-start) (nth 1 occ-start) (nth 2 occ-start)))))
+ (should (member occ-weekday weekday-nums)))))))
+
+;;; Property 4: INTERVAL Spacing
+
+(ert-deftest test-calendar-sync-property-interval-spacing-daily ()
+ "Property: Daily occurrences are spaced INTERVAL days apart.
+Consecutive occurrences should be exactly INTERVAL days apart."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((interval (1+ (random 5)))
+ (start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Interval Test" :start start-date))
+ (rrule (list :freq 'daily :interval interval :count 10))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (when (> (length occurrences) 1)
+ (let ((dates (mapcar (lambda (o) (plist-get o :start)) occurrences)))
+ (cl-loop for i from 0 below (1- (length dates))
+ for d1 = (nth i dates)
+ for d2 = (nth (1+ i) dates)
+ do (let ((gap (round (test-calendar-sync-days-between d1 d2))))
+ (should (= interval gap)))))))))
+
+(ert-deftest test-calendar-sync-property-interval-spacing-weekly-single-day ()
+ "Property: Weekly single-day occurrences are spaced INTERVAL weeks apart."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((interval (1+ (random 3)))
+ (weekday (nth (random 7) '("MO" "TU" "WE" "TH" "FR" "SA" "SU")))
+ (start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Weekly Interval Test" :start start-date))
+ (rrule (list :freq 'weekly :byday (list weekday) :interval interval :count 8))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ (when (> (length occurrences) 1)
+ (let ((dates (mapcar (lambda (o) (plist-get o :start)) occurrences)))
+ (cl-loop for i from 0 below (1- (length dates))
+ for d1 = (nth i dates)
+ for d2 = (nth (1+ i) dates)
+ do (let ((gap (round (test-calendar-sync-days-between d1 d2))))
+ (should (= (* 7 interval) gap)))))))))
+
+;;; Property 5: Range Containment
+
+(ert-deftest test-calendar-sync-property-occurrences-within-range ()
+ "Property: All occurrences fall within the date range.
+No occurrence should be before range start or after range end."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((range-start-days (random 30))
+ (range-end-days (+ range-start-days 30 (random 60)))
+ (range (list (time-add (current-time) (* range-start-days 86400))
+ (time-add (current-time) (* range-end-days 86400))))
+ (start-date (test-calendar-sync-time-days-from-now (1+ range-start-days) 10 0))
+ (base-event (list :summary "Range Test" :start start-date))
+ (rrule (list :freq 'daily :interval 1))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (dolist (occ occurrences)
+ (let ((occ-start (plist-get occ :start)))
+ (should (calendar-sync--date-in-range-p occ-start range)))))))
+
+(ert-deftest test-calendar-sync-property-weekly-occurrences-within-range ()
+ "Property: All weekly occurrences fall within the date range."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((range (test-calendar-sync-narrow-range))
+ (start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (weekdays (test-calendar-sync-random-weekday-subset))
+ (base-event (list :summary "Range Test" :start start-date))
+ (rrule (list :freq 'weekly :byday weekdays :interval 1))
+ (occurrences (calendar-sync--expand-weekly base-event rrule range)))
+ (dolist (occ occurrences)
+ (let ((occ-start (plist-get occ :start)))
+ (should (calendar-sync--date-in-range-p occ-start range)))))))
+
+;;; Property 6: Determinism
+
+(ert-deftest test-calendar-sync-property-expansion-deterministic-daily ()
+ "Property: Same inputs produce identical outputs for daily expansion."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((interval (1+ (random 3)))
+ (count (+ 5 (random 10)))
+ (start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Determinism Test" :start start-date))
+ (rrule (list :freq 'daily :interval interval :count count))
+ (range (test-calendar-sync-wide-range))
+ (result1 (calendar-sync--expand-daily base-event rrule range))
+ (result2 (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length result1) (length result2)))
+ (cl-loop for o1 in result1
+ for o2 in result2
+ do (should (equal (plist-get o1 :start) (plist-get o2 :start)))))))
+
+(ert-deftest test-calendar-sync-property-expansion-deterministic-weekly ()
+ "Property: Same inputs produce identical outputs for weekly expansion."
+ (dotimes (_ test-calendar-sync-property-trials)
+ (let* ((interval (1+ (random 2)))
+ (weekdays (test-calendar-sync-random-weekday-subset))
+ (count (+ 5 (random 10)))
+ (start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Determinism Test" :start start-date))
+ (rrule (list :freq 'weekly :byday weekdays :interval interval :count count))
+ (range (test-calendar-sync-wide-range))
+ (result1 (calendar-sync--expand-weekly base-event rrule range))
+ (result2 (calendar-sync--expand-weekly base-event rrule range)))
+ (should (= (length result1) (length result2)))
+ (cl-loop for o1 in result1
+ for o2 in result2
+ do (should (equal (plist-get o1 :start) (plist-get o2 :start)))))))
+
+(provide 'test-calendar-sync-properties)
+;;; test-calendar-sync-properties.el ends here
diff --git a/tests/test-calendar-sync.el b/tests/test-calendar-sync.el
index 2de306b1..7cda5e73 100644
--- a/tests/test-calendar-sync.el
+++ b/tests/test-calendar-sync.el
@@ -452,7 +452,7 @@ Earlier events should appear first in the output."
(ics (test-calendar-sync-make-ics event))
(org-content (calendar-sync--parse-ics ics)))
(should org-content)
- (should (string-match-p "^# Google Calendar Events" org-content))
+ (should (string-match-p "^# Calendar Events" org-content))
(should (string-match-p "\\* Test Event" org-content))))
(ert-deftest test-calendar-sync--parse-ics-normal-multiple-events-all-included ()
@@ -577,7 +577,11 @@ Valid events should be parsed, invalid ones skipped."
(original-offset -21600) ; CST
(original-time (current-time))
(calendar-sync--last-timezone-offset original-offset)
- (calendar-sync--last-sync-time original-time))
+ (calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ ;; Set up per-calendar state
+ (puthash "test-calendar"
+ (list :status 'ok :last-sync original-time :last-error nil)
+ calendar-sync--calendar-states)
(unwind-protect
(progn
;; Save state
@@ -586,14 +590,17 @@ Valid events should be parsed, invalid ones skipped."
;; Clear variables
(setq calendar-sync--last-timezone-offset nil)
- (setq calendar-sync--last-sync-time nil)
+ (clrhash calendar-sync--calendar-states)
;; Load state
(calendar-sync--load-state)
;; Verify loaded correctly
(should (= original-offset calendar-sync--last-timezone-offset))
- (should (equal original-time calendar-sync--last-sync-time)))
+ (let ((loaded-state (gethash "test-calendar" calendar-sync--calendar-states)))
+ (should loaded-state)
+ (should (eq 'ok (plist-get loaded-state :status)))
+ (should (equal original-time (plist-get loaded-state :last-sync)))))
;; Cleanup
(when (file-exists-p test-state-file)
(delete-file test-state-file)))))
@@ -604,7 +611,7 @@ Valid events should be parsed, invalid ones skipped."
(test-state-file (expand-file-name "subdir/state.el" test-dir))
(calendar-sync--state-file test-state-file)
(calendar-sync--last-timezone-offset -21600)
- (calendar-sync--last-sync-time (current-time)))
+ (calendar-sync--calendar-states (make-hash-table :test 'equal)))
(unwind-protect
(progn
(calendar-sync--save-state)
@@ -618,35 +625,79 @@ Valid events should be parsed, invalid ones skipped."
"Test that load-state handles missing file gracefully."
(let ((calendar-sync--state-file "/nonexistent/path/state.el")
(calendar-sync--last-timezone-offset nil)
- (calendar-sync--last-sync-time nil))
+ (calendar-sync--calendar-states (make-hash-table :test 'equal)))
;; Should not error
(should-not (calendar-sync--load-state))
- ;; Variables should remain nil
+ ;; Variables should remain nil/empty
(should-not calendar-sync--last-timezone-offset)
- (should-not calendar-sync--last-sync-time)))
+ (should (= 0 (hash-table-count calendar-sync--calendar-states)))))
(ert-deftest test-calendar-sync--load-state-handles-corrupted-file ()
"Test that load-state handles corrupted state file gracefully."
(let* ((test-state-file (make-temp-file "calendar-sync-test-state"))
(calendar-sync--state-file test-state-file)
(calendar-sync--last-timezone-offset nil)
- (calendar-sync--last-sync-time nil))
+ (calendar-sync--calendar-states (make-hash-table :test 'equal)))
(unwind-protect
(progn
;; Write corrupted data
(with-temp-file test-state-file
(insert "this is not valid elisp {[}"))
- ;; Should handle error gracefully (catches error, logs message)
- ;; Returns error message string, not nil, but doesn't throw
- (should (stringp (calendar-sync--load-state)))
+ ;; Should handle error gracefully (catches error, logs message, returns nil)
+ ;; Function logs to *Messages* but returns nil (doesn't crash)
+ (should-not (calendar-sync--load-state))
- ;; Variables should remain nil (not loaded from corrupted file)
+ ;; Variables should remain nil/empty (not loaded from corrupted file)
(should-not calendar-sync--last-timezone-offset)
- (should-not calendar-sync--last-sync-time))
+ (should (= 0 (hash-table-count calendar-sync--calendar-states))))
;; Cleanup
(when (file-exists-p test-state-file)
(delete-file test-state-file)))))
+;;; Tests: Multi-Calendar Configuration
+
+(ert-deftest test-calendar-sync--calendar-names-returns-names ()
+ "Test that calendar-names returns list of calendar names."
+ (let ((calendar-sync-calendars
+ '((:name "cal1" :url "http://example.com/1" :file "/tmp/cal1.org")
+ (:name "cal2" :url "http://example.com/2" :file "/tmp/cal2.org"))))
+ (should (equal '("cal1" "cal2") (calendar-sync--calendar-names)))))
+
+(ert-deftest test-calendar-sync--calendar-names-empty-when-no-calendars ()
+ "Test that calendar-names returns empty list when no calendars configured."
+ (let ((calendar-sync-calendars nil))
+ (should (null (calendar-sync--calendar-names)))))
+
+(ert-deftest test-calendar-sync--get-calendar-by-name-finds-calendar ()
+ "Test that get-calendar-by-name finds correct calendar."
+ (let ((calendar-sync-calendars
+ '((:name "google" :url "http://google.com" :file "/tmp/gcal.org")
+ (:name "proton" :url "http://proton.me" :file "/tmp/pcal.org"))))
+ (let ((found (calendar-sync--get-calendar-by-name "proton")))
+ (should found)
+ (should (string= "proton" (plist-get found :name)))
+ (should (string= "http://proton.me" (plist-get found :url))))))
+
+(ert-deftest test-calendar-sync--get-calendar-by-name-returns-nil-for-unknown ()
+ "Test that get-calendar-by-name returns nil for unknown calendar."
+ (let ((calendar-sync-calendars
+ '((:name "google" :url "http://google.com" :file "/tmp/gcal.org"))))
+ (should (null (calendar-sync--get-calendar-by-name "nonexistent")))))
+
+(ert-deftest test-calendar-sync--get-calendar-state-returns-nil-for-new ()
+ "Test that get-calendar-state returns nil for calendar without state."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal)))
+ (should (null (calendar-sync--get-calendar-state "new-calendar")))))
+
+(ert-deftest test-calendar-sync--set-and-get-calendar-state ()
+ "Test setting and getting calendar state."
+ (let ((calendar-sync--calendar-states (make-hash-table :test 'equal))
+ (test-state '(:status ok :last-sync (0 0 0) :last-error nil)))
+ (calendar-sync--set-calendar-state "test-cal" test-state)
+ (let ((retrieved (calendar-sync--get-calendar-state "test-cal")))
+ (should retrieved)
+ (should (eq 'ok (plist-get retrieved :status))))))
+
(provide 'test-calendar-sync)
;;; test-calendar-sync.el ends here
diff --git a/tests/test-integration-recurring-events.el b/tests/test-integration-recurring-events.el
index 0d32d9e0..4629e6ef 100644
--- a/tests/test-integration-recurring-events.el
+++ b/tests/test-integration-recurring-events.el
@@ -105,7 +105,7 @@ Validates:
(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 (string-match-p "^# Calendar Events" org-output))
;; Should contain multiple GTFO entries
(let ((gtfo-count (with-temp-buffer
diff --git a/tests/testutil-calendar-sync.el b/tests/testutil-calendar-sync.el
index c83006b5..d1a94b01 100644
--- a/tests/testutil-calendar-sync.el
+++ b/tests/testutil-calendar-sync.el
@@ -8,6 +8,15 @@
(require 'calendar)
+;;; Test Environment Setup
+
+;; Provide stub for cj/log-silently if not already defined
+;; This function is defined in system-lib.el but tests should run standalone
+(unless (fboundp 'cj/log-silently)
+ (defun cj/log-silently (format-string &rest args)
+ "Stub for testing: silently ignore log messages."
+ nil))
+
;;; Dynamic Timestamp Generation
(defun test-calendar-sync-time-today-at (hour minute)
@@ -48,7 +57,7 @@ Returns (year month day hour minute) list suitable for tests."
(defun test-calendar-sync-time-date-only (offset-days)
"Generate date-only timestamp for OFFSET-DAYS from now.
-Returns (year month day) list for all-day events."
+Returns (year month day) list for all-day events and UNTIL dates."
(let* ((future (time-add (current-time) (* offset-days 24 3600)))
(decoded (decode-time future))
(year (nth 5 decoded))
@@ -56,6 +65,22 @@ Returns (year month day) list for all-day events."
(day (nth 3 decoded)))
(list year month day)))
+(defun test-calendar-sync-time-date-only-ago (offset-days)
+ "Generate date-only timestamp for OFFSET-DAYS ago.
+Returns (year month day) list for UNTIL dates in the past."
+ (let* ((past (time-subtract (current-time) (* offset-days 24 3600)))
+ (decoded (decode-time past))
+ (year (nth 5 decoded))
+ (month (nth 4 decoded))
+ (day (nth 3 decoded)))
+ (list year month day)))
+
+(defun test-calendar-sync-date-only-from-datetime (datetime)
+ "Extract date-only (year month day) from DATETIME list.
+DATETIME is (year month day hour minute).
+Returns (year month day) suitable for UNTIL dates."
+ (list (nth 0 datetime) (nth 1 datetime) (nth 2 datetime)))
+
;;; .ics Test Data Generation
(defun test-calendar-sync-ics-datetime (time-list)
@@ -106,5 +131,68 @@ Each event should be a VEVENT string from `test-calendar-sync-make-vevent'."
(string-join events "\n")
"\nEND:VCALENDAR"))
+;;; Property Test Helpers
+
+(defun test-calendar-sync-random-future-date ()
+ "Generate random date 1-180 days in future with random time.
+Returns (year month day hour minute) list."
+ (test-calendar-sync-time-days-from-now
+ (1+ (random 180))
+ (random 24)
+ (random 60)))
+
+(defun test-calendar-sync-random-past-date ()
+ "Generate random date 1-90 days in past with random time.
+Returns (year month day hour minute) list."
+ (test-calendar-sync-time-days-ago
+ (1+ (random 90))
+ (random 24)
+ (random 60)))
+
+(defun test-calendar-sync-random-weekday-subset ()
+ "Generate random non-empty subset of weekdays.
+Returns list of weekday strings like (\"MO\" \"WE\" \"FR\")."
+ (let ((days '("MO" "TU" "WE" "TH" "FR" "SA" "SU"))
+ (result '()))
+ (dolist (day days)
+ (when (zerop (random 2))
+ (push day result)))
+ ;; Ensure non-empty
+ (or result (list (nth (random 7) days)))))
+
+(defun test-calendar-sync-random-freq ()
+ "Return random RRULE frequency symbol."
+ (nth (random 4) '(daily weekly monthly yearly)))
+
+(defun test-calendar-sync-days-between (date1 date2)
+ "Calculate days between DATE1 and DATE2.
+Both dates are (year month day ...) lists.
+Returns float number of days (positive if date2 > date1)."
+ (let ((t1 (calendar-sync--date-to-time (list (nth 0 date1) (nth 1 date1) (nth 2 date1))))
+ (t2 (calendar-sync--date-to-time (list (nth 0 date2) (nth 1 date2) (nth 2 date2)))))
+ (/ (float-time (time-subtract t2 t1)) 86400.0)))
+
+(defun test-calendar-sync-wide-range ()
+ "Generate wide date range: 90 days past to 365 days future.
+Returns (start-time end-time) suitable for expansion functions."
+ (list (time-subtract (current-time) (* 90 86400))
+ (time-add (current-time) (* 365 86400))))
+
+(defun test-calendar-sync-narrow-range ()
+ "Generate narrow date range: today to 30 days future.
+Returns (start-time end-time) suitable for expansion functions."
+ (list (current-time)
+ (time-add (current-time) (* 30 86400))))
+
+(defun test-calendar-sync-date-to-time-value (date)
+ "Convert DATE list to Emacs time value.
+DATE is (year month day) or (year month day hour minute)."
+ (let ((year (nth 0 date))
+ (month (nth 1 date))
+ (day (nth 2 date))
+ (hour (or (nth 3 date) 0))
+ (minute (or (nth 4 date) 0)))
+ (encode-time 0 minute hour day month year)))
+
(provide 'testutil-calendar-sync)
;;; testutil-calendar-sync.el ends here