From eb01b3d24739e916d9dca33f5f039650a9de8457 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Fri, 3 Apr 2026 08:33:33 -0500 Subject: feat(music): add random-aware next/previous; refactor music + calendar-sync 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. --- tests/test-calendar-sync--expand-daily.el | 180 +++++++++++++ tests/test-calendar-sync--expand-monthly.el | 174 ++++++++++++ tests/test-calendar-sync--expand-yearly.el | 179 +++++++++++++ tests/test-music-config-random-navigation.el | 381 +++++++++++++++++++++++++++ 4 files changed, 914 insertions(+) create mode 100644 tests/test-calendar-sync--expand-daily.el create mode 100644 tests/test-calendar-sync--expand-monthly.el create mode 100644 tests/test-calendar-sync--expand-yearly.el create mode 100644 tests/test-music-config-random-navigation.el (limited to 'tests') 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 +;; +;;; 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 -- cgit v1.2.3