summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-03 08:33:33 -0500
committerCraig Jennings <c@cjennings.net>2026-04-03 08:33:33 -0500
commiteb01b3d24739e916d9dca33f5f039650a9de8457 (patch)
treeacd939426b9386b3b4b7d415e775b10781c682b9 /tests
parent3f7e427cf7530e38b7eb915c0dd49bd9318c9992 (diff)
feat(music): add random-aware next/previous; refactor music + calendar-syncHEADmain
Music: random mode now respected by next/previous keys. Previous navigates a 50-track play history ring buffer. Fixed playlist replacement bug. 24 new tests. Calendar-sync: consolidated duplicate parse functions, extracted timezone localization helper, unified expand-daily/monthly/yearly into parameterized function, removed dead code. 33 new characterization tests. -90 lines.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-calendar-sync--expand-daily.el180
-rw-r--r--tests/test-calendar-sync--expand-monthly.el174
-rw-r--r--tests/test-calendar-sync--expand-yearly.el179
-rw-r--r--tests/test-music-config-random-navigation.el381
4 files changed, 914 insertions, 0 deletions
diff --git a/tests/test-calendar-sync--expand-daily.el b/tests/test-calendar-sync--expand-daily.el
new file mode 100644
index 00000000..43b93664
--- /dev/null
+++ b/tests/test-calendar-sync--expand-daily.el
@@ -0,0 +1,180 @@
+;;; test-calendar-sync--expand-daily.el --- Tests for calendar-sync--expand-daily -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Characterization tests for calendar-sync--expand-daily.
+;; Captures current behavior before refactoring into unified expand function.
+;; Uses dynamic timestamps to avoid hardcoded dates.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--expand-daily-normal-generates-occurrences ()
+ "Test expanding daily event generates occurrences within range."
+ (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))
+ (base-event (list :summary "Daily Standup"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ ;; ~365 days future + ~90 days past = ~456 days of occurrences
+ (should (> (length occurrences) 300))
+ (should (< (length occurrences) 500))))
+
+(ert-deftest test-calendar-sync--expand-daily-normal-preserves-time ()
+ "Test that each occurrence preserves the original event time."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 14 30))
+ (end-date (test-calendar-sync-time-days-from-now 1 15 30))
+ (base-event (list :summary "Afternoon Check"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 1))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (> (length occurrences) 0))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= (nth 3 s) 14))
+ (should (= (nth 4 s) 30))))))
+
+(ert-deftest test-calendar-sync--expand-daily-normal-interval-two ()
+ "Test expanding every-other-day event."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 9 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Every Other Day"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 2))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ ;; 30 days / 2 = ~15 occurrences
+ (should (>= (length occurrences) 12))
+ (should (<= (length occurrences) 18))))
+
+(ert-deftest test-calendar-sync--expand-daily-normal-consecutive-dates-increase ()
+ "Test that occurrences are on consecutive dates spaced by interval."
+ (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))
+ (base-event (list :summary "Daily"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 3))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (when (> (length occurrences) 1)
+ (let ((prev (plist-get (car occurrences) :start)))
+ (dolist (occ (cdr occurrences))
+ (let ((curr (plist-get occ :start)))
+ ;; Days between consecutive occurrences should be ~3
+ (let ((days (test-calendar-sync-days-between prev curr)))
+ (should (>= days 2.5))
+ (should (<= days 3.5)))
+ (setq prev curr)))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--expand-daily-boundary-count-limits-occurrences ()
+ "Test that COUNT limits the total number of occurrences."
+ (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))
+ (base-event (list :summary "Limited"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 1 :count 7))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length occurrences) 7))))
+
+(ert-deftest test-calendar-sync--expand-daily-boundary-until-limits-occurrences ()
+ "Test that UNTIL date stops expansion."
+ (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-date-only 15))
+ (base-event (list :summary "Until-Limited"
+ :start start-date
+ :end end-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)))
+ ;; ~14 days worth of daily occurrences
+ (should (>= (length occurrences) 12))
+ (should (<= (length occurrences) 16))))
+
+(ert-deftest test-calendar-sync--expand-daily-boundary-respects-date-range ()
+ "Test that occurrences are within or at the date range boundaries."
+ (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))
+ (base-event (list :summary "Ranged"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 1))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range))
+ (range-start (nth 0 range))
+ ;; Add 1 day buffer — date-in-range-p checks date portion only
+ (range-end-padded (time-add (nth 1 range) 86400)))
+ (dolist (occ occurrences)
+ (let* ((s (plist-get occ :start))
+ (occ-time (test-calendar-sync-date-to-time-value s)))
+ (should (time-less-p range-start occ-time))
+ (should (time-less-p occ-time range-end-padded))))))
+
+(ert-deftest test-calendar-sync--expand-daily-boundary-count-one-returns-single ()
+ "Test that COUNT=1 returns exactly one occurrence."
+ (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))
+ (base-event (list :summary "Once"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 1 :count 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length occurrences) 1))))
+
+(ert-deftest test-calendar-sync--expand-daily-boundary-preserves-summary ()
+ "Test that each occurrence carries the original summary."
+ (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))
+ (base-event (list :summary "My Event"
+ :start start-date
+ :end end-date
+ :location "Room 5"))
+ (rrule (list :freq 'daily :interval 1 :count 3))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (dolist (occ occurrences)
+ (should (equal (plist-get occ :summary) "My Event")))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--expand-daily-error-past-until-returns-empty ()
+ "Test that UNTIL in the past produces no occurrences in future range."
+ (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-date-only-ago 50))
+ (base-event (list :summary "Past"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'daily :interval 1 :until until-date))
+ (range (list (time-subtract (current-time) (* 30 86400))
+ (time-add (current-time) (* 365 86400))))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length occurrences) 0))))
+
+(ert-deftest test-calendar-sync--expand-daily-error-no-end-time-still-works ()
+ "Test that event without :end still generates occurrences."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "No End" :start start-date))
+ (rrule (list :freq 'daily :interval 1 :count 5))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-daily base-event rrule range)))
+ (should (= (length occurrences) 5))))
+
+(provide 'test-calendar-sync--expand-daily)
+;;; test-calendar-sync--expand-daily.el ends here
diff --git a/tests/test-calendar-sync--expand-monthly.el b/tests/test-calendar-sync--expand-monthly.el
new file mode 100644
index 00000000..3dc1f2dc
--- /dev/null
+++ b/tests/test-calendar-sync--expand-monthly.el
@@ -0,0 +1,174 @@
+;;; test-calendar-sync--expand-monthly.el --- Tests for calendar-sync--expand-monthly -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Characterization tests for calendar-sync--expand-monthly.
+;; Captures current behavior before refactoring into unified expand function.
+;; Uses dynamic timestamps to avoid hardcoded dates.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--expand-monthly-normal-generates-occurrences ()
+ "Test expanding monthly event generates occurrences within range."
+ (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))
+ (base-event (list :summary "Monthly Review"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ ;; ~12-15 months of range
+ (should (> (length occurrences) 10))
+ (should (< (length occurrences) 20))))
+
+(ert-deftest test-calendar-sync--expand-monthly-normal-preserves-day-of-month ()
+ "Test that each occurrence falls on the same day of month."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 5 10 0))
+ (end-date (test-calendar-sync-time-days-from-now 5 11 0))
+ (expected-day (nth 2 start-date))
+ (base-event (list :summary "Monthly"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (> (length occurrences) 0))
+ ;; Day of month should be consistent (may clamp for short months)
+ (dolist (occ occurrences)
+ (let ((day (nth 2 (plist-get occ :start))))
+ (should (<= day expected-day))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-normal-interval-two ()
+ "Test expanding bi-monthly event."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 9 0))
+ (end-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "Bi-Monthly"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 2))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ ;; ~15 months / 2 = ~7-8 occurrences
+ (should (>= (length occurrences) 5))
+ (should (<= (length occurrences) 10))))
+
+(ert-deftest test-calendar-sync--expand-monthly-normal-preserves-time ()
+ "Test that each occurrence preserves the original event time."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 15 45))
+ (end-date (test-calendar-sync-time-days-from-now 1 16 45))
+ (base-event (list :summary "Timed Monthly"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1 :count 3))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= (nth 3 s) 15))
+ (should (= (nth 4 s) 45))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--expand-monthly-boundary-count-limits-occurrences ()
+ "Test that COUNT limits the total number of occurrences."
+ (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))
+ (base-event (list :summary "Limited"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1 :count 4))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 4))))
+
+(ert-deftest test-calendar-sync--expand-monthly-boundary-until-limits-occurrences ()
+ "Test that UNTIL date stops expansion."
+ (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-date-only 120))
+ (base-event (list :summary "Until-Limited"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ ;; ~4 months of monthly occurrences
+ (should (>= (length occurrences) 3))
+ (should (<= (length occurrences) 5))))
+
+(ert-deftest test-calendar-sync--expand-monthly-boundary-count-one-returns-single ()
+ "Test that COUNT=1 returns exactly one occurrence."
+ (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))
+ (base-event (list :summary "Once"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1 :count 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 1))))
+
+(ert-deftest test-calendar-sync--expand-monthly-boundary-respects-date-range ()
+ "Test that occurrences outside date range are excluded."
+ (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))
+ (base-event (list :summary "Ranged"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range))
+ (range-start (nth 0 range))
+ (range-end (nth 1 range)))
+ (dolist (occ occurrences)
+ (let* ((s (plist-get occ :start))
+ (occ-time (test-calendar-sync-date-to-time-value s)))
+ (should (time-less-p range-start occ-time))
+ (should (time-less-p occ-time range-end))))))
+
+(ert-deftest test-calendar-sync--expand-monthly-boundary-preserves-summary ()
+ "Test that each occurrence carries the original summary."
+ (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))
+ (base-event (list :summary "Team Sync"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1 :count 3))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (dolist (occ occurrences)
+ (should (equal (plist-get occ :summary) "Team Sync")))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--expand-monthly-error-past-until-returns-empty ()
+ "Test that UNTIL in the past produces no occurrences in future range."
+ (let* ((start-date (test-calendar-sync-time-days-ago 200 10 0))
+ (end-date (test-calendar-sync-time-days-ago 200 11 0))
+ (until-date (test-calendar-sync-time-date-only-ago 100))
+ (base-event (list :summary "Past"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'monthly :interval 1 :until until-date))
+ (range (list (time-subtract (current-time) (* 30 86400))
+ (time-add (current-time) (* 365 86400))))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 0))))
+
+(ert-deftest test-calendar-sync--expand-monthly-error-no-end-time-still-works ()
+ "Test that event without :end still generates occurrences."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "No End" :start start-date))
+ (rrule (list :freq 'monthly :interval 1 :count 3))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-monthly base-event rrule range)))
+ (should (= (length occurrences) 3))))
+
+(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
new file mode 100644
index 00000000..ad9b8f27
--- /dev/null
+++ b/tests/test-calendar-sync--expand-yearly.el
@@ -0,0 +1,179 @@
+;;; test-calendar-sync--expand-yearly.el --- Tests for calendar-sync--expand-yearly -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Characterization tests for calendar-sync--expand-yearly.
+;; Captures current behavior before refactoring into unified expand function.
+;; Uses dynamic timestamps to avoid hardcoded dates.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-calendar-sync)
+(require 'calendar-sync)
+
+;;; Normal Cases
+
+(ert-deftest test-calendar-sync--expand-yearly-normal-generates-occurrences ()
+ "Test expanding yearly event generates occurrences within range."
+ (let* ((start-date (test-calendar-sync-time-days-ago 800 10 0))
+ (end-date (test-calendar-sync-time-days-ago 800 11 0))
+ (base-event (list :summary "Birthday"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ ;; Range covers ~1.25 years, event started ~2.2 years ago
+ ;; Should see 1-2 occurrences in range
+ (should (>= (length occurrences) 1))
+ (should (<= (length occurrences) 3))))
+
+(ert-deftest test-calendar-sync--expand-yearly-normal-preserves-date ()
+ "Test that each occurrence falls on the same month and day."
+ (let* ((start-date (test-calendar-sync-time-days-ago 1100 10 0))
+ (end-date (test-calendar-sync-time-days-ago 1100 11 0))
+ (expected-month (nth 1 start-date))
+ (expected-day (nth 2 start-date))
+ (base-event (list :summary "Anniversary"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (should (> (length occurrences) 0))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= (nth 1 s) expected-month))
+ (should (= (nth 2 s) expected-day))))))
+
+(ert-deftest test-calendar-sync--expand-yearly-normal-preserves-time ()
+ "Test that each occurrence preserves the original event time."
+ (let* ((start-date (test-calendar-sync-time-days-ago 400 15 30))
+ (end-date (test-calendar-sync-time-days-ago 400 16 30))
+ (base-event (list :summary "Yearly"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1 :count 5))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (dolist (occ occurrences)
+ (let ((s (plist-get occ :start)))
+ (should (= (nth 3 s) 15))
+ (should (= (nth 4 s) 30))))))
+
+(ert-deftest test-calendar-sync--expand-yearly-normal-interval-two ()
+ "Test expanding bi-yearly event."
+ (let* ((start-date (test-calendar-sync-time-days-ago 2000 10 0))
+ (end-date (test-calendar-sync-time-days-ago 2000 11 0))
+ (base-event (list :summary "Bi-Yearly"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 2))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ ;; Over ~5.5 years, bi-yearly = ~2-3 total, 0-1 in range
+ (should (<= (length occurrences) 2))))
+
+;;; Boundary Cases
+
+(ert-deftest test-calendar-sync--expand-yearly-boundary-count-limits-occurrences ()
+ "Test that COUNT limits the total number of occurrences."
+ (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))
+ (base-event (list :summary "Limited"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1 :count 3))
+ ;; Use a very wide range to capture all 3 years
+ (range (list (time-subtract (current-time) (* 90 86400))
+ (time-add (current-time) (* 1460 86400))))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (should (= (length occurrences) 3))))
+
+(ert-deftest test-calendar-sync--expand-yearly-boundary-until-limits-occurrences ()
+ "Test that UNTIL date stops expansion."
+ (let* ((start-date (test-calendar-sync-time-days-ago 1000 10 0))
+ (end-date (test-calendar-sync-time-days-ago 1000 11 0))
+ (until-date (test-calendar-sync-time-date-only 365))
+ (base-event (list :summary "Until-Limited"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1 :until until-date))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ ;; Should have occurrences within range but stopped by UNTIL
+ (should (>= (length occurrences) 1))
+ (should (<= (length occurrences) 3))))
+
+(ert-deftest test-calendar-sync--expand-yearly-boundary-count-one-returns-single ()
+ "Test that COUNT=1 returns exactly one occurrence."
+ (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))
+ (base-event (list :summary "Once"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1 :count 1))
+ (range (test-calendar-sync-wide-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (should (= (length occurrences) 1))))
+
+(ert-deftest test-calendar-sync--expand-yearly-boundary-respects-date-range ()
+ "Test that occurrences outside date range are excluded."
+ (let* ((start-date (test-calendar-sync-time-days-ago 1500 10 0))
+ (end-date (test-calendar-sync-time-days-ago 1500 11 0))
+ (base-event (list :summary "Ranged"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1))
+ (range (test-calendar-sync-narrow-range))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range))
+ (range-start (nth 0 range))
+ (range-end (nth 1 range)))
+ (dolist (occ occurrences)
+ (let* ((s (plist-get occ :start))
+ (occ-time (test-calendar-sync-date-to-time-value s)))
+ (should (time-less-p range-start occ-time))
+ (should (time-less-p occ-time range-end))))))
+
+(ert-deftest test-calendar-sync--expand-yearly-boundary-preserves-summary ()
+ "Test that each occurrence carries the original summary."
+ (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))
+ (base-event (list :summary "Annual Gala"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1 :count 2))
+ (range (list (time-subtract (current-time) (* 90 86400))
+ (time-add (current-time) (* 1460 86400))))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (dolist (occ occurrences)
+ (should (equal (plist-get occ :summary) "Annual Gala")))))
+
+;;; Error Cases
+
+(ert-deftest test-calendar-sync--expand-yearly-error-past-until-returns-empty ()
+ "Test that UNTIL in the past produces no occurrences in future range."
+ (let* ((start-date (test-calendar-sync-time-days-ago 1500 10 0))
+ (end-date (test-calendar-sync-time-days-ago 1500 11 0))
+ (until-date (test-calendar-sync-time-date-only-ago 365))
+ (base-event (list :summary "Past"
+ :start start-date
+ :end end-date))
+ (rrule (list :freq 'yearly :interval 1 :until until-date))
+ (range (list (time-subtract (current-time) (* 30 86400))
+ (time-add (current-time) (* 365 86400))))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (should (= (length occurrences) 0))))
+
+(ert-deftest test-calendar-sync--expand-yearly-error-no-end-time-still-works ()
+ "Test that event without :end still generates occurrences."
+ (let* ((start-date (test-calendar-sync-time-days-from-now 1 10 0))
+ (base-event (list :summary "No End" :start start-date))
+ (rrule (list :freq 'yearly :interval 1 :count 2))
+ (range (list (time-subtract (current-time) (* 90 86400))
+ (time-add (current-time) (* 1460 86400))))
+ (occurrences (calendar-sync--expand-yearly base-event rrule range)))
+ (should (= (length occurrences) 2))))
+
+(provide 'test-calendar-sync--expand-yearly)
+;;; test-calendar-sync--expand-yearly.el ends here
diff --git a/tests/test-music-config-random-navigation.el b/tests/test-music-config-random-navigation.el
new file mode 100644
index 00000000..efeea05c
--- /dev/null
+++ b/tests/test-music-config-random-navigation.el
@@ -0,0 +1,381 @@
+;;; test-music-config-random-navigation.el --- Tests for random-aware next/previous -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music-next, cj/music-previous, and the random
+;; history tracking mechanism.
+;;
+;; When emms-random-playlist is active, next should pick a random track
+;; (not sequential), and previous should navigate back through the
+;; history of recently played tracks.
+;;
+;; Test organization:
+;; - Normal Cases: next/previous in sequential mode, next/previous in random mode
+;; - Boundary Cases: empty history, single history entry, history at max capacity
+;; - Error Cases: previous with no history, no tracks in playlist
+;;
+;;; Code:
+
+(require 'ert)
+
+;; Stub missing dependencies before loading music-config
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+;; Add EMMS elpa directory to load path for batch testing
+(let ((emms-dir (car (file-expand-wildcards
+ (expand-file-name "elpa/emms-*" user-emacs-directory)))))
+ (when emms-dir
+ (add-to-list 'load-path emms-dir)))
+
+;; Load EMMS for playlist buffer setup
+(require 'emms)
+(require 'emms-playlist-mode)
+
+;; Load production code
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-randnav--setup-playlist-buffer (track-names)
+ "Create an EMMS playlist buffer with TRACK-NAMES and return it.
+Each entry in TRACK-NAMES becomes a file track in the playlist."
+ (let ((buf (get-buffer-create "*EMMS-Test-Playlist*")))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (dolist (name track-names)
+ (emms-playlist-insert-track (emms-track 'file name)))))
+ buf))
+
+(defun test-randnav--teardown ()
+ "Clean up random navigation state and test buffers."
+ (setq cj/music--random-history nil)
+ (setq emms-random-playlist nil)
+ (when-let ((buf (get-buffer "*EMMS-Test-Playlist*")))
+ (kill-buffer buf)))
+
+(defun test-randnav--capture-message (fn &rest args)
+ "Call FN with ARGS and return the message it produces."
+ (let ((captured nil))
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest msg-args)
+ (setq captured (apply #'format fmt msg-args)))))
+ (apply fn args))
+ captured))
+
+;;; Normal Cases - cj/music-next
+
+(ert-deftest test-music-config-next-normal-sequential-calls-emms-next ()
+ "Validate cj/music-next calls emms-next when random mode is off."
+ (unwind-protect
+ (let ((emms-random-playlist nil)
+ (called nil))
+ (cl-letf (((symbol-function 'emms-next)
+ (lambda () (setq called t))))
+ (cj/music-next)
+ (should called)))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-next-normal-random-calls-emms-random ()
+ "Validate cj/music-next calls emms-random when random mode is on."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (called nil))
+ (cl-letf (((symbol-function 'emms-random)
+ (lambda () (setq called t))))
+ (cj/music-next)
+ (should called)))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-next-normal-sequential-does-not-call-random ()
+ "Validate cj/music-next does not call emms-random when random mode is off."
+ (unwind-protect
+ (let ((emms-random-playlist nil)
+ (random-called nil))
+ (cl-letf (((symbol-function 'emms-next)
+ (lambda () nil))
+ ((symbol-function 'emms-random)
+ (lambda () (setq random-called t))))
+ (cj/music-next)
+ (should-not random-called)))
+ (test-randnav--teardown)))
+
+;;; Normal Cases - cj/music-previous
+
+(ert-deftest test-music-config-previous-normal-sequential-calls-emms-previous ()
+ "Validate cj/music-previous calls emms-previous when random mode is off."
+ (unwind-protect
+ (let ((emms-random-playlist nil)
+ (called nil))
+ (cl-letf (((symbol-function 'emms-previous)
+ (lambda () (setq called t))))
+ (cj/music-previous)
+ (should called)))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-previous-normal-random-selects-history-track ()
+ "Validate cj/music-previous selects the history track within the existing playlist."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3")))
+ (emms-random-playlist t)
+ (cj/music--random-history (list "/music/track2.mp3" "/music/track1.mp3"))
+ (selected-pos nil))
+ (cl-letf (((symbol-function 'emms-playlist-select)
+ (lambda (pos) (setq selected-pos pos)))
+ ((symbol-function 'emms-start)
+ (lambda () nil)))
+ (cj/music-previous)
+ ;; Should have selected a position in the buffer
+ (should selected-pos)
+ ;; The track at that position should be track2
+ (with-current-buffer buf
+ (goto-char selected-pos)
+ (let ((track (emms-playlist-track-at (point))))
+ (should (equal (emms-track-name track) "/music/track2.mp3"))))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-previous-normal-random-pops-history ()
+ "Validate cj/music-previous removes the played track from history."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3")))
+ (emms-random-playlist t)
+ (cj/music--random-history (list "/music/track2.mp3" "/music/track1.mp3")))
+ (cl-letf (((symbol-function 'emms-playlist-select)
+ (lambda (_pos) nil))
+ ((symbol-function 'emms-start)
+ (lambda () nil)))
+ (cj/music-previous)
+ (should (equal cj/music--random-history '("/music/track1.mp3")))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-previous-normal-sequential-does-not-touch-history ()
+ "Validate cj/music-previous in sequential mode ignores history."
+ (unwind-protect
+ (let ((emms-random-playlist nil)
+ (cj/music--random-history (list "/music/track2.mp3" "/music/track1.mp3")))
+ (cl-letf (((symbol-function 'emms-previous)
+ (lambda () nil)))
+ (cj/music-previous)
+ (should (equal cj/music--random-history
+ '("/music/track2.mp3" "/music/track1.mp3")))))
+ (test-randnav--teardown)))
+
+;;; Normal Cases - cj/music--record-random-history
+
+(ert-deftest test-music-config--record-random-history-normal-pushes-track ()
+ "Validate recording history pushes current track name onto the list."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history nil))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () (emms-track 'file "/music/track1.mp3"))))
+ (cj/music--record-random-history)
+ (should (equal cj/music--random-history '("/music/track1.mp3")))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--record-random-history-normal-preserves-order ()
+ "Validate history maintains most-recent-first order."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history '("/music/track1.mp3")))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () (emms-track 'file "/music/track2.mp3"))))
+ (cj/music--record-random-history)
+ (should (equal (car cj/music--random-history) "/music/track2.mp3"))
+ (should (equal (cadr cj/music--random-history) "/music/track1.mp3"))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--record-random-history-normal-skips-when-sequential ()
+ "Validate recording is skipped when random mode is off."
+ (unwind-protect
+ (let ((emms-random-playlist nil)
+ (cj/music--random-history nil))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () (emms-track 'file "/music/track1.mp3"))))
+ (cj/music--record-random-history)
+ (should (null cj/music--random-history))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--record-random-history-normal-skips-duplicate ()
+ "Validate recording skips if current track is already at head of history."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history '("/music/track1.mp3")))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () (emms-track 'file "/music/track1.mp3"))))
+ (cj/music--record-random-history)
+ (should (= 1 (length cj/music--random-history)))))
+ (test-randnav--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config-previous-boundary-empty-history-falls-back ()
+ "Validate cj/music-previous falls back to emms-previous when history is empty."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history nil)
+ (fallback-called nil))
+ (cl-letf (((symbol-function 'emms-previous)
+ (lambda () (setq fallback-called t))))
+ (cj/music-previous)
+ (should fallback-called)))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-previous-boundary-empty-history-messages ()
+ "Validate cj/music-previous shows message when history is empty in random mode."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history nil))
+ (cl-letf (((symbol-function 'emms-previous)
+ (lambda () nil)))
+ (let ((msg (test-randnav--capture-message #'cj/music-previous)))
+ (should (stringp msg))
+ (should (string-match-p "history" msg)))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-previous-boundary-single-entry-empties-history ()
+ "Validate popping the last history entry leaves history empty."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3")))
+ (emms-random-playlist t)
+ (cj/music--random-history (list "/music/track1.mp3")))
+ (cl-letf (((symbol-function 'emms-playlist-select)
+ (lambda (_pos) nil))
+ ((symbol-function 'emms-start)
+ (lambda () nil)))
+ (cj/music-previous)
+ (should (null cj/music--random-history))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--record-random-history-boundary-caps-at-max ()
+ "Validate history never exceeds cj/music--random-history-max entries."
+ (unwind-protect
+ (let* ((emms-random-playlist t)
+ (cj/music--random-history-max 5)
+ (cj/music--random-history (number-sequence 1 5)))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () (emms-track 'file "/music/new.mp3"))))
+ (cj/music--record-random-history)
+ (should (= (length cj/music--random-history) 5))
+ (should (equal (car cj/music--random-history) "/music/new.mp3"))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--record-random-history-boundary-drops-oldest ()
+ "Validate that when history is full, the oldest entry is dropped."
+ (unwind-protect
+ (let* ((emms-random-playlist t)
+ (cj/music--random-history-max 3)
+ (cj/music--random-history '("/music/c.mp3" "/music/b.mp3" "/music/a.mp3")))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () (emms-track 'file "/music/d.mp3"))))
+ (cj/music--record-random-history)
+ (should (= (length cj/music--random-history) 3))
+ (should (equal (car cj/music--random-history) "/music/d.mp3"))
+ (should-not (member "/music/a.mp3" cj/music--random-history))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--record-random-history-boundary-no-track-no-crash ()
+ "Validate recording handles nil current track without error."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history nil))
+ (cl-letf (((symbol-function 'emms-playlist-current-selected-track)
+ (lambda () nil)))
+ (cj/music--record-random-history)
+ (should (null cj/music--random-history))))
+ (test-randnav--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config-previous-error-random-no-history-no-crash ()
+ "Validate cj/music-previous does not crash when random is on but history is nil."
+ (unwind-protect
+ (let ((emms-random-playlist t)
+ (cj/music--random-history nil))
+ (cl-letf (((symbol-function 'emms-previous)
+ (lambda () nil)))
+ (should-not (condition-case err
+ (progn (cj/music-previous) nil)
+ (error err)))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config-previous-error-track-not-in-playlist-messages ()
+ "Validate cj/music-previous shows message when history track is no longer in playlist."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track3.mp3")))
+ (emms-random-playlist t)
+ (cj/music--random-history (list "/music/gone.mp3")))
+ (let ((msg (test-randnav--capture-message #'cj/music-previous)))
+ (should (stringp msg))
+ (should (string-match-p "no longer" msg))))
+ (test-randnav--teardown)))
+
+;;; Normal Cases - cj/music--find-track-in-playlist
+
+(ert-deftest test-music-config--find-track-in-playlist-normal-returns-position ()
+ "Validate finding a track returns its buffer position."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3"))))
+ (let ((pos (cj/music--find-track-in-playlist "/music/track2.mp3")))
+ (should pos)
+ (with-current-buffer buf
+ (goto-char pos)
+ (should (equal (emms-track-name (emms-playlist-track-at (point)))
+ "/music/track2.mp3")))))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--find-track-in-playlist-normal-finds-first-track ()
+ "Validate finding the first track in the playlist."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3"))))
+ (let ((pos (cj/music--find-track-in-playlist "/music/track1.mp3")))
+ (should pos)))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--find-track-in-playlist-normal-finds-last-track ()
+ "Validate finding the last track in the playlist."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3"))))
+ (let ((pos (cj/music--find-track-in-playlist "/music/track3.mp3")))
+ (should pos)))
+ (test-randnav--teardown)))
+
+;;; Boundary Cases - cj/music--find-track-in-playlist
+
+(ert-deftest test-music-config--find-track-in-playlist-boundary-not-found-returns-nil ()
+ "Validate returning nil when track is not in playlist."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3"))))
+ (should-not (cj/music--find-track-in-playlist "/music/gone.mp3")))
+ (test-randnav--teardown)))
+
+(ert-deftest test-music-config--find-track-in-playlist-boundary-empty-playlist ()
+ "Validate returning nil for an empty playlist."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (_buf (test-randnav--setup-playlist-buffer '())))
+ (should-not (cj/music--find-track-in-playlist "/music/track1.mp3")))
+ (test-randnav--teardown)))
+
+(provide 'test-music-config-random-navigation)
+;;; test-music-config-random-navigation.el ends here