diff options
| -rw-r--r-- | modules/calendar-sync.el | 228 | ||||
| -rw-r--r-- | modules/music-config.el | 82 | ||||
| -rw-r--r-- | tests/test-calendar-sync--expand-daily.el | 180 | ||||
| -rw-r--r-- | tests/test-calendar-sync--expand-monthly.el | 174 | ||||
| -rw-r--r-- | tests/test-calendar-sync--expand-yearly.el | 179 | ||||
| -rw-r--r-- | tests/test-music-config-random-navigation.el | 381 |
6 files changed, 1050 insertions, 174 deletions
diff --git a/modules/calendar-sync.el b/modules/calendar-sync.el index 610281d2..fc9736b5 100644 --- a/modules/calendar-sync.el +++ b/modules/calendar-sync.el @@ -69,7 +69,6 @@ ;;; Code: -(require 'org) (require 'user-constants) ; For gcal-file, pcal-file paths ;;; Configuration @@ -383,28 +382,32 @@ Returns nil if not found." (when (and event-str (stringp event-str)) (calendar-sync--get-property-line event-str "RECURRENCE-ID"))) -(defun calendar-sync--parse-recurrence-id (recurrence-id-value) - "Parse RECURRENCE-ID-VALUE into (year month day hour minute) list. -Returns nil for invalid input. For date-only values, returns (year month day nil nil)." - (when (and recurrence-id-value - (stringp recurrence-id-value) - (not (string-empty-p recurrence-id-value))) +(defun calendar-sync--parse-ics-datetime (value) + "Parse iCal datetime VALUE into (year month day hour minute) list. +Returns nil for invalid input. For date-only values, returns (year month day nil nil). +Handles formats: 20260203T090000Z, 20260203T090000, 20260203." + (when (and value + (stringp value) + (not (string-empty-p value))) (cond ;; DateTime format: 20260203T090000Z or 20260203T090000 - ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" recurrence-id-value) - (list (string-to-number (match-string 1 recurrence-id-value)) - (string-to-number (match-string 2 recurrence-id-value)) - (string-to-number (match-string 3 recurrence-id-value)) - (string-to-number (match-string 4 recurrence-id-value)) - (string-to-number (match-string 5 recurrence-id-value)))) + ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" value) + (list (string-to-number (match-string 1 value)) + (string-to-number (match-string 2 value)) + (string-to-number (match-string 3 value)) + (string-to-number (match-string 4 value)) + (string-to-number (match-string 5 value)))) ;; Date-only format: 20260203 - ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" recurrence-id-value) - (list (string-to-number (match-string 1 recurrence-id-value)) - (string-to-number (match-string 2 recurrence-id-value)) - (string-to-number (match-string 3 recurrence-id-value)) + ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" value) + (list (string-to-number (match-string 1 value)) + (string-to-number (match-string 2 value)) + (string-to-number (match-string 3 value)) nil nil)) (t nil)))) +(defalias 'calendar-sync--parse-recurrence-id #'calendar-sync--parse-ics-datetime + "Parse RECURRENCE-ID value. See `calendar-sync--parse-ics-datetime'.") + (defun calendar-sync--collect-recurrence-exceptions (ics-content) "Collect all RECURRENCE-ID events from ICS-CONTENT. Returns hash table mapping UID to list of exception event plists. @@ -438,31 +441,9 @@ Each exception plist contains :recurrence-id (parsed), :start, :end, :summary, e (location (calendar-sync--clean-text (calendar-sync--get-property event-str "LOCATION")))) (when (and recurrence-id-parsed start-parsed) - ;; Convert RECURRENCE-ID to local time - ;; Handle: UTC (Z suffix), TZID, or assume local (let ((local-recurrence-id - (cond - ;; UTC time (Z suffix) - convert from UTC - (recurrence-id-is-utc - (calendar-sync--convert-utc-to-local - (nth 0 recurrence-id-parsed) - (nth 1 recurrence-id-parsed) - (nth 2 recurrence-id-parsed) - (or (nth 3 recurrence-id-parsed) 0) - (or (nth 4 recurrence-id-parsed) 0) - 0)) ; seconds - ;; TZID specified - convert from that timezone - (recurrence-id-tzid - (or (calendar-sync--convert-tz-to-local - (nth 0 recurrence-id-parsed) - (nth 1 recurrence-id-parsed) - (nth 2 recurrence-id-parsed) - (or (nth 3 recurrence-id-parsed) 0) - (or (nth 4 recurrence-id-parsed) 0) - recurrence-id-tzid) - recurrence-id-parsed)) - ;; No timezone info - assume local - (t recurrence-id-parsed)))) + (calendar-sync--localize-parsed-datetime + recurrence-id-parsed recurrence-id-is-utc recurrence-id-tzid))) (let ((exception-plist (list :recurrence-id local-recurrence-id :recurrence-id-raw recurrence-id @@ -562,27 +543,8 @@ Returns nil if not found." (when (string-match pattern event-str) (match-string 1 event-str))))) -(defun calendar-sync--parse-exdate (exdate-value) - "Parse EXDATE-VALUE into (year month day hour minute) list. -Returns nil for invalid input. For date-only values, returns (year month day nil nil)." - (when (and exdate-value - (stringp exdate-value) - (not (string-empty-p exdate-value))) - (cond - ;; DateTime format: 20260203T130000Z or 20260203T130000 - ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)T\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)Z?\\'" exdate-value) - (list (string-to-number (match-string 1 exdate-value)) - (string-to-number (match-string 2 exdate-value)) - (string-to-number (match-string 3 exdate-value)) - (string-to-number (match-string 4 exdate-value)) - (string-to-number (match-string 5 exdate-value)))) - ;; Date-only format: 20260203 - ((string-match "\\`\\([0-9]\\{4\\}\\)\\([0-9]\\{2\\}\\)\\([0-9]\\{2\\}\\)\\'" exdate-value) - (list (string-to-number (match-string 1 exdate-value)) - (string-to-number (match-string 2 exdate-value)) - (string-to-number (match-string 3 exdate-value)) - nil nil)) - (t nil)))) +(defalias 'calendar-sync--parse-exdate #'calendar-sync--parse-ics-datetime + "Parse EXDATE value. See `calendar-sync--parse-ics-datetime'.") (defun calendar-sync--collect-exdates (event-str) "Collect all excluded dates from EVENT-STR, handling timezone conversion. @@ -600,30 +562,9 @@ Converts TZID-qualified and UTC times to local time." (exdate-is-utc (and exdate-value (string-suffix-p "Z" exdate-value))) (exdate-parsed (calendar-sync--parse-exdate exdate-value))) (when exdate-parsed - (let ((local-exdate - (cond - ;; UTC time (Z suffix) - convert from UTC - (exdate-is-utc - (calendar-sync--convert-utc-to-local - (nth 0 exdate-parsed) - (nth 1 exdate-parsed) - (nth 2 exdate-parsed) - (or (nth 3 exdate-parsed) 0) - (or (nth 4 exdate-parsed) 0) - 0)) - ;; TZID specified - convert from that timezone - (exdate-tzid - (or (calendar-sync--convert-tz-to-local - (nth 0 exdate-parsed) - (nth 1 exdate-parsed) - (nth 2 exdate-parsed) - (or (nth 3 exdate-parsed) 0) - (or (nth 4 exdate-parsed) 0) - exdate-tzid) - exdate-parsed)) - ;; No timezone info - use as-is (local time) - (t exdate-parsed)))) - (push local-exdate result))))) + (push (calendar-sync--localize-parsed-datetime + exdate-parsed exdate-is-utc exdate-tzid) + result)))) (nreverse result)))) (defun calendar-sync--exdate-matches-p (occurrence-start exdate) @@ -835,6 +776,25 @@ TZ database as the `date' command." source-tz (error-message-string err)) nil)))) +(defun calendar-sync--localize-parsed-datetime (parsed is-utc tzid) + "Convert PARSED datetime to local time using timezone info. +PARSED is (year month day hour minute) or (year month day nil nil). +IS-UTC non-nil means the value had a Z suffix. +TZID is a timezone string like \"Europe/Lisbon\", or nil. +Returns PARSED converted to local time, or PARSED unchanged if no conversion needed." + (cond + (is-utc + (calendar-sync--convert-utc-to-local + (nth 0 parsed) (nth 1 parsed) (nth 2 parsed) + (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) 0)) + (tzid + (or (calendar-sync--convert-tz-to-local + (nth 0 parsed) (nth 1 parsed) (nth 2 parsed) + (or (nth 3 parsed) 0) (or (nth 4 parsed) 0) + tzid) + parsed)) + (t parsed))) + (defun calendar-sync--parse-timestamp (timestamp-str &optional tzid) "Parse iCal timestamp string TIMESTAMP-STR. Returns (year month day hour minute) or (year month day) for all-day events. @@ -942,9 +902,10 @@ Returns plist with :freq :interval :byday :until :count." (setq result (plist-put result :interval 1))) result)) -(defun calendar-sync--expand-daily (base-event rrule range) - "Expand daily recurring event. -BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." +(defun calendar-sync--expand-simple-recurrence (base-event rrule range advance-fn) + "Expand a simple (non-weekly) recurring event using ADVANCE-FN to step dates. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range. +ADVANCE-FN takes (current-date interval) and returns the next date." (let* ((start (plist-get base-event :start)) (interval (plist-get rrule :interval)) (until (plist-get rrule :until)) @@ -953,29 +914,27 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) (num-generated 0) (range-end-time (cadr range))) - ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance - ;; For COUNT, generate all occurrences from start regardless of range (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) (or (not until) (calendar-sync--before-date-p current-date until)) (or (not count) (< num-generated count))) (let ((occurrence-datetime (append current-date (nthcdr 3 start)))) - ;; Check UNTIL date first - (when (or (not until) (calendar-sync--before-date-p current-date until)) - ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start - (when (or (not count) (< num-generated count)) - (setq num-generated (1+ num-generated)) - ;; Only add to output if within date range - (when (calendar-sync--date-in-range-p occurrence-datetime range) - (push (calendar-sync--create-occurrence base-event occurrence-datetime) - occurrences))))) - (setq current-date (calendar-sync--add-days current-date interval))) + (setq num-generated (1+ num-generated)) + (when (calendar-sync--date-in-range-p occurrence-datetime range) + (push (calendar-sync--create-occurrence base-event occurrence-datetime) + occurrences))) + (setq current-date (funcall advance-fn current-date interval))) (nreverse occurrences))) +(defun calendar-sync--expand-daily (base-event rrule range) + "Expand daily recurring event. +BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." + (calendar-sync--expand-simple-recurrence + base-event rrule range #'calendar-sync--add-days)) + (defun calendar-sync--expand-weekly (base-event rrule range) "Expand weekly recurring event. BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." (let* ((start (plist-get base-event :start)) - (end (plist-get base-event :end)) (interval (plist-get rrule :interval)) (byday (plist-get rrule :byday)) (until (plist-get rrule :until)) @@ -1024,60 +983,15 @@ BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." (defun calendar-sync--expand-monthly (base-event rrule range) "Expand monthly recurring event. BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." - (let* ((start (plist-get base-event :start)) - (interval (plist-get rrule :interval)) - (until (plist-get rrule :until)) - (count (plist-get rrule :count)) - (occurrences '()) - (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) - (num-generated 0) - (range-end-time (cadr range))) - ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance - ;; For COUNT, generate all occurrences from start regardless of range - (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) - (or (not until) (calendar-sync--before-date-p current-date until)) - (or (not count) (< num-generated count))) - (let ((occurrence-datetime (append current-date (nthcdr 3 start)))) - ;; Check UNTIL date first - (when (or (not until) (calendar-sync--before-date-p current-date until)) - ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start - (when (or (not count) (< num-generated count)) - (setq num-generated (1+ num-generated)) - ;; Only add to output if within date range - (when (calendar-sync--date-in-range-p occurrence-datetime range) - (push (calendar-sync--create-occurrence base-event occurrence-datetime) - occurrences))))) - (setq current-date (calendar-sync--add-months current-date interval))) - (nreverse occurrences))) + (calendar-sync--expand-simple-recurrence + base-event rrule range #'calendar-sync--add-months)) (defun calendar-sync--expand-yearly (base-event rrule range) "Expand yearly recurring event. BASE-EVENT is the event plist, RRULE is parsed rrule, RANGE is date range." - (let* ((start (plist-get base-event :start)) - (interval (plist-get rrule :interval)) - (until (plist-get rrule :until)) - (count (plist-get rrule :count)) - (occurrences '()) - (current-date (list (nth 0 start) (nth 1 start) (nth 2 start))) - (num-generated 0) - (range-end-time (cadr range))) - ;; For infinite recurrence (no COUNT/UNTIL), stop at range-end for performance - ;; For COUNT, generate all occurrences from start regardless of range - (while (and (or count until (time-less-p (calendar-sync--date-to-time current-date) range-end-time)) - (or (not until) (calendar-sync--before-date-p current-date until)) - (or (not count) (< num-generated count))) - (let ((occurrence-datetime (append current-date (nthcdr 3 start)))) - ;; Check UNTIL date first - (when (or (not until) (calendar-sync--before-date-p current-date until)) - ;; Check COUNT - increment BEFORE range check so COUNT is absolute from start - (when (or (not count) (< num-generated count)) - (setq num-generated (1+ num-generated)) - ;; Only add to output if within date range - (when (calendar-sync--date-in-range-p occurrence-datetime range) - (push (calendar-sync--create-occurrence base-event occurrence-datetime) - occurrences))))) - (setq current-date (calendar-sync--add-months current-date (* 12 interval)))) - (nreverse occurrences))) + (calendar-sync--expand-simple-recurrence + base-event rrule range + (lambda (date interval) (calendar-sync--add-months date (* 12 interval))))) (defun calendar-sync--expand-recurring-event (event-str range) "Expand recurring event EVENT-STR into individual occurrences within RANGE. @@ -1253,8 +1167,7 @@ RECURRENCE-ID exceptions are applied to override specific occurrences." "\n") nil))) (error - (setq calendar-sync--last-error (error-message-string err)) - (cj/log-silently "calendar-sync: Parse error: %s" calendar-sync--last-error) + (cj/log-silently "calendar-sync: Parse error: %s" (error-message-string err)) nil))) ;;; Sync functions @@ -1285,15 +1198,12 @@ invoked when the fetch completes, either successfully or with an error." (if (and (eq (process-status process) 'exit) (= (process-exit-status process) 0)) (calendar-sync--normalize-line-endings (buffer-string)) - (setq calendar-sync--last-error - (format "curl failed: %s" (string-trim event))) - (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error) + (cj/log-silently "calendar-sync: Fetch error: curl failed: %s" (string-trim event)) nil)))) (kill-buffer buf) (funcall callback content)))))))) (error - (setq calendar-sync--last-error (error-message-string err)) - (cj/log-silently "calendar-sync: Fetch error: %s" calendar-sync--last-error) + (cj/log-silently "calendar-sync: Fetch error: %s" (error-message-string err)) (funcall callback nil)))) (defun calendar-sync--write-file (content file) diff --git a/modules/music-config.el b/modules/music-config.el index 46474a23..08ce0658 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -408,6 +408,62 @@ Offers completion over existing names but allows new names." (user-error "Playlist file no longer exists: %s" (file-name-nondirectory path)))))) +;;; Commands: random-aware navigation + +(defvar cj/music--random-history nil + "List of recently played track names during random mode, most recent first.") + +(defvar cj/music--random-history-max 50 + "Maximum number of tracks to keep in random playback history.") + +(defun cj/music--record-random-history () + "Push the current track onto random history if random mode is active. +Intended for use on `emms-player-started-hook'." + (when emms-random-playlist + (when-let ((track (emms-playlist-current-selected-track))) + (let ((name (emms-track-name track))) + (unless (equal name (car cj/music--random-history)) + (push name cj/music--random-history) + (when (> (length cj/music--random-history) cj/music--random-history-max) + (setq cj/music--random-history + (seq-take cj/music--random-history cj/music--random-history-max)))))))) + +(defun cj/music-next () + "Play next track. Respects random mode — picks a random track if active." + (interactive) + (if emms-random-playlist + (emms-random) + (emms-next))) + +(defun cj/music--find-track-in-playlist (track-name) + "Return buffer position of TRACK-NAME in the current playlist, or nil." + (with-current-buffer (cj/music--ensure-playlist-buffer) + (save-excursion + (goto-char (point-min)) + (let ((found nil)) + (while (and (not found) (not (eobp))) + (when-let ((track (emms-playlist-track-at (point)))) + (when (equal (emms-track-name track) track-name) + (setq found (point)))) + (unless found (forward-line 1))) + found)))) + +(defun cj/music-previous () + "Play previous track. In random mode, go back through playback history." + (interactive) + (if (and emms-random-playlist cj/music--random-history) + (let* ((track-name (pop cj/music--random-history)) + (pos (cj/music--find-track-in-playlist track-name))) + (if pos + (progn + (emms-playlist-select pos) + (emms-start)) + (message "Track no longer in playlist: %s" + (file-name-nondirectory track-name)))) + (when (and emms-random-playlist (null cj/music--random-history)) + (message "No random history to go back to")) + (emms-previous))) + ;;; Commands: consume mode (defvar cj/music-consume-mode nil @@ -443,8 +499,7 @@ Intended for use on `emms-player-finished-hook'." (unless (featurep 'emms) (require 'emms)) - (emms-playing-time-disable-display) - (emms-mode-line-mode -1)) +) (defun cj/music-playlist-toggle () @@ -525,8 +580,8 @@ Dirs added recursively." "R" #'cj/music-create-radio-station "SPC" #'emms-pause "s" #'emms-stop - "n" #'emms-next - "p" #'emms-previous + "n" #'cj/music-next + "p" #'cj/music-previous "g" #'emms-playlist-mode-go "Z" #'emms-shuffle "r" #'emms-toggle-repeat-playlist @@ -587,11 +642,9 @@ Dirs added recursively." ;; Update supported file types for mpv player (setq emms-player-mpv-regexp - (rx (or - ;; Stream URLs - (seq bos (or "http" "https" "mms") "://") - ;; Local music files by extension - (seq "." (or "aac" "flac" "m4a" "mp3" "ogg" "opus" "wav") eos)))) + (concat "\\(?:\\`\\(?:https?\\|mms\\)://\\)\\|\\(?:\\." + (regexp-opt cj/music-file-extensions) + "\\'\\)")) ;; Keep cj/music-playlist-file in sync if playlist is cleared (defun cj/music--after-playlist-clear (&rest _) @@ -770,6 +823,7 @@ For URL tracks: decoded URL." (add-hook 'window-selection-change-functions #'cj/music--update-active-bg nil t)) (add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display) + (add-hook 'emms-player-started-hook #'cj/music--record-random-history) (add-hook 'emms-player-started-hook #'cj/music--update-header) (add-hook 'emms-player-stopped-hook #'cj/music--update-header) (add-hook 'emms-player-paused-hook #'cj/music--update-header) @@ -789,10 +843,10 @@ For URL tracks: decoded URL." ("p" . emms-playlist-mode-go) ("SPC" . emms-pause) ("s" . emms-stop) - ("n" . emms-next) - (">" . emms-next) - ("P" . emms-previous) - ("<" . emms-previous) + ("n" . cj/music-next) + (">" . cj/music-next) + ("P" . cj/music-previous) + ("<" . cj/music-previous) ("f" . emms-seek-forward) ("b" . emms-seek-backward) ("q" . emms-playlist-mode-bury-buffer) @@ -826,8 +880,6 @@ For URL tracks: decoded URL." ("=" . emms-volume-raise) ("-" . emms-volume-lower))) -;; Quick toggle key - use autoload to avoid loading emms at startup -(autoload 'cj/music-playlist-toggle "music-config" "Toggle EMMS playlist window." t) (keymap-global-set "<f10>" #'cj/music-playlist-toggle) ;;; Radio station creation 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 |
