;; lib/events/tests/calendar.sx — civil date core + RRULE window expansion. (define ev-cal-pass 0) (define ev-cal-fail 0) (define ev-cal-failures (list)) (define ev-cal-check! (fn (name got expected) (if (= got expected) (set! ev-cal-pass (+ ev-cal-pass 1)) (do (set! ev-cal-fail (+ ev-cal-fail 1)) (append! ev-cal-failures (str name "\n expected: " expected "\n got: " got)))))) ;; Project occurrences to (civil weekday) pairs for legible assertions. (define ev-cal-shape (fn (occs) (map (fn (o) (list (ev-dt->civil (get o :start)) (ev-dt-weekday (get o :start)))) occs))) (define ev-cal-starts (fn (occs) (map (fn (o) (ev-dt->civil (get o :start))) occs))) (define ev-cal-run-all! (fn () (do (ev-cal-check! "epoch day zero" (ev-days-from-civil 1970 1 1) 0) (ev-cal-check! "y2k day number" (ev-days-from-civil 2000 1 1) 10957) (ev-cal-check! "leap day round trip" (ev-civil-from-days (ev-days-from-civil 2024 2 29)) (list 2024 2 29)) (ev-cal-check! "pre-epoch round trip" (ev-civil-from-days (ev-days-from-civil 1969 12 31)) (list 1969 12 31)) (ev-cal-check! "epoch is thursday" (ev-weekday-of-days 0) 3) (ev-cal-check! "2026-06-06 is saturday" (ev-dt-weekday (ev-date 2026 6 6)) 5) (ev-cal-check! "dt carries time of day" (ev-dt-tod (ev-dt 2026 6 1 9 30)) 570) (ev-cal-check! "civil from dt" (ev-dt->civil (ev-dt 2026 12 25 8 0)) (list 2026 12 25)) (ev-cal-check! "days in feb 2024 (leap)" (ev-days-in-month 2024 2) 29) (ev-cal-check! "days in feb 2026" (ev-days-in-month 2026 2) 28) (ev-cal-check! "add months wraps year" (ev-add-months 2026 11 3) (list 2027 2)) (ev-cal-check! "add months within year" (ev-add-months 2026 1 5) (list 2026 6)) (let ((ev (ev-event (quote one) (ev-dt 2026 6 10 14 0) 60 nil 1))) (do (ev-cal-check! "single inside window emits once" (len (ev-expand ev (ev-date 2026 6 1) (ev-date 2026 7 1))) 1) (ev-cal-check! "single before window omitted" (len (ev-expand ev (ev-date 2026 7 1) (ev-date 2026 8 1))) 0) (ev-cal-check! "single after window omitted" (len (ev-expand ev (ev-date 2026 1 1) (ev-date 2026 2 1))) 0) (ev-cal-check! "occurrence end is start plus duration" (get (first (ev-expand ev (ev-date 2026 6 1) (ev-date 2026 7 1))) :end) (+ (ev-dt 2026 6 10 14 0) 60)))) (let ((daily (ev-event (quote d) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1))) (do (ev-cal-check! "daily count caps occurrences" (ev-cal-starts (ev-expand daily (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 1) (list 2026 6 2) (list 2026 6 3) (list 2026 6 4) (list 2026 6 5))) (ev-cal-check! "daily preserves time of day" (ev-dt-tod (get (first (ev-expand daily (ev-date 2026 6 1) (ev-date 2026 7 1))) :start)) 540))) (let ((di (ev-event (quote di) (ev-dt 2026 6 1 0 0) 30 {:interval 3 :freq :daily :until (ev-date 2026 6 30)} 1))) (ev-cal-check! "daily interval 3 steps by three days" (ev-cal-starts (ev-expand di (ev-date 2026 6 1) (ev-date 2026 6 13))) (list (list 2026 6 1) (list 2026 6 4) (list 2026 6 7) (list 2026 6 10) (list 2026 6 13)))) (let ((dc (ev-event (quote dc) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 10} 1))) (ev-cal-check! "count is window-independent (clip middle)" (ev-cal-starts (ev-expand dc (ev-date 2026 6 5) (ev-date 2026 6 8))) (list (list 2026 6 5) (list 2026 6 6) (list 2026 6 7) (list 2026 6 8)))) (let ((dc2 (ev-event (quote dc2) (ev-dt 2026 6 1 0 0) 30 {:freq :daily :count 3} 1))) (ev-cal-check! "count exhausted before window yields nothing" (len (ev-expand dc2 (ev-date 2026 6 10) (ev-date 2026 6 20))) 0)) (let ((wk (ev-event (quote w) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :byday (list 0 2 4)} 1))) (ev-cal-check! "weekly byday mon/wed/fri first two weeks" (ev-cal-shape (ev-expand wk (ev-date 2026 6 1) (ev-date 2026 6 13))) (list (list (list 2026 6 1) 0) (list (list 2026 6 3) 2) (list (list 2026 6 5) 4) (list (list 2026 6 8) 0) (list (list 2026 6 10) 2) (list (list 2026 6 12) 4)))) (let ((wu (ev-event (quote wu) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :until (ev-dt 2026 6 10 23 0) :byday (list 0 2)} 1))) (ev-cal-check! "weekly until clips trailing occurrences" (ev-cal-starts (ev-expand wu (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 1) (list 2026 6 3) (list 2026 6 8) (list 2026 6 10)))) (let ((wi (ev-event (quote wi) (ev-dt 2026 6 1 18 0) 90 {:interval 2 :freq :weekly :byday (list 0)} 1))) (ev-cal-check! "weekly interval 2 skips alternate weeks" (ev-cal-starts (ev-expand wi (ev-date 2026 6 1) (ev-date 2026 7 6))) (list (list 2026 6 1) (list 2026 6 15) (list 2026 6 29)))) (let ((wd (ev-event (quote wd) (ev-dt 2026 6 3 12 0) 60 {:freq :weekly :count 3} 1))) (ev-cal-check! "weekly default byday is dtstart weekday" (ev-cal-shape (ev-expand wd (ev-date 2026 6 1) (ev-date 2026 8 1))) (list (list (list 2026 6 3) 2) (list (list 2026 6 10) 2) (list (list 2026 6 17) 2)))) (let ((wc (ev-event (quote wc) (ev-dt 2026 6 1 18 0) 90 {:freq :weekly :count 10 :byday (list 0 2)} 1))) (ev-cal-check! "weekly count window-independent (clip middle)" (ev-cal-starts (ev-expand wc (ev-date 2026 6 15) (ev-date 2026 7 5))) (list (list 2026 6 15) (list 2026 6 17) (list 2026 6 22) (list 2026 6 24) (list 2026 6 29) (list 2026 7 1)))) (let ((wf (ev-event (quote wf) (ev-dt 2026 6 3 18 0) 90 {:freq :weekly :count 4 :byday (list 0 2 4)} 1))) (ev-cal-check! "first week skips byday earlier than dtstart" (ev-cal-starts (ev-expand wf (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 3) (list 2026 6 5) (list 2026 6 8) (list 2026 6 10)))) (let ((md (ev-event (quote md) (ev-dt 2026 1 15 9 0) 60 {:bymonthday (list 15) :freq :monthly} 1))) (do (ev-cal-check! "monthly bymonthday 15th" (ev-cal-starts (ev-expand md (ev-date 2026 1 1) (ev-date 2026 4 1))) (list (list 2026 1 15) (list 2026 2 15) (list 2026 3 15))) (ev-cal-check! "monthly preserves time of day" (ev-dt-tod (get (first (ev-expand md (ev-date 2026 1 1) (ev-date 2026 4 1))) :start)) 540))) (let ((mm (ev-event (quote mm) (ev-dt 2026 1 1 9 0) 60 {:bymonthday (list 1 15) :freq :monthly :count 4} 1))) (ev-cal-check! "monthly multiple bymonthday sorted within month" (ev-cal-starts (ev-expand mm (ev-date 2026 1 1) (ev-date 2026 12 1))) (list (list 2026 1 1) (list 2026 1 15) (list 2026 2 1) (list 2026 2 15)))) (let ((ml (ev-event (quote ml) (ev-dt 2026 1 31 9 0) 60 {:bymonthday (list -1) :freq :monthly} 1))) (ev-cal-check! "monthly bymonthday -1 is last day" (ev-cal-starts (ev-expand ml (ev-date 2026 1 1) (ev-date 2026 4 1))) (list (list 2026 1 31) (list 2026 2 28) (list 2026 3 31)))) (let ((mn (ev-event (quote mn) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord 2 :wd 1})} 1))) (ev-cal-check! "monthly 2nd tuesday" (ev-cal-shape (ev-expand mn (ev-date 2026 1 1) (ev-date 2026 4 1))) (list (list (list 2026 1 13) 1) (list (list 2026 2 10) 1) (list (list 2026 3 10) 1)))) (let ((mz (ev-event (quote mz) (ev-dt 2026 1 1 9 0) 60 {:freq :monthly :byday (list {:ord -1 :wd 4})} 1))) (ev-cal-check! "monthly last friday" (ev-cal-shape (ev-expand mz (ev-date 2026 1 1) (ev-date 2026 4 1))) (list (list (list 2026 1 30) 4) (list (list 2026 2 27) 4) (list (list 2026 3 27) 4)))) (let ((m31 (ev-event (quote m31) (ev-dt 2026 1 31 9 0) 60 {:freq :monthly :count 4} 1))) (ev-cal-check! "monthly default day-of-month skips short months" (ev-cal-starts (ev-expand m31 (ev-date 2026 1 1) (ev-date 2026 12 1))) (list (list 2026 1 31) (list 2026 3 31) (list 2026 5 31) (list 2026 7 31)))) (let ((mi (ev-event (quote mi) (ev-dt 2026 1 10 9 0) 60 {:interval 3 :freq :monthly :count 3} 1))) (ev-cal-check! "monthly interval 3 steps by quarter" (ev-cal-starts (ev-expand mi (ev-date 2026 1 1) (ev-date 2027 1 1))) (list (list 2026 1 10) (list 2026 4 10) (list 2026 7 10)))) (let ((mc (ev-event (quote mc) (ev-dt 2026 1 5 9 0) 60 {:freq :monthly :count 12} 1))) (ev-cal-check! "monthly count window-independent (clip middle)" (ev-cal-starts (ev-expand mc (ev-date 2026 4 1) (ev-date 2026 6 30))) (list (list 2026 4 5) (list 2026 5 5) (list 2026 6 5)))) (let ((a (ev-event (quote a) (ev-dt 2026 6 2 10 0) 30 {:freq :daily :count 2} 1)) (b (ev-event (quote b) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 2} 1))) (ev-cal-check! "expand-all sorts merged occurrences by start" (map (fn (o) (list (get o :id) (ev-dt->civil (get o :start)))) (ev-expand-all (list a b) (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list (quote b) (list 2026 6 1)) (list (quote b) (list 2026 6 2)) (list (quote a) (list 2026 6 2)) (list (quote a) (list 2026 6 3)))))))) ;; ---- EXDATE / RDATE exceptions ---- (define ev-cal-ex-run-all! (fn () (do ;; EXDATE removes a matching occurrence from the recurrence (let ((ex (ev-event-full (quote standup) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 5} 1 (list (ev-dt 2026 6 3 9 0)) (list)))) (ev-cal-check! "EXDATE excludes the matching occurrence" (ev-cal-starts (ev-expand ex (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 1) (list 2026 6 2) (list 2026 6 4) (list 2026 6 5)))) ;; EXDATE that matches nothing is a no-op (let ((ex2 (ev-event-full (quote s) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1 (list (ev-dt 2026 6 9 9 0)) (list)))) (ev-cal-check! "EXDATE not matching any occurrence is a no-op" (len (ev-expand ex2 (ev-date 2026 6 1) (ev-date 2026 7 1))) 3)) ;; RDATE adds an explicit occurrence (within the window) (let ((rd (ev-event-full (quote s) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1 (list) (list (ev-dt 2026 6 10 9 0))))) (do (ev-cal-check! "RDATE adds an explicit occurrence, sorted in" (ev-cal-starts (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 1) (list 2026 6 2) (list 2026 6 3) (list 2026 6 10))) (ev-cal-check! "RDATE outside the window is dropped" (len (ev-expand rd (ev-date 2026 6 1) (ev-date 2026 6 5))) 3))) ;; RDATE coinciding with an rrule occurrence is de-duplicated (let ((rdup (ev-event-full (quote s) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1 (list) (list (ev-dt 2026 6 2 9 0))))) (ev-cal-check! "RDATE duplicating an occurrence does not double it" (len (ev-expand rdup (ev-date 2026 6 1) (ev-date 2026 7 1))) 3)) ;; EXDATE wins over RDATE for the same datetime (let ((both (ev-event-full (quote s) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1 (list (ev-dt 2026 6 2 9 0)) (list (ev-dt 2026 6 2 9 0))))) (ev-cal-check! "EXDATE wins over RDATE and the rrule for the same date" (ev-cal-starts (ev-expand both (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 1) (list 2026 6 3)))) ;; RDATE-only event (no rrule) (let ((ronly (ev-event-full (quote s) (ev-dt 2026 6 1 9 0) 30 nil 1 (list) (list (ev-dt 2026 6 5 9 0) (ev-dt 2026 6 3 9 0))))) (ev-cal-check! "RDATE-only event yields dtstart plus the extra dates, sorted" (ev-cal-starts (ev-expand ronly (ev-date 2026 6 1) (ev-date 2026 7 1))) (list (list 2026 6 1) (list 2026 6 3) (list 2026 6 5)))) ;; plain ev-event (no exception keys) is unaffected (let ((plain (ev-event (quote p) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 3} 1))) (ev-cal-check! "plain event without exceptions expands unchanged" (len (ev-expand plain (ev-date 2026 6 1) (ev-date 2026 7 1))) 3))))) ;; ---- per-occurrence overrides (reschedule one instance) ---- (define ev-cal-ov-run-all! (fn () (let ((base (ev-event (quote standup) (ev-dt 2026 6 1 9 0) 30 {:freq :daily :count 4} 1))) (do ;; reschedule one instance to a new time + duration (let ((moved (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 45))) (let ((occs (ev-expand moved (ev-date 2026 6 1) (ev-date 2026 6 5)))) (do (ev-cal-check! "override moves only the targeted instance" (map (fn (o) (ev-dt-tod (get o :start))) occs) (list 540 840 540 540)) (ev-cal-check! "override applies the new duration" (map (fn (o) (- (get o :end) (get o :start))) occs) (list 30 45 30 30)) (ev-cal-check! "override keeps the series length" (len occs) 4)))) ;; an instance moved out of the window vacates its slot (let ((movedout (ev-with-override base (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 20 9 0) 30))) (ev-cal-check! "instance moved out of window is dropped, slot vacated" (ev-cal-starts (ev-expand movedout (ev-date 2026 6 1) (ev-date 2026 6 5))) (list (list 2026 6 1) (list 2026 6 3) (list 2026 6 4)))) ;; override for a non-existent original start is a no-op (let ((noop (ev-with-override base (ev-dt 2026 6 9 9 0) (ev-dt 2026 6 9 14 0) 45))) (ev-cal-check! "override for a non-occurring start is a no-op" (len (ev-expand noop (ev-date 2026 6 1) (ev-date 2026 6 5))) 4)) ;; overrides re-sort the agenda when an instance moves earlier (let ((early (ev-with-override base (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 1 7 0) 30))) (ev-cal-check! "an instance moved earlier re-sorts into place" (map (fn (o) (ev-dt-tod (get o :start))) (ev-expand early (ev-date 2026 6 1) (ev-date 2026 6 5))) (list 420 540 540 540))))))) (define ev-calendar-tests-run! (fn () (do (set! ev-cal-pass 0) (set! ev-cal-fail 0) (set! ev-cal-failures (list)) (ev-cal-run-all!) (ev-cal-ex-run-all!) (ev-cal-ov-run-all!) {:failures ev-cal-failures :total (+ ev-cal-pass ev-cal-fail) :passed ev-cal-pass :failed ev-cal-fail})))