aboutsummaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2026-07-08 10:25:43 -0500
committerCraig Jennings <c@cjennings.net>2026-07-08 10:25:43 -0500
commitad8d86bdb2470901e09b4208b0d8d9f2709f02da (patch)
tree1aedf41b51e7449dbd088781f25afca5070e836c /tests
parent50a915c57b3511f0158772805e9d36535505235c (diff)
downloaddotemacs-ad8d86bdb2470901e09b4208b0d8d9f2709f02da.tar.gz
dotemacs-ad8d86bdb2470901e09b4208b0d8d9f2709f02da.zip
feat(music): queue radio picks and save on request
The radio-browser lookup and the manual station creator no longer write .m3u files at pick time. Picks become url tracks in the queue, carrying the station name, uuid, and favicon as track properties, and play immediately. The display and cover-art layers read the properties first and fall back to the on-disk metadata, so existing station files keep working. Saving is the normal playlist save, now on w. The radio feature's S=stop rebind had silently shadowed the old S=save binding. An all-stream queue saves into the MPD playlist dir, and the station name pre-fills the prompt with no -Radio suffix. A custom emitter writes the station metadata back out as .m3u comment lines, since the stock EMMS writer emits bare URLs and would lose names and cover art on reload. I removed the write-at-pick machinery (station-m3u, write-stations, disambiguate-name, the -Radio filename suffix) and the orphaned safe-filename helper. An empty name at the save prompt now signals an error instead of writing a hidden .m3u.
Diffstat (limited to 'tests')
-rw-r--r--tests/test-music-config--art-cache-key.el15
-rw-r--r--tests/test-music-config--art-favicon-url.el17
-rw-r--r--tests/test-music-config--m3u-text.el93
-rw-r--r--tests/test-music-config--radio-station-track.el135
-rw-r--r--tests/test-music-config--radio.el102
-rw-r--r--tests/test-music-config--safe-filename.el97
-rw-r--r--tests/test-music-config--save-helpers.el90
-rw-r--r--tests/test-music-config-create-radio-station.el197
-rw-r--r--tests/test-music-config-more-commands.el149
9 files changed, 530 insertions, 365 deletions
diff --git a/tests/test-music-config--art-cache-key.el b/tests/test-music-config--art-cache-key.el
index 0ef82a21..bc869808 100644
--- a/tests/test-music-config--art-cache-key.el
+++ b/tests/test-music-config--art-cache-key.el
@@ -50,5 +50,20 @@
(should (string= (cj/music-art--cache-key track entries)
(concat "url-" (sha1 url))))))
+(ert-deftest test-music-config--art-cache-key-normal-track-property ()
+ "Normal: a queued lookup station carries its uuid as a track property and
+needs no entries at all."
+ (let ((track (emms-track 'url "https://ck4.example.net/live")))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track nil) "prop-uuid"))))
+
+(ert-deftest test-music-config--art-cache-key-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries uuid."
+ (let* ((url "https://ck5.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid "entries-uuid" :favicon nil))))
+ (emms-track-set track 'radio-uuid "prop-uuid")
+ (should (string= (cj/music-art--cache-key track entries) "prop-uuid"))))
+
(provide 'test-music-config--art-cache-key)
;;; test-music-config--art-cache-key.el ends here
diff --git a/tests/test-music-config--art-favicon-url.el b/tests/test-music-config--art-favicon-url.el
index d9759ab3..9e7b92f8 100644
--- a/tests/test-music-config--art-favicon-url.el
+++ b/tests/test-music-config--art-favicon-url.el
@@ -48,5 +48,22 @@
(let ((track (emms-track 'file "/music/x.flac")))
(should (null (cj/music-art--favicon-url track nil)))))
+(ert-deftest test-music-config--art-favicon-url-normal-track-property ()
+ "Normal: a queued lookup station carries its favicon as a track property."
+ (let ((track (emms-track 'url "https://fp.example.net/live")))
+ (emms-track-set track 'radio-favicon "https://fp.example.net/icon.png")
+ (should (string= (cj/music-art--favicon-url track nil)
+ "https://fp.example.net/icon.png"))))
+
+(ert-deftest test-music-config--art-favicon-url-normal-property-beats-entries ()
+ "Normal: the track property wins over a conflicting entries favicon."
+ (let* ((url "https://fp2.example.net/live")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "X" :uuid nil
+ :favicon "https://entries.example/e.png"))))
+ (emms-track-set track 'radio-favicon "https://prop.example/p.png")
+ (should (string= (cj/music-art--favicon-url track entries)
+ "https://prop.example/p.png"))))
+
(provide 'test-music-config--art-favicon-url)
;;; test-music-config--art-favicon-url.el ends here
diff --git a/tests/test-music-config--m3u-text.el b/tests/test-music-config--m3u-text.el
new file mode 100644
index 00000000..14c9f2bf
--- /dev/null
+++ b/tests/test-music-config--m3u-text.el
@@ -0,0 +1,93 @@
+;;; test-music-config--m3u-text.el --- playlist .m3u emitter tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The custom .m3u emitter behind playlist save. The stock EMMS m3u writer
+;; emits bare URLs, which would throw away a station's name/uuid/favicon on
+;; save; this emitter writes the same #EXTINF / #RADIOBROWSERUUID /
+;; #RADIOBROWSERFAVICON lines the parser (`cj/music--m3u-entries') reads, so
+;; save -> load round-trips a station's display name and cover-art metadata.
+;; Metadata comes from track properties first, the m3u-scan entries as
+;; fallback (a loaded legacy playlist has entries but no properties).
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for 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 'music-config)
+
+(declare-function cj/music--m3u-text "music-config" (tracks entries))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+(ert-deftest test-music-m3u-text-normal-file-track-bare-path ()
+ "Normal: a file track is a bare absolute path under the #EXTM3U header."
+ (let ((text (cj/music--m3u-text (list (emms-track 'file "/music/a.flac")) nil)))
+ (should (string-prefix-p "#EXTM3U\n" text))
+ (should (string-match-p "^/music/a\\.flac$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-properties ()
+ "Normal: a url track with properties emits uuid, favicon, EXTINF, and url."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://ck.somafm.com/gs"
+ :stationuuid "uuid-1" :favicon "https://somafm.com/i.png")))
+ (text (cj/music--m3u-text (list track) nil)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-1$" text))
+ (should (string-match-p "^#RADIOBROWSERFAVICON:https://somafm\\.com/i\\.png$" text))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^https://ck\\.somafm\\.com/gs$" text))))
+
+(ert-deftest test-music-m3u-text-normal-url-track-from-entries-fallback ()
+ "Normal: a propertyless url track (a loaded legacy playlist) resolves its
+metadata from the ENTRIES alist."
+ (let* ((url "https://legacy.example/stream")
+ (track (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid "uuid-9"
+ :favicon "https://legacy.example/f.ico")))
+ (text (cj/music--m3u-text (list track) entries)))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-9$" text))
+ (should (string-match-p "^#EXTINF:-1,Legacy FM$" text))))
+
+(ert-deftest test-music-m3u-text-boundary-url-track-no-metadata ()
+ "Boundary: a url track with neither properties nor entries still gets an
+EXTINF label (the tidied host) and no uuid/favicon lines."
+ (let ((text (cj/music--m3u-text
+ (list (emms-track 'url "https://ice6.somafm.com/live")) nil)))
+ (should (string-match-p "^#EXTINF:-1,somafm\\.com$" text))
+ (should-not (string-match-p "RADIOBROWSERUUID" text))
+ (should-not (string-match-p "RADIOBROWSERFAVICON" text))))
+
+(ert-deftest test-music-m3u-text-normal-mixed-order-preserved ()
+ "Normal: a mixed queue keeps its track order in the file."
+ (let* ((f (emms-track 'file "/music/b.mp3"))
+ (u (cj/music-radio--station-track '(:name "S" :url "https://s.example/x")))
+ (text (cj/music--m3u-text (list f u) nil)))
+ (should (< (string-match "^/music/b\\.mp3$" text)
+ (string-match "^https://s\\.example/x$" text)))))
+
+(ert-deftest test-music-m3u-text-round-trip-through-parser ()
+ "Normal: the parser recovers name, uuid, and favicon from emitted text."
+ (let* ((track (cj/music-radio--station-track
+ '(:name "Round Trip" :url "https://rt.example/s"
+ :stationuuid "uuid-rt" :favicon "https://rt.example/f.png")))
+ (entries (cj/music--m3u-entries (cj/music--m3u-text (list track) nil)))
+ (meta (cdr (assoc "https://rt.example/s" entries))))
+ (should (equal (plist-get meta :name) "Round Trip"))
+ (should (equal (plist-get meta :uuid) "uuid-rt"))
+ (should (equal (plist-get meta :favicon) "https://rt.example/f.png"))))
+
+(ert-deftest test-music-m3u-text-boundary-empty-playlist-header-only ()
+ "Boundary: an empty track list is just the #EXTM3U header."
+ (should (equal (cj/music--m3u-text nil nil) "#EXTM3U\n")))
+
+(provide 'test-music-config--m3u-text)
+;;; test-music-config--m3u-text.el ends here
diff --git a/tests/test-music-config--radio-station-track.el b/tests/test-music-config--radio-station-track.el
new file mode 100644
index 00000000..5816c776
--- /dev/null
+++ b/tests/test-music-config--radio-station-track.el
@@ -0,0 +1,135 @@
+;;; test-music-config--radio-station-track.el --- station->track + enqueue tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The queue-first radio model: a picked station becomes an EMMS url track
+;; carrying its metadata as track properties (info-title, radio-uuid,
+;; radio-favicon) instead of being written to an .m3u. Covers the pure
+;; station->track builder and the enqueue-and-play buffer mechanics (with
+;; playback mocked at the EMMS boundary).
+
+;;; Code:
+
+(require 'ert)
+(require 'cl-lib)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for 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 'music-config)
+
+(declare-function cj/music-radio--station-track "music-config" (st))
+(declare-function cj/music-radio--enqueue-and-play "music-config" (tracks))
+
+;;; --------------------------- station-track -----------------------------------
+
+(ert-deftest test-music-radio-station-track-normal ()
+ "Normal: a full station plist yields a url track with all three properties."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Adroit Jazz Underground"
+ :url_resolved "https://icecast.walmradio.com:8443/jazz"
+ :stationuuid "ea8059be-d119"
+ :favicon "https://walmradio.com/icon.png"))))
+ (should (eq (emms-track-type track) 'url))
+ (should (equal (emms-track-name track) "https://icecast.walmradio.com:8443/jazz"))
+ (should (equal (emms-track-get track 'info-title) "Adroit Jazz Underground"))
+ (should (equal (emms-track-get track 'radio-uuid) "ea8059be-d119"))
+ (should (equal (emms-track-get track 'radio-favicon) "https://walmradio.com/icon.png"))))
+
+(ert-deftest test-music-radio-station-track-no-url-is-nil ()
+ "Error: a station with no stream URL yields nil, not a broken track."
+ (should-not (cj/music-radio--station-track '(:name "No URL" :url_resolved "" :url ""))))
+
+(ert-deftest test-music-radio-station-track-boundary-no-name ()
+ "Boundary: a nameless station falls back to \"Radio\" for its title."
+ (let ((track (cj/music-radio--station-track '(:url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Radio"))))
+
+(ert-deftest test-music-radio-station-track-boundary-newline-name-stripped ()
+ "Boundary: newlines in an external station name are flattened to spaces."
+ (let ((track (cj/music-radio--station-track
+ '(:name "Line\nBreak" :url "https://s.example/live"))))
+ (should (equal (emms-track-get track 'info-title) "Line Break"))))
+
+(ert-deftest test-music-radio-station-track-boundary-empty-uuid-favicon-absent ()
+ "Boundary: empty-string uuid/favicon are treated as absent, not stored."
+ (let ((track (cj/music-radio--station-track
+ '(:name "X" :url "https://s.example/live" :stationuuid "" :favicon ""))))
+ (should-not (emms-track-get track 'radio-uuid))
+ (should-not (emms-track-get track 'radio-favicon))))
+
+;;; ------------------------- enqueue-and-play ----------------------------------
+
+(defmacro test-music-radio--with-playlist (&rest body)
+ "Run BODY with a fresh, uniquely named playlist buffer and playback mocked."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-radio-enqueue*"))
+ (emms-player-playing-p nil)
+ (started 0) (stopped 0))
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop)
+ (lambda () (setq stopped (1+ stopped)))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-normal-appends-and-plays ()
+ "Normal: tracks land in the playlist buffer in order; the first is selected
+and playback starts."
+ (test-music-radio--with-playlist
+ (let ((t1 (cj/music-radio--station-track '(:name "One" :url "https://one.example/a")))
+ (t2 (cj/music-radio--station-track '(:name "Two" :url "https://two.example/b"))))
+ (cj/music-radio--enqueue-and-play (list t1 t2))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (let ((names '()))
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push (emms-track-name tr) names))
+ (forward-line 1)))
+ (should (equal (nreverse names)
+ '("https://one.example/a" "https://two.example/b"))))
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://one.example/a")))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-appends-after-existing ()
+ "Boundary: an existing queue is kept; new tracks append and the first NEW
+track is the one selected."
+ (test-music-radio--with-playlist
+ (let ((old (cj/music-radio--station-track '(:name "Old" :url "https://old.example/x")))
+ (new (cj/music-radio--station-track '(:name "New" :url "https://new.example/y"))))
+ (cj/music-radio--enqueue-and-play (list old))
+ (cj/music-radio--enqueue-and-play (list new))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (should (equal (emms-track-name (emms-playlist-selected-track))
+ "https://new.example/y"))))))
+
+(ert-deftest test-music-radio-enqueue-and-play-interrupts-current-playback ()
+ "Normal: when something is playing, enqueue stops it before starting."
+ (test-music-radio--with-playlist
+ (let ((emms-player-playing-p t)
+ (tr (cj/music-radio--station-track '(:name "Z" :url "https://z.example/s"))))
+ (cj/music-radio--enqueue-and-play (list tr))
+ (should (= stopped 1))
+ (should (= started 1)))))
+
+(ert-deftest test-music-radio-enqueue-and-play-boundary-nil-is-no-op ()
+ "Boundary: an empty track list does nothing — no buffer churn, no playback."
+ (test-music-radio--with-playlist
+ (cj/music-radio--enqueue-and-play nil)
+ (should (= started 0))
+ (should (= stopped 0))))
+
+(provide 'test-music-config--radio-station-track)
+;;; test-music-config--radio-station-track.el ends here
diff --git a/tests/test-music-config--radio.el b/tests/test-music-config--radio.el
index 56a9c2dc..3923a599 100644
--- a/tests/test-music-config--radio.el
+++ b/tests/test-music-config--radio.el
@@ -3,11 +3,12 @@
;; Author: Craig Jennings <c@cjennings.net>
;;
;;; Commentary:
-;; Phase 1 of the radio-browser lookup (spec docs/specs/2026-07-06-radio-browser-lookup-spec.org):
-;; the pure pieces behind the search command. Parsing a recorded JSON response,
-;; picking a station's stream URL, emitting the .m3u, formatting the marginalia
-;; annotation (Variant B: codec/bitrate/country/votes/tags), disambiguating a
-;; colliding filename, and building the search URL. The network GET and the
+;; The pure pieces behind the radio-browser search command (spec
+;; docs/specs/2026-07-06-radio-browser-lookup-spec.org): parsing a recorded
+;; JSON response, picking a station's stream URL, formatting the marginalia
+;; annotation (Variant B: codec/bitrate/country/votes/tags), and building the
+;; search URL. The station->track builder and the queue mechanics live in
+;; test-music-config--radio-station-track.el; the network GET and the
;; interactive command are exercised in the daemon, not here.
;;; Code:
@@ -22,10 +23,8 @@
(declare-function cj/music-radio--parse-search "music-config" (json-text))
(declare-function cj/music-radio--station-url "music-config" (st))
-(declare-function cj/music-radio--station-m3u "music-config" (st))
(declare-function cj/music-radio--tags-snippet "music-config" (tags n))
(declare-function cj/music-radio--format-candidate "music-config" (st))
-(declare-function cj/music-radio--disambiguate-name "music-config" (name uuid taken))
(declare-function cj/music-radio--search-url "music-config" (server query))
(defconst test-music-radio--fixture
@@ -62,49 +61,20 @@
;;; --------------------------- station-url ------------------------------------
(ert-deftest test-music-radio-station-url-resolved ()
- "Normal: url_resolved is preferred."
+ "Normal: url_resolved wins when present."
(should (equal (cj/music-radio--station-url (test-music-radio--first))
"https://icecast.walmradio.com:8443/jazz")))
(ert-deftest test-music-radio-station-url-fallback-to-url ()
- "Boundary: empty url_resolved falls back to url."
+ "Boundary: an empty url_resolved falls back to url."
(should (equal (cj/music-radio--station-url
- '(:url_resolved "" :url "http://example.test/stream"))
- "http://example.test/stream")))
+ '(:url_resolved "" :url "http://fallback.test/stream"))
+ "http://fallback.test/stream")))
(ert-deftest test-music-radio-station-url-none ()
"Error: neither url_resolved nor url yields nil."
(should-not (cj/music-radio--station-url '(:url_resolved "" :url ""))))
-;;; --------------------------- station-m3u ------------------------------------
-
-(ert-deftest test-music-radio-station-m3u-shape ()
- "Normal: the .m3u carries #EXTM3U, the UUID, #EXTINF, and the stream URL."
- (let ((m3u (cj/music-radio--station-m3u (test-music-radio--first))))
- (should (string-prefix-p "#EXTM3U\n" m3u))
- (should (string-match-p "#RADIOBROWSERUUID:ea8059be-d119-4de3-b27b-0d9bd6aedb17" m3u))
- (should (string-match-p "#EXTINF:1,Adroit Jazz Underground" m3u))
- (should (string-match-p "https://icecast.walmradio.com:8443/jazz" m3u))))
-
-(ert-deftest test-music-radio-station-m3u-no-url-is-nil ()
- "Error: a station with no usable stream URL emits nil (not a broken file)."
- (should-not (cj/music-radio--station-m3u '(:name "Broken" :url_resolved "" :url ""))))
-
-(ert-deftest test-music-radio-station-m3u-captures-favicon ()
- "Normal: a station with a favicon writes a #RADIOBROWSERFAVICON line so the
-cover-art layer needs no lookup later."
- (let ((m3u (cj/music-radio--station-m3u
- '(:name "Art Radio" :url_resolved "https://art.example/live"
- :stationuuid "u-art" :favicon "https://cdn.example/art.png"))))
- (should (string-match-p "#RADIOBROWSERFAVICON:https://cdn.example/art.png" m3u))))
-
-(ert-deftest test-music-radio-station-m3u-no-favicon-omits-line ()
- "Boundary: a station with an empty favicon writes no #RADIOBROWSERFAVICON line."
- (let ((m3u (cj/music-radio--station-m3u
- '(:name "Plain" :url_resolved "https://plain.example/live"
- :stationuuid "u-plain" :favicon ""))))
- (should-not (string-match-p "#RADIOBROWSERFAVICON" m3u))))
-
;;; --------------------------- tags-snippet -----------------------------------
(ert-deftest test-music-radio-tags-snippet-takes-first-n ()
@@ -126,21 +96,6 @@ cover-art layer needs no lookup later."
(should (string-match-p "174208" ann))
(should (string-match-p "bebop" ann))))
-;;; --------------------------- disambiguate-name ------------------------------
-
-(ert-deftest test-music-radio-disambiguate-name-unique ()
- "Normal: a name not already taken keeps its safe basename."
- (should (equal (cj/music-radio--disambiguate-name "Jazz Radio" "uuid1234" '())
- "Jazz_Radio")))
-
-(ert-deftest test-music-radio-disambiguate-name-collision ()
- "Boundary: a colliding name gets a UUID fragment appended, making it unique."
- (let ((first (cj/music-radio--disambiguate-name "Jazz Radio" "aaaabbbbcccc" '())))
- (should (equal first "Jazz_Radio"))
- (should-not (equal (cj/music-radio--disambiguate-name "Jazz Radio" "aaaabbbbcccc"
- (list first))
- first))))
-
;;; --------------------------- search-url -------------------------------------
(ert-deftest test-music-radio-search-url-encodes-query-and-limit ()
@@ -158,7 +113,6 @@ cover-art layer needs no lookup later."
(should-not (string-match-p "name=ambient" u))))
(declare-function cj/music-radio--candidates "music-config" (stations))
-(declare-function cj/music-radio--write-stations "music-config" (stations dir))
;;; --------------------------- candidates (dedup) -----------------------------
@@ -180,41 +134,5 @@ cover-art layer needs no lookup later."
(should (= (length cands) 2))
(should (= (length (delete-dups (copy-sequence keys))) 2))))
-;;; --------------------------- write-stations ---------------------------------
-
-(ert-deftest test-music-radio-write-stations-writes-and-skips ()
- "Normal + Error: a station with a URL is written; one with no URL is skipped and named."
- (let ((dir (make-temp-file "radio-write-" t)))
- (unwind-protect
- (let* ((stations (list (test-music-radio--first)
- '(:name "No URL Here" :url_resolved "" :url "")))
- (result (cj/music-radio--write-stations stations dir)))
- (should (= (length (plist-get result :written)) 1))
- (should (member "No URL Here" (plist-get result :skipped)))
- (should (file-exists-p (car (plist-get result :written)))))
- (delete-directory dir t))))
-
-(ert-deftest test-music-radio-write-stations-radio-suffix ()
- "Normal: a written filename carries the -Radio suffix before .m3u."
- (let ((dir (make-temp-file "radio-write-" t)))
- (unwind-protect
- (let* ((result (cj/music-radio--write-stations (list (test-music-radio--first)) dir))
- (path (car (plist-get result :written))))
- (should (string-suffix-p "-Radio.m3u" path))
- (should (string-match-p "Adroit_Jazz_Underground-Radio\\.m3u\\'" path)))
- (delete-directory dir t))))
-
-(ert-deftest test-music-radio-write-stations-collision-writes-two-files ()
- "Boundary: two same-named stations in one run write two distinct files, no overwrite."
- (let ((dir (make-temp-file "radio-write-" t)))
- (unwind-protect
- (let* ((stations '((:name "Same Name" :url_resolved "http://a.test/s" :stationuuid "aaaa1111")
- (:name "Same Name" :url_resolved "http://b.test/s" :stationuuid "bbbb2222")))
- (result (cj/music-radio--write-stations stations dir))
- (written (plist-get result :written)))
- (should (= (length written) 2))
- (should-not (equal (nth 0 written) (nth 1 written))))
- (delete-directory dir t))))
-
(provide 'test-music-config--radio)
;;; test-music-config--radio.el ends here
diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el
deleted file mode 100644
index 8105ee15..00000000
--- a/tests/test-music-config--safe-filename.el
+++ /dev/null
@@ -1,97 +0,0 @@
-;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--safe-filename function.
-;; Tests the pure helper that sanitizes filenames by replacing invalid chars.
-;;
-;; Test organization:
-;; - Normal Cases: Valid filenames unchanged, spaces replaced
-;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars
-;; - Error Cases: Nil input
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Load production code
-(require 'music-config)
-
-;;; Normal Cases
-
-(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged ()
- "Validate alphanumeric filename remains unchanged."
- (should (string= (cj/music--safe-filename "MyPlaylist123")
- "MyPlaylist123")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged ()
- "Validate filename with hyphens remains unchanged."
- (should (string= (cj/music--safe-filename "my-playlist-name")
- "my-playlist-name")))
-
-(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged ()
- "Validate filename with underscores remains unchanged."
- (should (string= (cj/music--safe-filename "my_playlist_name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-normal-spaces-replaced ()
- "Validate spaces are replaced with underscores."
- (should (string= (cj/music--safe-filename "My Favorite Songs")
- "My_Favorite_Songs")))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced ()
- "Validate special characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "playlist@#$%^&*()")
- "playlist_________")))
-
-(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced ()
- "Validate unicode characters are replaced with underscores."
- (should (string= (cj/music--safe-filename "中文歌曲")
- "____")))
-
-(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid ()
- "Validate mixed valid and invalid characters."
- (should (string= (cj/music--safe-filename "Rock & Roll")
- "Rock___Roll")))
-
-(ert-deftest test-music-config--safe-filename-boundary-dots-replaced ()
- "Validate dots are replaced with underscores."
- (should (string= (cj/music--safe-filename "my.playlist.name")
- "my_playlist_name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced ()
- "Validate slashes are replaced with underscores."
- (should (string= (cj/music--safe-filename "folder/file")
- "folder_file")))
-
-(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars ()
- "Validate consecutive invalid characters each become underscores."
- (should (string= (cj/music--safe-filename "test!!!name")
- "test___name")))
-
-(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged ()
- "Validate empty string remains unchanged."
- (should (string= (cj/music--safe-filename "")
- "")))
-
-(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars ()
- "Validate string with only invalid characters becomes all underscores."
- (should (string= (cj/music--safe-filename "!@#$%")
- "_____")))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error ()
- "Validate nil input signals error."
- (should-error (cj/music--safe-filename nil)
- :type 'wrong-type-argument))
-
-(provide 'test-music-config--safe-filename)
-;;; test-music-config--safe-filename.el ends here
diff --git a/tests/test-music-config--save-helpers.el b/tests/test-music-config--save-helpers.el
new file mode 100644
index 00000000..ffb0a477
--- /dev/null
+++ b/tests/test-music-config--save-helpers.el
@@ -0,0 +1,90 @@
+;;; test-music-config--save-helpers.el --- save default-name + directory tests -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; The pure helpers behind the queue-first save flow: which name the save
+;; prompt pre-fills (`cj/music--save-default-name') and which directory the
+;; file lands in (`cj/music--save-directory'). An all-stream queue saves into
+;; the radio playlist home (`cj/music-radio-save-dir'); anything else saves
+;; into `cj/music-m3u-root'.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for 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 'music-config)
+
+(declare-function cj/music--save-default-name "music-config" (tracks file entries))
+(declare-function cj/music--save-directory "music-config" (tracks))
+(declare-function cj/music-radio--station-track "music-config" (st))
+
+;;; --------------------------- save-default-name -------------------------------
+
+(ert-deftest test-music-save-default-name-normal-associated-file-wins ()
+ "Normal: an associated playlist file names the save, station or not."
+ (let ((tr (cj/music-radio--station-track '(:name "S" :url "https://s.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) "/pl/jazz.m3u" nil)
+ "jazz"))))
+
+(ert-deftest test-music-save-default-name-normal-station-title ()
+ "Normal: with no file, the first url track's station name pre-fills."
+ (let ((tr (cj/music-radio--station-track
+ '(:name "Groove Salad" :url "https://gs.example/x"))))
+ (should (equal (cj/music--save-default-name (list tr) nil nil)
+ "Groove Salad"))))
+
+(ert-deftest test-music-save-default-name-normal-first-url-track-wins ()
+ "Normal: a file track ahead of the station doesn't block the station name;
+the first URL track with a name wins."
+ (let ((f (emms-track 'file "/music/a.flac"))
+ (tr (cj/music-radio--station-track
+ '(:name "Second Pick" :url "https://sp.example/x"))))
+ (should (equal (cj/music--save-default-name (list f tr) nil nil)
+ "Second Pick"))))
+
+(ert-deftest test-music-save-default-name-normal-entries-fallback ()
+ "Normal: a propertyless url track resolves its name from ENTRIES."
+ (let* ((url "https://legacy.example/s")
+ (tr (emms-track 'url url))
+ (entries (list (list url :name "Legacy FM" :uuid nil :favicon nil))))
+ (should (equal (cj/music--save-default-name (list tr) nil entries)
+ "Legacy FM"))))
+
+(ert-deftest test-music-save-default-name-boundary-no-candidates-nil ()
+ "Boundary: no file, no url tracks -> nil (caller falls back to a timestamp)."
+ (should-not (cj/music--save-default-name
+ (list (emms-track 'file "/music/a.flac")) nil nil))
+ (should-not (cj/music--save-default-name nil nil nil)))
+
+;;; ---------------------------- save-directory ---------------------------------
+
+(ert-deftest test-music-save-directory-normal-all-streams-radio-dir ()
+ "Normal: an all-stream queue saves into the radio playlist dir."
+ (let ((u1 (emms-track 'url "https://a.example/1"))
+ (u2 (emms-track 'url "https://b.example/2")))
+ (should (equal (cj/music--save-directory (list u1 u2))
+ cj/music-radio-save-dir))))
+
+(ert-deftest test-music-save-directory-normal-mixed-goes-to-m3u-root ()
+ "Normal: any non-stream track routes the save to the music playlist root."
+ (let ((u (emms-track 'url "https://a.example/1"))
+ (f (emms-track 'file "/music/a.flac")))
+ (should (equal (cj/music--save-directory (list u f))
+ cj/music-m3u-root))))
+
+(ert-deftest test-music-save-directory-boundary-empty-goes-to-m3u-root ()
+ "Boundary: an empty queue defaults to the music playlist root."
+ (should (equal (cj/music--save-directory nil) cj/music-m3u-root)))
+
+(provide 'test-music-config--save-helpers)
+;;; test-music-config--save-helpers.el ends here
diff --git a/tests/test-music-config-create-radio-station.el b/tests/test-music-config-create-radio-station.el
index 1f4365a4..21241965 100644
--- a/tests/test-music-config-create-radio-station.el
+++ b/tests/test-music-config-create-radio-station.el
@@ -1,153 +1,108 @@
-;;; test-music-config-create-radio-station.el --- Tests for radio station creation -*- coding: utf-8; lexical-binding: t; -*-
+;;; test-music-config-create-radio-station.el --- Tests for manual radio-station entry -*- 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.
+;; Unit tests for cj/music-create-radio-station under the queue-first model:
+;; a hand-entered name + URL becomes a url track in the playlist queue (with
+;; the name as its title property) and playback starts. Nothing is written
+;; to disk — saving is the normal playlist-save flow.
;;
;; 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
-;;
+;; - Normal Cases: track queued with title, playback started, no file written
+;; - Boundary Cases: unicode name preserved verbatim
+;; - Error Cases: empty name, empty URL
+
;;; Code:
(require 'ert)
-(require 'testutil-general)
+(require 'cl-lib)
;; 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
+(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)))
-(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"))
+(require 'emms)
+(require 'music-config)
-(defun test-music-config-create-radio-station-teardown ()
- "Clean up test environment."
- (cj/delete-test-base-dir))
+(defmacro test-music-create-radio--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer, playback mocked, messages captured."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-create-radio*"))
+ (emms-player-playing-p nil)
+ (started 0) (msg nil))
+ (ignore started msg)
+ (unwind-protect
+ (cl-letf (((symbol-function 'emms-start)
+ (lambda () (setq started (1+ started))))
+ ((symbol-function 'emms-stop) #'ignore)
+ ((symbol-function 'message)
+ (lambda (fmt &rest args)
+ (when fmt (setq msg (apply #'format fmt args))))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name)))))
+
+(defun test-music-create-radio--queued-tracks ()
+ "Track objects currently in the test playlist buffer."
+ (let ((tracks '()))
+ (with-current-buffer cj/music-playlist-buffer-name
+ (save-excursion
+ (goto-char (point-min))
+ (while (not (eobp))
+ (when-let ((tr (emms-playlist-track-at (point))))
+ (push tr tracks))
+ (forward-line 1))))
+ (nreverse tracks)))
;;; 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)))
+(ert-deftest test-music-config-create-radio-station-normal-queues-track ()
+ "Normal: name+url queues a url track carrying the name, and playback starts."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Jazz FM" "http://stream.jazzfm.com/radio")
+ (let ((tracks (test-music-create-radio--queued-tracks)))
+ (should (= (length tracks) 1))
+ (should (eq (emms-track-type (car tracks)) 'url))
+ (should (equal (emms-track-name (car tracks)) "http://stream.jazzfm.com/radio"))
+ (should (equal (emms-track-get (car tracks) 'info-title) "Jazz FM")))
+ (should (= started 1))
+ (should (string-match-p "Jazz FM" msg))))
+
+(ert-deftest test-music-config-create-radio-station-normal-writes-no-file ()
+ "Normal: nothing lands on disk — saving is the playlist-save flow's job."
+ (let ((tmp (file-name-as-directory (make-temp-file "cj-radio-nofile-" t))))
(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))))
+ (test-music-create-radio--with-env
+ (let ((cj/music-m3u-root tmp)
+ (cj/music-radio-save-dir tmp))
+ (cj/music-create-radio-station "NPR" "https://example.test/stream")
+ (should-not (directory-files tmp nil "\\.m3u\\'"))))
+ (delete-directory tmp t))))
;;; 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))))
+(ert-deftest test-music-config-create-radio-station-boundary-unicode-name ()
+ "Boundary: a unicode name is kept verbatim on the track (no filename munging)."
+ (test-music-create-radio--with-env
+ (cj/music-create-radio-station "Café Del Mar ☕" "https://cafe.example/stream")
+ (should (equal (emms-track-get (car (test-music-create-radio--queued-tracks))
+ 'info-title)
+ "Café Del Mar ☕"))))
;;; 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))
+ "Error: empty name signals user-error."
+ (should-error (cj/music-create-radio-station "" "https://x") :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))))
+ "Error: empty URL signals user-error."
+ (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
(provide 'test-music-config-create-radio-station)
;;; test-music-config-create-radio-station.el ends here
diff --git a/tests/test-music-config-more-commands.el b/tests/test-music-config-more-commands.el
index c351c1f1..530aa379 100644
--- a/tests/test-music-config-more-commands.el
+++ b/tests/test-music-config-more-commands.el
@@ -9,7 +9,9 @@
;; cj/music-playlist-edit
;; cj/music-playlist-toggle
;; cj/music-playlist-show
-;; cj/music-create-radio-station
+;;
+;; cj/music-create-radio-station lives in
+;; test-music-config-create-radio-station.el.
;;; Code:
@@ -17,12 +19,21 @@
(require 'cl-lib)
(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module.
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for 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 'music-config)
;; Top-level defvars so let-binds reach the dynamic var under lexical
;; scope.
(defvar cj/music-playlist-file nil)
-(defvar emms-source-playlist-ask-before-overwrite t)
;;; cj/music-playlist-load
@@ -56,28 +67,87 @@
;;; cj/music-playlist-save
-(ert-deftest test-music-playlist-save-writes-fresh-name ()
- "Normal: save with a fresh name writes via emms-playlist-save."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-save-" t)))
- (cj/music-m3u-root tmp)
- saved-args msg)
- (unwind-protect
- (cl-letf (((symbol-function 'cj/music--get-m3u-basenames)
- (lambda () nil))
- ((symbol-function 'completing-read)
- (lambda (&rest _) "fresh"))
- ((symbol-function 'cj/music--ensure-playlist-buffer)
- (lambda () (current-buffer)))
- ((symbol-function 'emms-playlist-save)
- (lambda (fmt path) (setq saved-args (list fmt path))))
- ((symbol-function 'cj/music--sync-playlist-file) #'ignore)
- ((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-playlist-save))
- (delete-directory tmp t))
- (should (equal (car saved-args) 'm3u))
- (should (string-match-p "fresh\\.m3u\\'" (cadr saved-args)))
- (should (string-match-p "Saved playlist" msg))))
+(defmacro test-music-save--with-env (&rest body)
+ "Run BODY with a fresh playlist buffer and temp save directories.
+Binds TMP-M3U and TMP-RADIO (both cleaned up) and captures completing-read's
+INITIAL argument in CR-INITIAL."
+ `(let* ((cj/music-playlist-buffer-name
+ (generate-new-buffer-name "*test-save*"))
+ (tmp-m3u (file-name-as-directory (make-temp-file "cj-save-m3u-" t)))
+ (tmp-radio (file-name-as-directory (make-temp-file "cj-save-radio-" t)))
+ (cj/music-m3u-root tmp-m3u)
+ (cj/music-radio-save-dir tmp-radio)
+ (cr-initial 'unset))
+ (ignore cr-initial)
+ (unwind-protect
+ (cl-letf (((symbol-function 'message) #'ignore)
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _coll &optional _pred _req initial _hist def &rest _)
+ (setq cr-initial initial)
+ (or initial def "fallback"))))
+ ,@body)
+ (when (get-buffer cj/music-playlist-buffer-name)
+ (kill-buffer cj/music-playlist-buffer-name))
+ (delete-directory tmp-m3u t)
+ (delete-directory tmp-radio t))))
+
+(defun test-music-save--queue (tracks)
+ "Put TRACKS into the (fresh) test playlist buffer."
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (save-excursion
+ (goto-char (point-max))
+ (dolist (tr tracks)
+ (emms-playlist-insert-track tr)))))
+
+(ert-deftest test-music-playlist-save-station-queue-prefills-and-targets-radio-dir ()
+ "Normal: an all-stream queue pre-fills the station name and saves into the
+radio dir with the station metadata written."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (emms-track-set tr 'radio-uuid "uuid-gs")
+ (test-music-save--queue (list tr)))
+ (cj/music-playlist-save)
+ (should (equal cr-initial "Groove Salad"))
+ (let ((file (expand-file-name "Groove Salad.m3u" tmp-radio)))
+ (should (file-exists-p file))
+ (with-temp-buffer
+ (insert-file-contents file)
+ (let ((text (buffer-string)))
+ (should (string-match-p "^#EXTINF:-1,Groove Salad$" text))
+ (should (string-match-p "^#RADIOBROWSERUUID:uuid-gs$" text))
+ (should (string-match-p "^https://gs\\.example/stream$" text)))))
+ (should-not (directory-files tmp-m3u nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-file-queue-targets-m3u-root-no-prefill ()
+ "Normal: a file queue saves into the music root with no station pre-fill."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'file "/music/a.flac")))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (= (length (directory-files tmp-m3u nil "\\.m3u\\'")) 1))
+ (should-not (directory-files tmp-radio nil "\\.m3u\\'"))))
+
+(ert-deftest test-music-playlist-save-associated-file-name-wins-over-station ()
+ "Normal: a queue with an associated playlist file defaults to that name,
+even when it contains stations."
+ (test-music-save--with-env
+ (let ((tr (emms-track 'url "https://gs.example/stream")))
+ (emms-track-set tr 'info-title "Groove Salad")
+ (test-music-save--queue (list tr)))
+ (with-current-buffer (cj/music--ensure-playlist-buffer)
+ (setq cj/music-playlist-file (expand-file-name "morning.m3u" tmp-radio)))
+ (cj/music-playlist-save)
+ (should-not cr-initial)
+ (should (file-exists-p (expand-file-name "morning.m3u" tmp-radio)))))
+
+(ert-deftest test-music-playlist-save-error-empty-name ()
+ "Error: an empty name at the prompt signals user-error, not a hidden .m3u."
+ (test-music-save--with-env
+ (test-music-save--queue (list (emms-track 'url "https://x.example/s")))
+ (cl-letf (((symbol-function 'completing-read) (lambda (&rest _) "")))
+ (should-error (cj/music-playlist-save) :type 'user-error))
+ (should-not (directory-files tmp-radio nil "m3u"))))
;;; cj/music-playlist-edit
@@ -138,36 +208,5 @@
(should (eq switched buf))
(should msg)))
-;;; cj/music-create-radio-station
-
-(ert-deftest test-music-create-radio-station-writes-m3u ()
- "Normal: with name+url, an EXTM3U-style file is written into music-m3u-root."
- (let* ((tmp (file-name-as-directory (make-temp-file "cj-music-radio-" t)))
- (cj/music-m3u-root tmp)
- msg)
- (unwind-protect
- (progn
- (cl-letf (((symbol-function 'message)
- (lambda (fmt &rest args) (setq msg (apply #'format fmt args)))))
- (cj/music-create-radio-station "NPR" "https://example.test/stream"))
- (let ((file (expand-file-name "NPR_Radio.m3u" tmp)))
- (should (file-exists-p file))
- (with-temp-buffer
- (insert-file-contents file)
- (let ((text (buffer-string)))
- (should (string-match-p "#EXTM3U" text))
- (should (string-match-p "NPR" text))
- (should (string-match-p "https://example.test/stream" text))))))
- (delete-directory tmp t))
- (should (string-match-p "Created radio station" msg))))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-name ()
- "Error: an empty name is rejected with user-error."
- (should-error (cj/music-create-radio-station "" "https://x") :type 'user-error))
-
-(ert-deftest test-music-create-radio-station-rejects-empty-url ()
- "Error: an empty URL is rejected with user-error."
- (should-error (cj/music-create-radio-station "NPR" "") :type 'user-error))
-
(provide 'test-music-config-more-commands)
;;; test-music-config-more-commands.el ends here