summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/music-config.el72
-rw-r--r--tests/test-music-config-toggle-consume.el262
2 files changed, 322 insertions, 12 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index a3960440..a488bf4e 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -2,7 +2,7 @@
;;
;;; Commentary:
;;
-;; Comprehensive music management in Emacs via EMMS with MPV backend.
+;; Music management in Emacs via EMMS with MPV backend.
;; Focus: simple, modular helpers; consistent error handling; streamlined UX.
;;
;; Highlights:
@@ -331,6 +331,32 @@ Offers completion over existing names but allows new names."
(user-error "Playlist file no longer exists: %s"
(file-name-nondirectory path))))))
+;;; Commands: consume mode
+
+(defvar cj/music-consume-mode nil
+ "Non-nil means consume mode is active.
+When enabled, tracks are removed from the playlist after they finish playing.")
+
+(defun cj/music--consume-track ()
+ "Remove the just-finished track from the playlist.
+Intended for use on `emms-player-finished-hook'."
+ (when cj/music-consume-mode
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (when (and emms-playlist-selected-marker
+ (marker-position emms-playlist-selected-marker))
+ (save-excursion
+ (goto-char emms-playlist-selected-marker)
+ (emms-playlist-mode-kill-track))))))
+
+(defun cj/music-toggle-consume ()
+ "Toggle consume mode. When active, tracks are removed after playing."
+ (interactive)
+ (setq cj/music-consume-mode (not cj/music-consume-mode))
+ (if cj/music-consume-mode
+ (add-hook 'emms-player-finished-hook #'cj/music--consume-track)
+ (remove-hook 'emms-player-finished-hook #'cj/music--consume-track))
+ (message "Consume mode %s" (if cj/music-consume-mode "enabled" "disabled")))
+
;;; Commands: UI
;;; Minimal ensure-loaded setup for on-demand use
@@ -419,13 +445,17 @@ Dirs added recursively."
"m" #'cj/music-playlist-toggle
"M" #'cj/music-playlist-show
"a" #'cj/music-fuzzy-select-and-add
- "r" #'cj/music-create-radio-station
+ "R" #'cj/music-create-radio-station
"SPC" #'emms-pause
"s" #'emms-stop
"n" #'emms-next
"p" #'emms-previous
"g" #'emms-playlist-mode-go
- "x" #'emms-shuffle)
+ "Z" #'emms-shuffle
+ "r" #'emms-toggle-repeat-playlist
+ "t" #'emms-toggle-repeat-track
+ "z" #'emms-toggle-random-playlist
+ "x" #'cj/music-toggle-consume)
(keymap-set cj/custom-keymap "m" cj/music-map)
(with-eval-after-load 'which-key
@@ -434,13 +464,17 @@ Dirs added recursively."
"C-; m m" "toggle playlist"
"C-; m M" "show playlist"
"C-; m a" "add music"
- "C-; m r" "create radio"
+ "C-; m R" "create radio"
"C-; m SPC" "pause"
"C-; m s" "stop"
"C-; m n" "next track"
"C-; m p" "previous track"
"C-; m g" "goto playlist"
- "C-; m x" "shuffle"))
+ "C-; m Z" "shuffle"
+ "C-; m r" "repeat playlist"
+ "C-; m t" "repeat track"
+ "C-; m z" "random"
+ "C-; m x" "consume"))
(use-package emms
:defer t
@@ -499,27 +533,41 @@ Dirs added recursively."
("SPC" . emms-pause)
("s" . emms-stop)
("n" . emms-next)
+ (">" . emms-next)
("P" . emms-previous)
+ ("<" . emms-previous)
("f" . emms-seek-forward)
("b" . emms-seek-backward)
- ("x" . emms-shuffle)
("q" . emms-playlist-mode-bury-buffer)
("a" . cj/music-fuzzy-select-and-add)
+ ;; Toggles (aligned with ncmpcpp)
+ ("r" . emms-toggle-repeat-playlist)
+ ("t" . emms-toggle-repeat-track)
+ ("z" . emms-toggle-random-playlist)
+ ("x" . cj/music-toggle-consume)
+ ("Z" . emms-shuffle)
+ ;; Info
+ ("i" . emms-show)
+ ("o" . emms-playlist-mode-center-current)
;; Manipulation
("A" . cj/music-append-track-to-playlist)
+ ("c" . cj/music-playlist-clear)
("C" . cj/music-playlist-clear)
("L" . cj/music-playlist-load)
("E" . cj/music-playlist-edit)
- ("R" . cj/music-playlist-reload)
+ ("g" . cj/music-playlist-reload)
("S" . cj/music-playlist-save)
- ;; Track reordering (bind directly to EMMS commands; no wrappers)
+ ;; Track reordering
+ ("S-<up>" . emms-playlist-mode-shift-track-up)
+ ("S-<down>" . emms-playlist-mode-shift-track-down)
("C-<up>" . emms-playlist-mode-shift-track-up)
("C-<down>" . emms-playlist-mode-shift-track-down)
;; Radio
- ("r" . cj/music-create-radio-station)
- ;; Volume (MPV)
- ("-" . emms-volume-lower)
- ("=" . emms-volume-raise)))
+ ("R" . cj/music-create-radio-station)
+ ;; Volume
+ ("+" . emms-volume-raise)
+ ("=" . 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)
diff --git a/tests/test-music-config-toggle-consume.el b/tests/test-music-config-toggle-consume.el
new file mode 100644
index 00000000..c2c38d34
--- /dev/null
+++ b/tests/test-music-config-toggle-consume.el
@@ -0,0 +1,262 @@
+;;; test-music-config-toggle-consume.el --- Tests for consume mode -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music-toggle-consume and cj/music--consume-track.
+;;
+;; cj/music-toggle-consume toggles consume mode on/off, managing the
+;; emms-player-finished-hook to remove tracks after playback.
+;;
+;; cj/music--consume-track is the hook function that removes the track
+;; at the selected marker position from the playlist buffer.
+;;
+;; Test organization:
+;; - Normal Cases: Toggle on/off, track removal during consume
+;; - Boundary Cases: Double toggle, single-track playlist, consume off
+;; - Error Cases: No selected marker, empty 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-consume--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.
+Uses EMMS's own insertion function for proper text properties.
+Selects the first track by default."
+ (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)))
+ ;; Set selected marker to first track
+ (when track-names
+ (goto-char (point-min))
+ (unless emms-playlist-selected-marker
+ (setq emms-playlist-selected-marker (make-marker)))
+ (set-marker emms-playlist-selected-marker (point)))))
+ buf))
+
+(defun test-consume--teardown ()
+ "Clean up consume mode state and test buffers."
+ (setq cj/music-consume-mode nil)
+ (remove-hook 'emms-player-finished-hook #'cj/music--consume-track)
+ (when-let ((buf (get-buffer "*EMMS-Test-Playlist*")))
+ (kill-buffer buf)))
+
+(defun test-consume--track-count (buf)
+ "Return number of tracks in playlist buffer BUF."
+ (with-current-buffer buf
+ (if (= (point-min) (point-max))
+ 0
+ (count-lines (point-min) (point-max)))))
+
+(defun test-consume--capture-message (fn &rest args)
+ "Call FN with ARGS and return the message it produces.
+Works in both interactive and batch mode."
+ (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-toggle-consume
+
+(ert-deftest test-music-config-toggle-consume-normal-enable-sets-variable ()
+ "Validate toggling consume on sets cj/music-consume-mode to t."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode nil)
+ (cj/music-toggle-consume)
+ (should (eq cj/music-consume-mode t)))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config-toggle-consume-normal-enable-adds-hook ()
+ "Validate toggling consume on adds consume-track to finished hook."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode nil)
+ (cj/music-toggle-consume)
+ (should (memq #'cj/music--consume-track emms-player-finished-hook)))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config-toggle-consume-normal-disable-clears-variable ()
+ "Validate toggling consume off sets cj/music-consume-mode to nil."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode t)
+ (add-hook 'emms-player-finished-hook #'cj/music--consume-track)
+ (cj/music-toggle-consume)
+ (should (eq cj/music-consume-mode nil)))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config-toggle-consume-normal-disable-removes-hook ()
+ "Validate toggling consume off removes consume-track from finished hook."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode t)
+ (add-hook 'emms-player-finished-hook #'cj/music--consume-track)
+ (cj/music-toggle-consume)
+ (should-not (memq #'cj/music--consume-track emms-player-finished-hook)))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config-toggle-consume-normal-enable-message ()
+ "Validate toggling on produces enabled message."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode nil)
+ (let ((msg (test-consume--capture-message #'cj/music-toggle-consume)))
+ (should (string-match-p "enabled" msg))))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config-toggle-consume-normal-disable-message ()
+ "Validate toggling off produces disabled message."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode t)
+ (add-hook 'emms-player-finished-hook #'cj/music--consume-track)
+ (let ((msg (test-consume--capture-message #'cj/music-toggle-consume)))
+ (should (string-match-p "disabled" msg))))
+ (test-consume--teardown)))
+
+;;; Normal Cases - cj/music--consume-track
+
+(ert-deftest test-music-config--consume-track-normal-calls-kill-at-selected ()
+ "Validate consume-track calls kill-track at the selected marker position."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-consume--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3")))
+ (cj/music-consume-mode t)
+ (kill-called nil)
+ (kill-position nil)
+ (expected-pos (with-current-buffer buf
+ (marker-position emms-playlist-selected-marker))))
+ (cl-letf (((symbol-function 'emms-playlist-mode-kill-track)
+ (lambda ()
+ (setq kill-called t
+ kill-position (point)))))
+ (cj/music--consume-track)
+ (should kill-called)
+ (should (= kill-position expected-pos))))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config--consume-track-normal-calls-kill-at-middle-track ()
+ "Validate consume-track navigates to middle track before killing."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-consume--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3" "/music/track3.mp3")))
+ (cj/music-consume-mode t)
+ (kill-position nil)
+ (expected-pos nil))
+ ;; Move selected marker to second track
+ (with-current-buffer buf
+ (goto-char (point-min))
+ (forward-line 1)
+ (set-marker emms-playlist-selected-marker (point))
+ (setq expected-pos (marker-position emms-playlist-selected-marker)))
+ (cl-letf (((symbol-function 'emms-playlist-mode-kill-track)
+ (lambda () (setq kill-position (point)))))
+ (cj/music--consume-track)
+ (should (= kill-position expected-pos))))
+ (test-consume--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config-toggle-consume-boundary-double-toggle-restores-state ()
+ "Validate toggling twice returns to original off state."
+ (unwind-protect
+ (progn
+ (setq cj/music-consume-mode nil)
+ (cj/music-toggle-consume)
+ (cj/music-toggle-consume)
+ (should (eq cj/music-consume-mode nil))
+ (should-not (memq #'cj/music--consume-track emms-player-finished-hook)))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config--consume-track-boundary-noop-when-disabled ()
+ "Validate consume-track does nothing when consume mode is off."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-consume--setup-playlist-buffer
+ '("/music/track1.mp3" "/music/track2.mp3")))
+ (cj/music-consume-mode nil))
+ (cj/music--consume-track)
+ (should (= 2 (test-consume--track-count buf))))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config--consume-track-boundary-single-track-calls-kill ()
+ "Validate consuming the only track in playlist still calls kill-track."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-consume--setup-playlist-buffer
+ '("/music/only-track.mp3")))
+ (cj/music-consume-mode t)
+ (kill-called nil))
+ (cl-letf (((symbol-function 'emms-playlist-mode-kill-track)
+ (lambda () (setq kill-called t))))
+ (cj/music--consume-track)
+ (should kill-called)))
+ (test-consume--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--consume-track-error-no-marker-no-crash ()
+ "Validate consume-track handles nil selected marker without error."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (test-consume--setup-playlist-buffer
+ '("/music/track1.mp3")))
+ (cj/music-consume-mode t))
+ ;; Clear the selected marker
+ (with-current-buffer buf
+ (setq emms-playlist-selected-marker nil))
+ ;; Should not error
+ (should-not (condition-case err
+ (progn (cj/music--consume-track) nil)
+ (error err))))
+ (test-consume--teardown)))
+
+(ert-deftest test-music-config--consume-track-error-empty-playlist-no-crash ()
+ "Validate consume-track handles empty playlist buffer without error."
+ (unwind-protect
+ (let* ((cj/music-playlist-buffer-name "*EMMS-Test-Playlist*")
+ (buf (get-buffer-create "*EMMS-Test-Playlist*"))
+ (cj/music-consume-mode t))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t)
+ (setq emms-playlist-selected-marker nil))
+ ;; Should not error
+ (should-not (condition-case err
+ (progn (cj/music--consume-track) nil)
+ (error err))))
+ (test-consume--teardown)))
+
+(provide 'test-music-config-toggle-consume)
+;;; test-music-config-toggle-consume.el ends here