summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-04-01 08:13:33 -0500
committerCraig Jennings <c@cjennings.net>2026-04-01 08:13:33 -0500
commitfdc63ea6675709d6ca42362b1be1918f8bf24478 (patch)
tree056dd89c3879b4fd4e928ce8c6772501a7d7ffa5 /tests
parent0d55edecf82b32b75780f24751dfa8d693b72708 (diff)
feat(music): add test coverage for 7 functions, refactor with extracted helpersHEADmain
Add 47 new unit tests across 7 test files covering playlist-modified-p, assert-valid-playlist-file, playlist-tracks, create-radio-station, ensure-playlist-buffer, after-playlist-clear, and header-text. Extract three helpers to reduce duplication: assert-m3u-files-exist (dedupes 2 identical guards), sync-playlist-file (dedupes 3 state-sync patterns), and select-m3u-file (reusable M3U selection with cancel). Simplify append-track-to-playlist nesting from 6 to 4 levels. Delete unused cj/music-keymap-prefix variable.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-music-config--after-playlist-clear.el116
-rw-r--r--tests/test-music-config--assert-valid-playlist-file.el131
-rw-r--r--tests/test-music-config--ensure-playlist-buffer.el106
-rw-r--r--tests/test-music-config--header-text.el156
-rw-r--r--tests/test-music-config--playlist-modified-p.el145
-rw-r--r--tests/test-music-config--playlist-tracks.el123
-rw-r--r--tests/test-music-config-create-radio-station.el153
7 files changed, 930 insertions, 0 deletions
diff --git a/tests/test-music-config--after-playlist-clear.el b/tests/test-music-config--after-playlist-clear.el
new file mode 100644
index 00000000..c23e2b5b
--- /dev/null
+++ b/tests/test-music-config--after-playlist-clear.el
@@ -0,0 +1,116 @@
+;;; test-music-config--after-playlist-clear.el --- Tests for playlist clear advice -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--after-playlist-clear function.
+;; Tests the advice function that resets cj/music-playlist-file
+;; when the EMMS playlist is cleared.
+;;
+;; Test organization:
+;; - Normal Cases: Clears file variable, noop when no buffer
+;; - Boundary Cases: Already nil stays nil, idempotent on multiple calls
+;; - Error Cases: Killed buffer doesn't crash
+;;
+;;; 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)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-after-clear--setup ()
+ "Create playlist buffer with a playlist file set."
+ (let ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-after-clear--teardown ()
+ "Clean up test playlist buffer."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))
+ (kill-buffer buf)))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--after-playlist-clear-normal-clears-file-variable ()
+ "Calling after-playlist-clear sets cj/music-playlist-file to nil."
+ (unwind-protect
+ (progn
+ (test-after-clear--setup)
+ (with-current-buffer cj/music-playlist-buffer-name
+ (setq cj/music-playlist-file "/path/to/playlist.m3u"))
+ (cj/music--after-playlist-clear)
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should-not cj/music-playlist-file)))
+ (test-after-clear--teardown)))
+
+(ert-deftest test-music-config--after-playlist-clear-normal-noop-when-no-buffer ()
+ "Does nothing when playlist buffer doesn't exist."
+ (unwind-protect
+ (progn
+ ;; Ensure no buffer exists
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (kill-buffer buf))
+ ;; Should not error
+ (cj/music--after-playlist-clear))
+ (test-after-clear--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--after-playlist-clear-boundary-already-nil-stays-nil ()
+ "Already-nil playlist file remains nil after clear."
+ (unwind-protect
+ (progn
+ (test-after-clear--setup)
+ (with-current-buffer cj/music-playlist-buffer-name
+ (setq cj/music-playlist-file nil))
+ (cj/music--after-playlist-clear)
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should-not cj/music-playlist-file)))
+ (test-after-clear--teardown)))
+
+(ert-deftest test-music-config--after-playlist-clear-boundary-multiple-calls-idempotent ()
+ "Multiple calls produce same result as single call."
+ (unwind-protect
+ (progn
+ (test-after-clear--setup)
+ (with-current-buffer cj/music-playlist-buffer-name
+ (setq cj/music-playlist-file "/path/to/playlist.m3u"))
+ (cj/music--after-playlist-clear)
+ (cj/music--after-playlist-clear)
+ (cj/music--after-playlist-clear)
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should-not cj/music-playlist-file)))
+ (test-after-clear--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--after-playlist-clear-error-killed-buffer-no-crash ()
+ "Killed buffer doesn't cause an error."
+ (let ((buf (test-after-clear--setup)))
+ (kill-buffer buf)
+ ;; Should not error when buffer is gone
+ (should-not (condition-case err
+ (progn (cj/music--after-playlist-clear) nil)
+ (error err)))))
+
+(provide 'test-music-config--after-playlist-clear)
+;;; test-music-config--after-playlist-clear.el ends here
diff --git a/tests/test-music-config--assert-valid-playlist-file.el b/tests/test-music-config--assert-valid-playlist-file.el
new file mode 100644
index 00000000..aca0eeb9
--- /dev/null
+++ b/tests/test-music-config--assert-valid-playlist-file.el
@@ -0,0 +1,131 @@
+;;; test-music-config--assert-valid-playlist-file.el --- Tests for playlist file validation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--assert-valid-playlist-file function.
+;; Tests the validation guard that ensures a playlist buffer has
+;; a valid, existing associated M3U file before operations proceed.
+;;
+;; Test organization:
+;; - Normal Cases: Valid file passes without error
+;; - Boundary Cases: Empty file, file exists but is empty
+;; - Error Cases: Nil file, nonexistent file, deleted file
+;;
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; 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)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-assert--setup ()
+ "Create test base dir and ensure playlist buffer exists."
+ (cj/create-test-base-dir)
+ (let ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-assert--teardown ()
+ "Clean up test playlist buffer and temp files."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))
+ (kill-buffer buf))
+ (cj/delete-test-base-dir))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--assert-valid-playlist-file-normal-existing-file-passes ()
+ "Valid existing file does not signal error."
+ (test-assert--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file "playlist-")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file file))
+ ;; Should not error
+ (cj/music--assert-valid-playlist-file))
+ (test-assert--teardown)))
+
+(ert-deftest test-music-config--assert-valid-playlist-file-normal-file-with-content-passes ()
+ "Valid file with content does not signal error."
+ (test-assert--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file-with-content "track1.mp3\ntrack2.mp3\n" "playlist-")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file file))
+ (cj/music--assert-valid-playlist-file))
+ (test-assert--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--assert-valid-playlist-file-boundary-empty-file-passes ()
+ "Empty but existing file does not signal error."
+ (test-assert--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file "playlist-")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file file))
+ ;; File exists but is empty - should still pass
+ (cj/music--assert-valid-playlist-file))
+ (test-assert--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--assert-valid-playlist-file-error-nil-file-signals-user-error ()
+ "Nil cj/music-playlist-file signals user-error."
+ (test-assert--setup)
+ (unwind-protect
+ (progn
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file nil))
+ (should-error (cj/music--assert-valid-playlist-file)
+ :type 'user-error))
+ (test-assert--teardown)))
+
+(ert-deftest test-music-config--assert-valid-playlist-file-error-nonexistent-file-signals-user-error ()
+ "Nonexistent file path signals user-error."
+ (test-assert--setup)
+ (unwind-protect
+ (progn
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file "/nonexistent/path/to/playlist.m3u"))
+ (should-error (cj/music--assert-valid-playlist-file)
+ :type 'user-error))
+ (test-assert--teardown)))
+
+(ert-deftest test-music-config--assert-valid-playlist-file-error-deleted-file-signals-user-error ()
+ "File that existed but was deleted signals user-error."
+ (test-assert--setup)
+ (unwind-protect
+ (let ((file (cj/create-temp-test-file "playlist-")))
+ (with-current-buffer (get-buffer cj/music-playlist-buffer-name)
+ (setq cj/music-playlist-file file))
+ ;; Verify it passes first
+ (cj/music--assert-valid-playlist-file)
+ ;; Now delete the file
+ (delete-file file)
+ ;; Should now signal error
+ (should-error (cj/music--assert-valid-playlist-file)
+ :type 'user-error))
+ (test-assert--teardown)))
+
+(provide 'test-music-config--assert-valid-playlist-file)
+;;; test-music-config--assert-valid-playlist-file.el ends here
diff --git a/tests/test-music-config--ensure-playlist-buffer.el b/tests/test-music-config--ensure-playlist-buffer.el
new file mode 100644
index 00000000..b7f9a8b7
--- /dev/null
+++ b/tests/test-music-config--ensure-playlist-buffer.el
@@ -0,0 +1,106 @@
+;;; test-music-config--ensure-playlist-buffer.el --- Tests for playlist buffer creation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--ensure-playlist-buffer function.
+;; Tests buffer creation, mode setup, and global variable assignment.
+;;
+;; Test organization:
+;; - Normal Cases: Creates new buffer, returns existing buffer
+;; - Boundary Cases: Wrong mode gets corrected, sets global variable
+;; - Error Cases: Killed buffer gets recreated, returns live buffer
+;;
+;;; 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)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-ensure--teardown ()
+ "Clean up test playlist buffer."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (kill-buffer buf)))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--ensure-playlist-buffer-normal-creates-buffer-when-none-exists ()
+ "Creates a new playlist buffer when none exists."
+ (unwind-protect
+ (progn
+ ;; Kill any existing buffer
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (kill-buffer buf))
+ (let ((result (cj/music--ensure-playlist-buffer)))
+ (should (bufferp result))
+ (should (buffer-live-p result))
+ (should (equal (buffer-name result) cj/music-playlist-buffer-name))))
+ (test-ensure--teardown)))
+
+(ert-deftest test-music-config--ensure-playlist-buffer-normal-returns-existing-buffer ()
+ "Returns existing buffer without recreating it."
+ (unwind-protect
+ (let* ((buf1 (cj/music--ensure-playlist-buffer))
+ (buf2 (cj/music--ensure-playlist-buffer)))
+ (should (eq buf1 buf2)))
+ (test-ensure--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--ensure-playlist-buffer-boundary-wrong-mode-corrected ()
+ "Buffer in wrong mode gets corrected to emms-playlist-mode."
+ (unwind-protect
+ (progn
+ ;; Create buffer in fundamental-mode
+ (let ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (fundamental-mode)))
+ (let ((result (cj/music--ensure-playlist-buffer)))
+ (with-current-buffer result
+ (should (eq major-mode 'emms-playlist-mode)))))
+ (test-ensure--teardown)))
+
+(ert-deftest test-music-config--ensure-playlist-buffer-boundary-sets-global-variable ()
+ "Sets emms-playlist-buffer to the returned buffer."
+ (unwind-protect
+ (let ((result (cj/music--ensure-playlist-buffer)))
+ (should (eq emms-playlist-buffer result)))
+ (test-ensure--teardown)))
+
+(ert-deftest test-music-config--ensure-playlist-buffer-boundary-sets-buffer-local-flag ()
+ "Sets emms-playlist-buffer-p to t in the buffer."
+ (unwind-protect
+ (let ((result (cj/music--ensure-playlist-buffer)))
+ (with-current-buffer result
+ (should (eq emms-playlist-buffer-p t))))
+ (test-ensure--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--ensure-playlist-buffer-error-killed-buffer-recreated ()
+ "After buffer is killed, calling again creates a new live buffer."
+ (unwind-protect
+ (progn
+ (let ((buf1 (cj/music--ensure-playlist-buffer)))
+ (kill-buffer buf1))
+ (let ((buf2 (cj/music--ensure-playlist-buffer)))
+ (should (buffer-live-p buf2))
+ (should (equal (buffer-name buf2) cj/music-playlist-buffer-name))))
+ (test-ensure--teardown)))
+
+(provide 'test-music-config--ensure-playlist-buffer)
+;;; test-music-config--ensure-playlist-buffer.el ends here
diff --git a/tests/test-music-config--header-text.el b/tests/test-music-config--header-text.el
new file mode 100644
index 00000000..8de97350
--- /dev/null
+++ b/tests/test-music-config--header-text.el
@@ -0,0 +1,156 @@
+;;; test-music-config--header-text.el --- Tests for playlist header string generation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--header-text function.
+;; Tests the multi-line header string builder for the playlist overlay.
+;;
+;; Test organization:
+;; - Normal Cases: Playlist name from file, "Untitled" default, track count
+;; - Boundary Cases: Stopped state, paused state, mode indicators
+;; - Error Cases: Empty playlist buffer
+;;
+;;; 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)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-header--setup-playlist-buffer (track-names)
+ "Create an EMMS playlist buffer with TRACK-NAMES and return it."
+ (let ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (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)))))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-header--teardown ()
+ "Clean up test playlist buffer and reset EMMS state."
+ (setq emms-player-playing-p nil)
+ (setq emms-player-paused-p nil)
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (setq cj/music-playlist-file nil))
+ (kill-buffer buf)))
+
+(defun test-header--strip-properties (str)
+ "Return STR with all text properties removed."
+ (let ((s (copy-sequence str)))
+ (set-text-properties 0 (length s) nil s)
+ s))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--header-text-normal-shows-playlist-name-from-file ()
+ "Header shows playlist name derived from cj/music-playlist-file."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (setq cj/music-playlist-file "/path/to/my-jazz.m3u"))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "my-jazz" plain))))
+ (test-header--teardown)))
+
+(ert-deftest test-music-config--header-text-normal-shows-untitled-when-no-file ()
+ "Header shows 'Untitled' when no playlist file is associated."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (setq cj/music-playlist-file nil))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "Untitled" plain))))
+ (test-header--teardown)))
+
+(ert-deftest test-music-config--header-text-normal-shows-track-count ()
+ "Header shows correct track count."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3" "/music/b.mp3" "/music/c.mp3"))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "(3)" plain))))
+ (test-header--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--header-text-boundary-stopped-state ()
+ "Header shows 'Stopped' when not playing."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (setq emms-player-playing-p nil)
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "Stopped" plain))))
+ (test-header--teardown)))
+
+(ert-deftest test-music-config--header-text-boundary-paused-state ()
+ "Header shows 'Paused' when player is paused."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (setq emms-player-playing-p t)
+ (setq emms-player-paused-p t)
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "Paused" plain))))
+ (test-header--teardown)))
+
+(ert-deftest test-music-config--header-text-boundary-contains-mode-labels ()
+ "Header contains mode indicator labels for repeat, single, random, consume."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '("/music/a.mp3"))
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "repeat" plain))
+ (should (string-match-p "single" plain))
+ (should (string-match-p "random" plain))
+ (should (string-match-p "consume" plain))))
+ (test-header--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--header-text-error-empty-playlist-shows-zero-count ()
+ "Header shows (0) for empty playlist."
+ (unwind-protect
+ (progn
+ (test-header--setup-playlist-buffer '())
+ (let* ((header (with-current-buffer cj/music-playlist-buffer-name
+ (cj/music--header-text)))
+ (plain (test-header--strip-properties header)))
+ (should (string-match-p "(0)" plain))))
+ (test-header--teardown)))
+
+(provide 'test-music-config--header-text)
+;;; test-music-config--header-text.el ends here
diff --git a/tests/test-music-config--playlist-modified-p.el b/tests/test-music-config--playlist-modified-p.el
new file mode 100644
index 00000000..cbfc71e3
--- /dev/null
+++ b/tests/test-music-config--playlist-modified-p.el
@@ -0,0 +1,145 @@
+;;; test-music-config--playlist-modified-p.el --- Tests for playlist modification detection -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--playlist-modified-p function.
+;; Tests the logic that compares the current EMMS playlist buffer
+;; against its associated M3U file to detect unsaved changes.
+;;
+;; Test organization:
+;; - Normal Cases: Matching/differing track lists
+;; - Boundary Cases: Empty playlists, nil file, ordering
+;; - Error Cases: Missing M3U file, missing buffer
+;;
+;;; 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)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-modified--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 cj/music-playlist-buffer-name)))
+ (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)))))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-modified--teardown ()
+ "Clean up test playlist buffer."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (kill-buffer buf)))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--playlist-modified-p-normal-identical-tracks-returns-nil ()
+ "Identical track lists in file and playlist returns nil (not modified)."
+ (unwind-protect
+ (let* ((tracks '("/music/a.mp3" "/music/b.mp3" "/music/c.mp3"))
+ (cj/music-playlist-file "/fake/playlist.m3u"))
+ (test-modified--setup-playlist-buffer tracks)
+ (cl-letf (((symbol-function 'cj/music--m3u-file-tracks)
+ (lambda (_file) tracks)))
+ (should-not (cj/music--playlist-modified-p))))
+ (test-modified--teardown)))
+
+(ert-deftest test-music-config--playlist-modified-p-normal-different-tracks-returns-non-nil ()
+ "Different track lists returns non-nil (modified)."
+ (unwind-protect
+ (let* ((file-tracks '("/music/a.mp3" "/music/b.mp3"))
+ (buf-tracks '("/music/a.mp3" "/music/c.mp3"))
+ (cj/music-playlist-file "/fake/playlist.m3u"))
+ (test-modified--setup-playlist-buffer buf-tracks)
+ (cl-letf (((symbol-function 'cj/music--m3u-file-tracks)
+ (lambda (_file) file-tracks)))
+ (should (cj/music--playlist-modified-p))))
+ (test-modified--teardown)))
+
+(ert-deftest test-music-config--playlist-modified-p-normal-extra-track-returns-non-nil ()
+ "Playlist with extra track compared to file returns non-nil."
+ (unwind-protect
+ (let* ((file-tracks '("/music/a.mp3" "/music/b.mp3"))
+ (buf-tracks '("/music/a.mp3" "/music/b.mp3" "/music/c.mp3"))
+ (cj/music-playlist-file "/fake/playlist.m3u"))
+ (test-modified--setup-playlist-buffer buf-tracks)
+ (cl-letf (((symbol-function 'cj/music--m3u-file-tracks)
+ (lambda (_file) file-tracks)))
+ (should (cj/music--playlist-modified-p))))
+ (test-modified--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--playlist-modified-p-boundary-no-playlist-file-returns-nil ()
+ "Nil cj/music-playlist-file returns nil without comparing tracks."
+ (unwind-protect
+ (let ((cj/music-playlist-file nil))
+ (test-modified--setup-playlist-buffer '("/music/a.mp3"))
+ (should-not (cj/music--playlist-modified-p)))
+ (test-modified--teardown)))
+
+(ert-deftest test-music-config--playlist-modified-p-boundary-empty-file-and-playlist-returns-nil ()
+ "Both empty file tracks and empty playlist returns nil."
+ (unwind-protect
+ (let ((cj/music-playlist-file "/fake/playlist.m3u"))
+ (test-modified--setup-playlist-buffer '())
+ (cl-letf (((symbol-function 'cj/music--m3u-file-tracks)
+ (lambda (_file) nil)))
+ (should-not (cj/music--playlist-modified-p))))
+ (test-modified--teardown)))
+
+(ert-deftest test-music-config--playlist-modified-p-boundary-order-difference-returns-non-nil ()
+ "Same tracks in different order returns non-nil."
+ (unwind-protect
+ (let* ((file-tracks '("/music/a.mp3" "/music/b.mp3"))
+ (buf-tracks '("/music/b.mp3" "/music/a.mp3"))
+ (cj/music-playlist-file "/fake/playlist.m3u"))
+ (test-modified--setup-playlist-buffer buf-tracks)
+ (cl-letf (((symbol-function 'cj/music--m3u-file-tracks)
+ (lambda (_file) file-tracks)))
+ (should (cj/music--playlist-modified-p))))
+ (test-modified--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--playlist-modified-p-error-missing-m3u-file-returns-nil ()
+ "When M3U file doesn't exist, m3u-file-tracks returns nil; empty playlist matches."
+ (unwind-protect
+ (let ((cj/music-playlist-file "/nonexistent/playlist.m3u"))
+ (test-modified--setup-playlist-buffer '())
+ ;; cj/music--m3u-file-tracks returns nil for nonexistent files
+ ;; empty playlist also returns nil, so equal = not modified
+ (should-not (cj/music--playlist-modified-p)))
+ (test-modified--teardown)))
+
+(ert-deftest test-music-config--playlist-modified-p-error-missing-m3u-with-tracks-returns-non-nil ()
+ "When M3U file doesn't exist but playlist has tracks, returns non-nil."
+ (unwind-protect
+ (let ((cj/music-playlist-file "/nonexistent/playlist.m3u"))
+ (test-modified--setup-playlist-buffer '("/music/a.mp3"))
+ ;; m3u-file-tracks returns nil, but playlist has tracks = modified
+ (should (cj/music--playlist-modified-p)))
+ (test-modified--teardown)))
+
+(provide 'test-music-config--playlist-modified-p)
+;;; test-music-config--playlist-modified-p.el ends here
diff --git a/tests/test-music-config--playlist-tracks.el b/tests/test-music-config--playlist-tracks.el
new file mode 100644
index 00000000..7736b8d2
--- /dev/null
+++ b/tests/test-music-config--playlist-tracks.el
@@ -0,0 +1,123 @@
+;;; test-music-config--playlist-tracks.el --- Tests for playlist track extraction -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music--playlist-tracks function.
+;; Tests reading track names from the current EMMS playlist buffer.
+;;
+;; Test organization:
+;; - Normal Cases: Single and multiple tracks
+;; - Boundary Cases: Empty playlist, order preservation
+;; - Error Cases: No playlist buffer, tracks without names
+;;
+;;; 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)))
+
+(require 'emms)
+(require 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Test helpers
+
+(defun test-tracks--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 cj/music-playlist-buffer-name)))
+ (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)))))
+ (setq emms-playlist-buffer buf)
+ buf))
+
+(defun test-tracks--teardown ()
+ "Clean up test playlist buffer."
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (kill-buffer buf)))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config--playlist-tracks-normal-multiple-tracks-returns-list ()
+ "Multiple tracks in playlist returns list of track names."
+ (unwind-protect
+ (let ((tracks '("/music/a.mp3" "/music/b.mp3" "/music/c.mp3")))
+ (test-tracks--setup-playlist-buffer tracks)
+ (should (equal (cj/music--playlist-tracks) tracks)))
+ (test-tracks--teardown)))
+
+(ert-deftest test-music-config--playlist-tracks-normal-single-track-returns-list ()
+ "Single track in playlist returns single-element list."
+ (unwind-protect
+ (let ((tracks '("/music/only.mp3")))
+ (test-tracks--setup-playlist-buffer tracks)
+ (should (equal (cj/music--playlist-tracks) tracks)))
+ (test-tracks--teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config--playlist-tracks-boundary-empty-playlist-returns-nil ()
+ "Empty playlist returns nil (empty list)."
+ (unwind-protect
+ (progn
+ (test-tracks--setup-playlist-buffer '())
+ (should-not (cj/music--playlist-tracks)))
+ (test-tracks--teardown)))
+
+(ert-deftest test-music-config--playlist-tracks-boundary-preserves-insertion-order ()
+ "Track order matches insertion order."
+ (unwind-protect
+ (let ((tracks '("/music/z.mp3" "/music/a.mp3" "/music/m.mp3")))
+ (test-tracks--setup-playlist-buffer tracks)
+ (should (equal (cj/music--playlist-tracks) tracks)))
+ (test-tracks--teardown)))
+
+(ert-deftest test-music-config--playlist-tracks-boundary-url-tracks-included ()
+ "URL tracks are included alongside file tracks."
+ (unwind-protect
+ (let* ((buf (get-buffer-create cj/music-playlist-buffer-name)))
+ (with-current-buffer buf
+ (emms-playlist-mode)
+ (setq emms-playlist-buffer-p t)
+ (let ((inhibit-read-only t))
+ (erase-buffer)
+ (emms-playlist-insert-track (emms-track 'file "/music/local.mp3"))
+ (emms-playlist-insert-track (emms-track 'url "http://stream.example.com/radio"))))
+ (setq emms-playlist-buffer buf)
+ (let ((result (cj/music--playlist-tracks)))
+ (should (= 2 (length result)))
+ (should (equal (nth 0 result) "/music/local.mp3"))
+ (should (equal (nth 1 result) "http://stream.example.com/radio"))))
+ (test-tracks--teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-music-config--playlist-tracks-error-no-buffer-creates-one ()
+ "When playlist buffer doesn't exist, ensure-playlist-buffer creates it."
+ (unwind-protect
+ (progn
+ ;; Kill any existing buffer
+ (when-let ((buf (get-buffer cj/music-playlist-buffer-name)))
+ (kill-buffer buf))
+ ;; Should create buffer and return empty list
+ (should-not (cj/music--playlist-tracks))
+ ;; Buffer should now exist
+ (should (get-buffer cj/music-playlist-buffer-name)))
+ (test-tracks--teardown)))
+
+(provide 'test-music-config--playlist-tracks)
+;;; test-music-config--playlist-tracks.el ends here
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
new file mode 100644
index 00000000..1f4365a4
--- /dev/null
+++ b/tests/test-music-config-create-radio-station.el
@@ -0,0 +1,153 @@
+;;; test-music-config-create-radio-station.el --- Tests for radio station creation -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for cj/music-create-radio-station function.
+;; Tests M3U file creation for radio stations with stream URLs.
+;;
+;; Test organization:
+;; - Normal Cases: Standard creation, EXTM3U format, safe filename
+;; - Boundary Cases: Unicode name, complex URL, overwrite confirmed
+;; - Error Cases: Empty name, empty URL, overwrite declined
+;;
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Stub missing dependencies before loading music-config
+(defvar-keymap cj/custom-keymap
+ :doc "Stub keymap for testing")
+
+;; Load production code
+(require 'music-config)
+
+;;; Setup & Teardown
+
+(defun test-music-config-create-radio-station-setup ()
+ "Setup test environment with temp directory for M3U output."
+ (cj/create-test-base-dir)
+ (cj/create-test-subdirectory "radio-playlists"))
+
+(defun test-music-config-create-radio-station-teardown ()
+ "Clean up test environment."
+ (cj/delete-test-base-dir))
+
+;;; Normal Cases
+
+(ert-deftest test-music-config-create-radio-station-normal-creates-m3u-file ()
+ "Creating a radio station produces an M3U file in the music root."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir))
+ (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
+ (let ((expected-file (expand-file-name "Jazz_FM_Radio.m3u" test-dir)))
+ (should (file-exists-p expected-file))))
+ (test-music-config-create-radio-station-teardown))))
+
+(ert-deftest test-music-config-create-radio-station-normal-extm3u-format ()
+ "Created file contains EXTM3U header, EXTINF with station name, and URL."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir))
+ (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
+ (let ((content (with-temp-buffer
+ (insert-file-contents
+ (expand-file-name "Jazz_FM_Radio.m3u" test-dir))
+ (buffer-string))))
+ (should (string-match-p "^#EXTM3U" content))
+ (should (string-match-p "#EXTINF:-1,Jazz FM" content))
+ (should (string-match-p "http://stream.jazzfm.com/radio" content))))
+ (test-music-config-create-radio-station-teardown))))
+
+(ert-deftest test-music-config-create-radio-station-normal-safe-filename ()
+ "Station name with special characters produces filesystem-safe filename."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir))
+ (cj/music-create-radio-station "Rock & Roll 101.5" "http://example.com/stream")
+ ;; Spaces and special chars replaced with underscores
+ (let ((expected-file (expand-file-name "Rock___Roll_101_5_Radio.m3u" test-dir)))
+ (should (file-exists-p expected-file))))
+ (test-music-config-create-radio-station-teardown))))
+
+;;; Boundary Cases
+
+(ert-deftest test-music-config-create-radio-station-boundary-unicode-name-safe-filename ()
+ "Unicode station name produces safe filename while preserving name in EXTINF."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir))
+ (cj/music-create-radio-station "Klassik Radio" "http://example.com/stream")
+ ;; Name is all ASCII-safe, so filename uses it directly
+ (should (file-exists-p (expand-file-name "Klassik_Radio_Radio.m3u" test-dir)))
+ ;; Original name preserved in EXTINF inside the file
+ (let ((content (with-temp-buffer
+ (insert-file-contents
+ (expand-file-name "Klassik_Radio_Radio.m3u" test-dir))
+ (buffer-string))))
+ (should (string-match-p "Klassik Radio" content))))
+ (test-music-config-create-radio-station-teardown))))
+
+(ert-deftest test-music-config-create-radio-station-boundary-url-with-query-params ()
+ "Complex URL with query parameters preserved in file content."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir)
+ (url "https://stream.example.com/radio?format=mp3&quality=320&token=abc123"))
+ (cj/music-create-radio-station "Test Radio" url)
+ (let ((content (with-temp-buffer
+ (insert-file-contents
+ (expand-file-name "Test_Radio_Radio.m3u" test-dir))
+ (buffer-string))))
+ (should (string-match-p (regexp-quote url) content))))
+ (test-music-config-create-radio-station-teardown))))
+
+(ert-deftest test-music-config-create-radio-station-boundary-overwrite-confirmed ()
+ "Overwriting existing file when user confirms succeeds."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir))
+ ;; Create initial file
+ (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
+ (let ((file (expand-file-name "MyRadio_Radio.m3u" test-dir)))
+ (should (file-exists-p file))
+ ;; Overwrite with user confirming
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) t)))
+ (cj/music-create-radio-station "MyRadio" "http://new.url/stream"))
+ ;; File should now contain new URL
+ (let ((content (with-temp-buffer
+ (insert-file-contents file)
+ (buffer-string))))
+ (should (string-match-p "http://new.url/stream" content))
+ (should-not (string-match-p "http://old.url/stream" content)))))
+ (test-music-config-create-radio-station-teardown))))
+
+;;; Error Cases
+
+(ert-deftest test-music-config-create-radio-station-error-empty-name-signals-user-error ()
+ "Empty station name signals user-error."
+ (should-error (cj/music-create-radio-station "" "http://example.com/stream")
+ :type 'user-error))
+
+(ert-deftest test-music-config-create-radio-station-error-empty-url-signals-user-error ()
+ "Empty URL signals user-error."
+ (should-error (cj/music-create-radio-station "Test Radio" "")
+ :type 'user-error))
+
+(ert-deftest test-music-config-create-radio-station-error-overwrite-declined-signals-user-error ()
+ "Declining overwrite signals user-error."
+ (let ((test-dir (test-music-config-create-radio-station-setup)))
+ (unwind-protect
+ (let ((cj/music-m3u-root test-dir))
+ ;; Create initial file
+ (cj/music-create-radio-station "MyRadio" "http://old.url/stream")
+ ;; Decline overwrite
+ (cl-letf (((symbol-function 'yes-or-no-p) (lambda (_prompt) nil)))
+ (should-error (cj/music-create-radio-station "MyRadio" "http://new.url/stream")
+ :type 'user-error)))
+ (test-music-config-create-radio-station-teardown))))
+
+(provide 'test-music-config-create-radio-station)
+;;; test-music-config-create-radio-station.el ends here