;; lib/events/tests/reminders.sx — reminder + digest derivation from the agenda. (define ev-rm-pass 0) (define ev-rm-fail 0) (define ev-rm-failures (list)) (define ev-rm-check! (fn (name got expected) (if (= got expected) (set! ev-rm-pass (+ ev-rm-pass 1)) (do (set! ev-rm-fail (+ ev-rm-fail 1)) (append! ev-rm-failures (str name "\n expected: " expected "\n got: " got)))))) ;; A store with a weekly class (Mon+Wed 18:00, 60m, 4 occurrences) and a one-off ;; talk; durable bookings on a persist backend. (define ev-rm-store (fn () (ev/schedule (ev/schedule (ev/empty) (quote yoga) (ev-dt 2026 6 1 18 0) 60 {:freq :weekly :count 4 :byday (list 0 2)} 20) (quote talk) (ev-dt 2026 6 2 12 0) 30 nil 50))) (define ev-rm-run-all! (fn () (let ((store (ev-rm-store)) (b (persist/open))) (let ((occs (ev/agenda store (ev-date 2026 6 1) (ev-date 2026 7 1)))) (do (ev/book-occ! b store (quote nia) (first occs)) (ev/book-occ! b store (quote ola) (first occs)) (ev/book-occ! b store (quote ola) (ev-occ (quote talk) (ev-dt 2026 6 2 12 0) 30)) (do (let ((rs (ev/occurrence-reminders b (first occs) 60))) (do (ev-rm-check! "one reminder per booked attendee" (len rs) 2) (ev-rm-check! "reminder fires lead minutes before start" (get (first rs) :fire-at) (- (ev-dt 2026 6 1 18 0) 60)) (ev-rm-check! "reminder idempotency key encodes occ/recipient/lead" (get (first rs) :id) (str (ev-occ-key (first occs)) "/" (quote nia) "/" 60)) (ev-rm-check! "reminder names the event" (get (first rs) :event) (quote yoga)))) (ev-rm-check! "unbooked occurrence has no reminders" (len (ev/occurrence-reminders b (ev-occ (quote yoga) (ev-dt 2026 6 3 18 0) 60) 60)) 0) (let ((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60))) (do (ev-rm-check! "agenda reminders cover all bookings" (len all) 3) (ev-rm-check! "agenda reminders sorted by fire-at" (map (fn (r) (get r :fire-at)) all) (list (- (ev-dt 2026 6 1 18 0) 60) (- (ev-dt 2026 6 1 18 0) 60) (- (ev-dt 2026 6 2 12 0) 60))))) (let ((all (ev/agenda-reminders b store (ev-date 2026 6 1) (ev-date 2026 7 1) 60))) (do (ev-rm-check! "nothing due before the first fire-at" (len (ev/due-reminders all (- (ev-dt 2026 6 1 17 0) 1))) 0) (ev-rm-check! "the two yoga reminders are due at 17:00" (len (ev/due-reminders all (ev-dt 2026 6 1 17 0))) 2) (ev-rm-check! "all reminders due once past the last fire-at" (len (ev/due-reminders all (ev-dt 2026 6 2 12 0))) 3))) (let ((r (first (ev/occurrence-reminders b (first occs) 60)))) (ev-rm-check! "reminder projects to (id recipient body)" (ev/reminder->msg r) (list (str (ev-occ-key (first occs)) "/" (quote nia) "/" 60) (quote nia) (list :reminder (quote yoga) (ev-dt 2026 6 1 18 0))))) (let ((dig (ev/agenda-digest b store (quote ola) (ev-date 2026 6 1) (ev-date 2026 7 1)))) (do (ev-rm-check! "digest is addressed to the actor" (get dig :recipient) (quote ola)) (ev-rm-check! "digest lists the actor's booked occurrences" (map (fn (it) (get it :event)) (get dig :items)) (list (quote yoga) (quote talk))))) (let ((empty-dig (ev/agenda-digest b store (quote nobody) (ev-date 2026 6 1) (ev-date 2026 7 1)))) (ev-rm-check! "digest empty for an actor with no bookings" (get empty-dig :items) (list))))))))) ;; ---- reschedule notifications ---- (define ev-rm-rs-run-all! (fn () (let ((b (persist/open)) (ev (ev-event (quote yoga) (ev-dt 2026 6 1 9 0) 60 {:freq :daily :count 3} 20))) (let ((occ2 (ev-occ (quote yoga) (ev-dt 2026 6 2 9 0) 60))) (do (ev/book-occ! b (ev/add-event (ev/empty) ev) (quote nia) occ2) (ev/book-occ! b (ev/add-event (ev/empty) ev) (quote ola) occ2) ;; reschedule the Jun 2 occurrence to 14:00 / 90 min (let ((moved (ev-with-override ev (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0) 90))) (let ((ns (ev/reschedule-notifications b moved))) (do (ev-rm-check! "every booked attendee is notified of the reschedule" (map (fn (n) (get n :recipient)) ns) (list (quote nia) (quote ola))) (ev-rm-check! "reschedule carries old and new start" (list (get (first ns) :old-start) (get (first ns) :new-start)) (list (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0))) (ev-rm-check! "reschedule carries the new duration" (get (first ns) :new-duration) 90) (ev-rm-check! "reschedule idempotency key encodes original key + new start" (get (first ns) :id) (str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0))) (ev-rm-check! "reschedule projects to notify wire shape" (ev/reschedule-notify->msg (first ns)) (list (str (ev-occ-key occ2) "/reschedule/" (ev-dt 2026 6 2 14 0)) (quote nia) (list :rescheduled (quote yoga) (ev-dt 2026 6 2 9 0) (ev-dt 2026 6 2 14 0))))))) ;; an override on an occurrence nobody booked notifies no one (let ((moved2 (ev-with-override ev (ev-dt 2026 6 3 9 0) (ev-dt 2026 6 3 10 0) 60))) (ev-rm-check! "rescheduling an unbooked occurrence notifies no one" (len (ev/reschedule-notifications b moved2)) 0)) ;; an event with no overrides yields no reschedule notifications (ev-rm-check! "event without overrides has no reschedule notifications" (len (ev/reschedule-notifications b ev)) 0)))))) (define ev-reminders-tests-run! (fn () (do (set! ev-rm-pass 0) (set! ev-rm-fail 0) (set! ev-rm-failures (list)) (ev-rm-run-all!) (ev-rm-rs-run-all!) {:failures ev-rm-failures :total (+ ev-rm-pass ev-rm-fail) :passed ev-rm-pass :failed ev-rm-fail})))