diff options
| -rw-r--r-- | docs/specs/2026-07-06-radio-browser-lookup-spec.org | 1 | ||||
| -rw-r--r-- | modules/music-config.el | 333 | ||||
| -rw-r--r-- | tests/test-music-config--art-cache-key.el | 15 | ||||
| -rw-r--r-- | tests/test-music-config--art-favicon-url.el | 17 | ||||
| -rw-r--r-- | tests/test-music-config--m3u-text.el | 93 | ||||
| -rw-r--r-- | tests/test-music-config--radio-station-track.el | 135 | ||||
| -rw-r--r-- | tests/test-music-config--radio.el | 102 | ||||
| -rw-r--r-- | tests/test-music-config--safe-filename.el | 97 | ||||
| -rw-r--r-- | tests/test-music-config--save-helpers.el | 90 | ||||
| -rw-r--r-- | tests/test-music-config-create-radio-station.el | 197 | ||||
| -rw-r--r-- | tests/test-music-config-more-commands.el | 149 |
11 files changed, 729 insertions, 500 deletions
diff --git a/docs/specs/2026-07-06-radio-browser-lookup-spec.org b/docs/specs/2026-07-06-radio-browser-lookup-spec.org index 637ec4f0..3118881b 100644 --- a/docs/specs/2026-07-06-radio-browser-lookup-spec.org +++ b/docs/specs/2026-07-06-radio-browser-lookup-spec.org @@ -8,6 +8,7 @@ :PROPERTIES: :ID: 4839b2c8-0552-4029-9e0f-4bf69b9a4dcd :END: +- 2026-07-08 Wed @ 10:16:25 -0500 — model revision (Craig): queue-first, save-on-request. The lookup (and the manual m creator) no longer writes .m3u files at pick time; picked stations become url tracks in the queue (name/uuid/favicon as track properties) and play immediately. Saving is the normal playlist save (moved to w — the earlier S binding was shadowed by emms-stop): an all-stream queue saves into the MPD playlist dir, the plain station name pre-fills the prompt (the "-Radio" filename suffix is retired), and a custom .m3u emitter writes the station metadata back out so load round-trips names and cover art. First pick names a multi-station save. Still DOING. - 2026-07-06 Mon @ 14:04:53 -0500 — build UX changes (Craig, during Phase 2 verify): pulled tag search from vNext into v1; the feature is a Radio row in the playlist buffer (n: by name, t: by tag, m: enter manually) rather than one command on S; single mode moved off t to s and emms-stop off s to S; created filenames carry a "-Radio" suffix. Still DOING. - 2026-07-06 Mon @ 13:08:32 -0500 — spec-response Phase 6: decomposed into build tasks in todo.org (parent stamped :SPEC_ID:); READY -> DOING. Build under way. - 2026-07-06 Mon @ 13:01:55 -0500 — spec-response: all 7 findings dispositioned (6 accept/modify, 1 resolved via new Decision 5); decisions [5/5], findings [7/7]. No blocking finding remains; readiness rubric re-run on the expanded spec passes. DRAFT -> READY. Awaiting Craig's go to decompose into build tasks (spec-response Phase 6, flips READY -> DOING). diff --git a/modules/music-config.el b/modules/music-config.el index 0699be4f..cd0d9d19 100644 --- a/modules/music-config.el +++ b/modules/music-config.el @@ -38,7 +38,6 @@ ;; compiles as a dynamic bind, not a dead lexical local -- otherwise emms / ;; orderless never see the binding (the lexical-binding foreign-special-var trap). (defvar orderless-smart-case) -(defvar emms-source-playlist-ask-before-overwrite) (defvar emms-playlist-buffer-p) (defvar emms-playlist-buffer) (defvar emms-random-playlist) @@ -109,11 +108,15 @@ falls back to the plain text player (names, a dim glyph, a thin status line)." (declare-function emms-track-name "emms") (declare-function emms-track-type "emms") (declare-function emms-track-get "emms") +(declare-function emms-track "emms") +(declare-function emms-track-set "emms") (declare-function emms-track-simple-description "emms") (declare-function emms-playlist-current-selected-track "emms") (declare-function emms-playlist-select "emms") +(declare-function emms-playlist-selected-track "emms") (declare-function emms-playlist-clear "emms") -(declare-function emms-playlist-save "emms-source-playlist") +(declare-function emms-playlist-insert-track "emms") +(declare-function emms-stop "emms") (declare-function emms-start "emms") (declare-function emms-random "emms") (declare-function emms-next "emms") @@ -349,18 +352,22 @@ modification date so marginalia can show them." tracks)))) (nreverse tracks))))) -(defun cj/music--playlist-tracks () - "Return list of track names from current EMMS playlist buffer." +(defun cj/music--playlist-track-objects () + "Return the track objects from the current EMMS playlist buffer, in order." (let ((tracks '())) (with-current-buffer (cj/music--ensure-playlist-buffer) (save-excursion (goto-char (point-min)) (while (not (eobp)) (when-let ((track (emms-playlist-track-at (point)))) - (push (emms-track-name track) tracks)) + (push track tracks)) (forward-line 1)))) (nreverse tracks))) +(defun cj/music--playlist-tracks () + "Return list of track names from current EMMS playlist buffer." + (mapcar #'emms-track-name (cj/music--playlist-track-objects))) + (defun cj/music--dedup-m3u-files (paths) "Return (BASENAME . PATH) conses for PATHS, first occurrence of a basename winning. Pure helper: since `cj/music--get-m3u-files' scans `cj/music-m3u-roots' in order, @@ -387,10 +394,6 @@ collision the earlier directory wins." (mapcar (lambda (pair) (file-name-sans-extension (car pair))) (cj/music--get-m3u-files))) -(defun cj/music--safe-filename (name) - "Return NAME made filesystem-safe by replacing bad chars with underscores." - (replace-regexp-in-string "[^a-zA-Z0-9_-]" "_" name)) - (defun cj/music--playlist-modified-p () "Return non-nil if current playlist differs from its associated M3U file." (and cj/music-playlist-file @@ -537,23 +540,103 @@ Replaces current playlist." (message "Loaded playlist: %s" choice-name))) +(defun cj/music--m3u-track-lines (track entries) + "The .m3u lines for TRACK. +A file track is its bare absolute path. A url track carries its station +metadata — an #EXTINF label plus #RADIOBROWSERUUID / #RADIOBROWSERFAVICON when +known — read from the track's properties first, then its ENTRIES metadata (a +loaded legacy playlist has entries but no properties), so a saved station +keeps its display name and cover art on reload." + (let ((name (emms-track-name track))) + (if (not (eq (emms-track-type track) 'url)) + (concat name "\n") + (let* ((meta (cdr (assoc name entries))) + (label (or (emms-track-get track 'info-title) + (plist-get meta :name) + (cj/music--tidy-host name))) + (uuid (or (emms-track-get track 'radio-uuid) + (plist-get meta :uuid))) + (favicon (or (emms-track-get track 'radio-favicon) + (plist-get meta :favicon)))) + (concat + (if (and (stringp uuid) (not (string-empty-p uuid))) + (format "#RADIOBROWSERUUID:%s\n" uuid) + "") + (if (and (stringp favicon) (not (string-empty-p favicon))) + (format "#RADIOBROWSERFAVICON:%s\n" + (replace-regexp-in-string "[\r\n]+" " " favicon)) + "") + (format "#EXTINF:-1,%s\n" + (replace-regexp-in-string "[\r\n]+" " " label)) + name "\n"))))) + +(defun cj/music--m3u-text (tracks entries) + "The full .m3u file text for TRACKS, station metadata from ENTRIES. +The stock EMMS m3u writer emits bare URLs; this emitter writes the comment +lines `cj/music--m3u-entries' parses, so save -> load round-trips." + (concat "#EXTM3U\n" + (mapconcat (lambda (tr) (cj/music--m3u-track-lines tr entries)) + tracks ""))) + +(defun cj/music--write-playlist-file (path tracks entries) + "Write TRACKS to PATH as .m3u text, station metadata from ENTRIES. +Refreshes the radio metadata cache since a new .m3u just landed." + (with-temp-file path + (insert (cj/music--m3u-text tracks entries))) + (cj/music--refresh-radio-name-map)) + +(defun cj/music--save-default-name (tracks file entries) + "The name the save prompt should offer. +FILE (the playlist's associated .m3u) wins when present. Otherwise the first +url track's station name — its title property, else its #EXTINF label from +ENTRIES. Nil when neither applies (the caller falls back to a timestamp)." + (if file + (file-name-sans-extension (file-name-nondirectory file)) + (cl-loop for tr in tracks + when (eq (emms-track-type tr) 'url) + thereis (or (emms-track-get tr 'info-title) + (plist-get (cdr (assoc (emms-track-name tr) entries)) + :name))))) + +(defun cj/music--save-directory (tracks) + "Directory a saved playlist targets. +An all-stream queue is a radio playlist and saves into +`cj/music-radio-save-dir'; anything else saves into `cj/music-m3u-root'." + (if (and tracks + (cl-every (lambda (tr) (eq (emms-track-type tr) 'url)) tracks)) + cj/music-radio-save-dir + cj/music-m3u-root)) + (defun cj/music-playlist-save () - "Save current EMMS playlist to a file in cj/music-m3u-root. -Offers completion over existing names but allows new names." + "Save the current EMMS playlist to an .m3u file. +An all-stream queue saves into `cj/music-radio-save-dir' (the radio playlist +home); anything else saves into `cj/music-m3u-root'. A queue of freshly +looked-up stations pre-fills the first station's name in the prompt; a +playlist with an associated file keeps that file's name as the default. +Station metadata (name, uuid, favicon) is written with each stream so a +reloaded playlist keeps its display name and cover art." (interactive) - (let* ((existing (cj/music--get-m3u-basenames)) - (default-name (if cj/music-playlist-file - (file-name-sans-extension (file-name-nondirectory cj/music-playlist-file)) - (format-time-string "playlist-%Y%m%d-%H%M%S"))) - (chosen (completing-read "Save playlist as: " existing nil nil nil nil default-name)) + (let* ((tracks (cj/music--playlist-track-objects)) + (entries (cj/music--radio-metadata)) + (existing (cj/music--get-m3u-basenames)) + (assoc-file (with-current-buffer (cj/music--ensure-playlist-buffer) + cj/music-playlist-file)) + (prefill (and (null assoc-file) + (cj/music--save-default-name tracks nil entries))) + (default-name (or (cj/music--save-default-name tracks assoc-file entries) + (format-time-string "playlist-%Y%m%d-%H%M%S"))) + (chosen (completing-read "Save playlist as: " existing nil nil + prefill nil default-name)) (filename (if (string-suffix-p ".m3u" chosen) chosen (concat chosen ".m3u"))) - (full (expand-file-name filename cj/music-m3u-root))) + (dir (cj/music--save-directory tracks)) + (full (expand-file-name filename dir))) + (when (string-empty-p (string-trim chosen)) + (user-error "Playlist name cannot be empty")) (when (and (file-exists-p full) (not (cj/confirm-strong (format "Overwrite %s? " filename)))) (user-error "Aborted saving playlist")) - (with-current-buffer (cj/music--ensure-playlist-buffer) - (let ((emms-source-playlist-ask-before-overwrite nil)) - (emms-playlist-save 'm3u full))) + (make-directory dir t) + (cj/music--write-playlist-file full tracks entries) (cj/music--sync-playlist-file full) (message "Saved playlist: %s" filename))) @@ -590,8 +673,9 @@ Offers completion over existing names but allows new names." (let ((path cj/music-playlist-file)) (when (cj/music--playlist-modified-p) (when (yes-or-no-p "Playlist modified. Save before editing? ") - (let ((emms-source-playlist-ask-before-overwrite nil)) - (emms-playlist-save 'm3u path)))) + (cj/music--write-playlist-file path + (cj/music--playlist-track-objects) + (cj/music--radio-metadata)))) ;; Re-validate existence before opening (if (file-exists-p path) (find-file-other-window path) @@ -838,7 +922,7 @@ 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" "queue radio station" "C-; m SPC" "pause" "C-; m s" "stop" "C-; m n" "next track" @@ -1104,7 +1188,7 @@ The rule uses a resize-safe :align-to span, not a hardcoded character count." (funcall mode-indicator "x" "consume" cj/music-consume-mode) "\n" (propertize "Keys " 'face 'cj/music-header-face) (propertize " : " 'face 'cj/music-header-face) - (propertize "a:add c:clear L:load S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss" + (propertize "a:add c:clear L:load w:save S:stop SPC:pause <>:skip ↑↓:move C-↑↓:reorder q:dismiss" 'face 'cj/music-keyhint-face) "\n" (propertize "Radio " 'face 'cj/music-header-face) (propertize " : " 'face 'cj/music-header-face) @@ -1320,7 +1404,7 @@ unless fancy." ("L" . cj/music-playlist-load) ("E" . cj/music-playlist-edit) ("g" . cj/music-playlist-reload) - ("S" . cj/music-playlist-save) + ("w" . cj/music-playlist-save) ;; Track reordering ("S-<up>" . emms-playlist-mode-shift-track-up) ("S-<down>" . emms-playlist-mode-shift-track-down) @@ -1336,7 +1420,10 @@ unless fancy." ;;; Radio station creation (defun cj/music-create-radio-station (name url) - "Create a radio station M3U playlist with NAME and URL in cj/music-m3u-root." + "Queue and play a radio station from a hand-entered NAME and URL. +The station becomes a url track in the playlist (NAME as its title) and +playback starts. Nothing is written to disk — save the queue with the normal +playlist save, where NAME pre-fills the prompt." (interactive (list (read-string "Radio station name: ") (read-string "Stream URL: "))) @@ -1344,25 +1431,21 @@ unless fancy." (user-error "Radio station name cannot be empty")) (when (string-empty-p url) (user-error "Stream URL cannot be empty")) - (let* ((safe (cj/music--safe-filename name)) - (file (expand-file-name (concat safe "_Radio.m3u") cj/music-m3u-root)) - (content (format "#EXTM3U\n#EXTINF:-1,%s\n%s\n" name url))) - (when (and (file-exists-p file) - (not (cj/confirm-strong (format "Overwrite %s? " (file-name-nondirectory file))))) - (user-error "Aborted creating radio station")) - (with-temp-file file - (insert content)) - (cj/music--refresh-radio-name-map) - (message "Created radio station: %s" (file-name-nondirectory file)))) + (cj/emms--setup) + (cj/music-radio--enqueue-and-play + (list (cj/music-radio--station-track (list :name name :url url)))) + (message "Queued radio station: %s" name)) ;; The manual name+URL creator is bound to m in the radio row below (see the ;; with-eval-after-load block near the radio-browser lookup), not R. ;; --------------------------- Radio-browser Lookup ---------------------------- -;; Search radio-browser.info and turn a selection into a playable radio .m3u. -;; Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The pure pieces -;; (parse / emit / format / filename) carry the tests; the network GET and the -;; interactive command are exercised live. +;; Search radio-browser.info and queue a selection as playing url tracks, each +;; carrying its station metadata as track properties. Nothing is written at +;; pick time; the playlist save writes the metadata back out as .m3u comment +;; lines. Spec: docs/specs/2026-07-06-radio-browser-lookup-spec.org. The +;; pure pieces (parse / track-build / format) carry the tests; the network GET +;; and the interactive command are exercised live. (require 'url) @@ -1378,11 +1461,8 @@ The project asks clients to identify themselves.") "Maximum number of stations a radio-browser search returns.") (defvar cj/music-radio-save-dir (expand-file-name "~/.local/share/mpd/playlists/") - "Directory new radio-browser stations are written to (the radio home).") - -(defvar cj/music-radio-filename-suffix "-Radio" - "Suffix appended to a created station's basename, before the .m3u extension. -Marks a lookup-created station in the playlist directory.") + "Directory radio playlists are saved to (the radio home). +The playlist save targets it when every track in the queue is a stream.") (defun cj/music-radio--parse-search (json-text) "Parse a radio-browser JSON-TEXT array into a list of station plists. @@ -1400,29 +1480,43 @@ clear message instead of a stack trace." (cond ((and (stringp r) (not (string-empty-p r))) r) ((and (stringp u) (not (string-empty-p u))) u)))) -(defun cj/music-radio--station-m3u (st) - "Return the .m3u file text for station ST, or nil when it has no stream URL. -Matches the existing radio files: #EXTM3U, an optional #RADIOBROWSERUUID line, -#EXTINF:1,<name>, and the stream URL." - (let* ((url (cj/music-radio--station-url st)) - ;; Strip newlines so an unexpected multi-line name can't inject extra - ;; m3u lines; the API returns single-line names, but it's external data. - (name (replace-regexp-in-string "[\r\n]+" " " - (or (plist-get st :name) "Radio"))) - (uuid (plist-get st :stationuuid)) - (favicon (plist-get st :favicon))) - (when url - (concat "#EXTM3U\n" - (if (and (stringp uuid) (not (string-empty-p uuid))) - (format "#RADIOBROWSERUUID:%s\n" uuid) - "") - ;; Capture the favicon at creation so the cover-art layer needs - ;; no byuuid lookup later; same newline-strip guard as the name. - (if (and (stringp favicon) (not (string-empty-p favicon))) - (format "#RADIOBROWSERFAVICON:%s\n" - (replace-regexp-in-string "[\r\n]+" " " favicon)) - "") - (format "#EXTINF:1,%s\n%s\n" name url))))) +(defun cj/music-radio--station-track (st) + "Return an EMMS url track for station ST, or nil when it has no stream URL. +The track carries the station name as `info-title' plus `radio-uuid' and +`radio-favicon' properties, so display names and cover art need no .m3u on +disk; the playlist save writes the same metadata back out as comment lines. +Newlines in the external name/favicon are flattened so they can't inject +extra .m3u lines at save time." + (when-let ((url (cj/music-radio--station-url st))) + (let ((track (emms-track 'url url)) + (name (replace-regexp-in-string "[\r\n]+" " " + (or (plist-get st :name) "Radio"))) + (uuid (plist-get st :stationuuid)) + (favicon (plist-get st :favicon))) + (emms-track-set track 'info-title name) + (when (and (stringp uuid) (not (string-empty-p uuid))) + (emms-track-set track 'radio-uuid uuid)) + (when (and (stringp favicon) (not (string-empty-p favicon))) + (emms-track-set track 'radio-favicon + (replace-regexp-in-string "[\r\n]+" " " favicon))) + track))) + +(defun cj/music-radio--enqueue-and-play (tracks) + "Append TRACKS to the playlist buffer and play the first of them. +Interrupts whatever is playing; the rest of the queue is left in place. A nil +TRACKS is a no-op." + (when tracks + (cj/emms--setup) + (with-current-buffer (cj/music--ensure-playlist-buffer) + (let ((first-pos nil)) + (save-excursion + (dolist (tr tracks) + (goto-char (point-max)) + (unless first-pos (setq first-pos (point))) + (emms-playlist-insert-track tr))) + (emms-playlist-select first-pos))) + (when emms-player-playing-p (emms-stop)) + (emms-start))) (defun cj/music-radio--tags-snippet (tags n) "Return the first N comma-separated TAGS as a trimmed display string. @@ -1442,15 +1536,6 @@ Variant B: codec, bitrate, country, votes, and the first few tags." (tags (cj/music-radio--tags-snippet (plist-get st :tags) 3))) (format "%-4s %-5s %-2s ♥%d %s" codec bitrate cc votes tags))) -(defun cj/music-radio--disambiguate-name (name uuid taken) - "Return a filesystem-safe basename for NAME, unique against the TAKEN list. -On a collision, append a short fragment of UUID. Pure helper: keeps two -same-named stations picked in one search from overwriting each other." - (let ((base (cj/music--safe-filename name))) - (if (member base taken) - (concat base "_" (substring (or uuid "x") 0 (min 8 (length (or uuid "x"))))) - base))) - (defun cj/music-radio--search-url (server query &optional field) "Build the radio-browser station-search URL for QUERY against SERVER. FIELD is the search field: \"name\" (default) or \"tag\"." @@ -1508,29 +1593,6 @@ to one station. Pure helper." (puthash disp t seen) (push (cons disp st) out))))) -(defun cj/music-radio--write-stations (stations dir) - "Write each station in STATIONS as an .m3u into DIR. -Skips a station with no stream URL and disambiguates a filename that collides -with an already-written one this run. Returns a plist (:written PATHS :skipped -NAMES). Creates DIR when absent." - (make-directory dir t) - (let ((taken '()) (written '()) (skipped '())) - (dolist (st stations) - (let ((m3u (cj/music-radio--station-m3u st))) - (if (not m3u) - (push (or (plist-get st :name) "(unnamed)") skipped) - (let* ((base (cj/music-radio--disambiguate-name - (or (plist-get st :name) "Radio") - (plist-get st :stationuuid) taken)) - (path (expand-file-name - (concat base cj/music-radio-filename-suffix ".m3u") dir))) - (push base taken) - (with-temp-file path (insert m3u)) - (push path written))))) - ;; New .m3u files landed, so the cached url->label map is stale. - (when written (cj/music--refresh-radio-name-map)) - (list :written (nreverse written) :skipped (nreverse skipped)))) - (defun cj/music-radio--completion-table (candidates) "Completion table over CANDIDATES carrying the Variant-B marginalia affix." (lambda (string pred action) @@ -1571,21 +1633,17 @@ selection order; each pick is removed from the pool so it can't be chosen twice. (setq pool (delq cell pool)))))) (nreverse chosen))) -(defun cj/music-radio--play (paths) - "Play the first .m3u in PATHS immediately (interrupting), enqueue the rest." - (when paths - (emms-play-playlist (car paths)) - (dolist (p (cdr paths)) - (emms-add-playlist p)))) - (defun cj/music-radio--search-and-play (query field) - "Search radio-browser for QUERY on FIELD, pick stations, then create and play. + "Search radio-browser for QUERY on FIELD, pick stations, then queue and play. FIELD is \"name\" or \"tag\". Lists matching stations (annotated with codec, -bitrate, country, votes, and tags), lets you pick several one at a time, writes -each as an .m3u into `cj/music-radio-save-dir', then plays the selection through -mpv (interrupting whatever was playing)." +bitrate, country, votes, and tags), lets you pick several one at a time, adds +each to the playlist as a url track carrying its station metadata, and plays +the first pick (interrupting whatever was playing). Nothing is written to +disk; save the queue with the normal playlist save, where the station name +pre-fills the prompt." (when (string-empty-p (string-trim query)) (user-error "Empty search")) + (cj/emms--setup) (let* ((stations (cj/music-radio--search query field)) (candidates (cj/music-radio--candidates stations))) (unless candidates @@ -1593,31 +1651,30 @@ mpv (interrupting whatever was playing)." (let ((chosen (cj/music-radio--pick-loop candidates))) (unless chosen (user-error "No stations selected")) - (let* ((result (cj/music-radio--write-stations chosen cj/music-radio-save-dir)) - (written (plist-get result :written)) - (skipped (plist-get result :skipped))) - (when written - (cj/music-radio--play written)) - (message "Created %d station%s%s%s" - (length written) - (if (= (length written) 1) "" "s") + (let ((tracks (delq nil (mapcar #'cj/music-radio--station-track chosen))) + (skipped (cl-loop for st in chosen + unless (cj/music-radio--station-url st) + collect (or (plist-get st :name) "(unnamed)")))) + (cj/music-radio--enqueue-and-play tracks) + (message "Queued %d station%s%s%s" + (length tracks) + (if (= (length tracks) 1) "" "s") (if skipped (format ", skipped %d with no URL (%s)" (length skipped) (string-join skipped ", ")) "") - (if written + (if tracks (format " — playing %s" - (file-name-sans-extension - (file-name-nondirectory (car written)))) + (emms-track-get (car tracks) 'info-title)) "")))))) (defun cj/music-radio-search-by-name (query) - "Search radio-browser.info by station name, then create and play a selection." + "Search radio-browser.info by station name, then queue and play a selection." (interactive "sRadio search (name): ") (cj/music-radio--search-and-play query "name")) (defun cj/music-radio-search-by-tag (tag) - "Search radio-browser.info by tag/genre, then create and play a selection." + "Search radio-browser.info by tag/genre, then queue and play a selection." (interactive "sRadio search (tag): ") (cj/music-radio--search-and-play tag "tag")) @@ -1643,23 +1700,28 @@ file hash. Gitignored runtime state; `cj/music-clear-art-cache' empties it.") (defun cj/music-art--cache-key (track &optional entries) "Stable cache-file basename (no extension) for TRACK. -A url with a #RADIOBROWSERUUID in ENTRIES keys on the uuid so a station shares -one cached logo; any other url keys on a hash of its address; a file keys on a -hash of its path." +A url with a station uuid — the track's `radio-uuid' property, else a +#RADIOBROWSERUUID in ENTRIES — keys on the uuid so a station shares one cached +logo; any other url keys on a hash of its address; a file keys on a hash of +its path." (let ((name (emms-track-name track))) (if (eq (emms-track-type track) 'url) - (let ((uuid (plist-get (cdr (assoc name entries)) :uuid))) + (let ((uuid (or (emms-track-get track 'radio-uuid) + (plist-get (cdr (assoc name entries)) :uuid)))) (if (and (stringp uuid) (not (string-empty-p uuid))) uuid (concat "url-" (sha1 name)))) (concat "file-" (sha1 name))))) (defun cj/music-art--favicon-url (track &optional entries) - "Direct favicon image URL for a url TRACK from its captured -#RADIOBROWSERFAVICON, or nil. A station with only a uuid resolves via a byuuid -lookup elsewhere; a file track has no favicon URL." + "Direct favicon image URL for a url TRACK, or nil. +The track's `radio-favicon' property wins, then its captured +#RADIOBROWSERFAVICON from ENTRIES. A station with only a uuid resolves via a +byuuid lookup elsewhere; a file track has no favicon URL." (when (eq (emms-track-type track) 'url) - (let ((fav (plist-get (cdr (assoc (emms-track-name track) entries)) :favicon))) + (let ((fav (or (emms-track-get track 'radio-favicon) + (plist-get (cdr (assoc (emms-track-name track) entries)) + :favicon)))) (and (stringp fav) (not (string-empty-p fav)) fav)))) (defun cj/music-art--valid-image-p (data) @@ -1733,9 +1795,10 @@ when there is nothing to fetch." (unless (cj/music-art--cached-file key) (when (eq (emms-track-type track) 'url) (let ((fav (or (cj/music-art--favicon-url track entries) - (let ((uuid (plist-get (cdr (assoc (emms-track-name track) - entries)) - :uuid))) + (let ((uuid (or (emms-track-get track 'radio-uuid) + (plist-get (cdr (assoc (emms-track-name track) + entries)) + :uuid)))) (and (stringp uuid) (not (string-empty-p uuid)) (cj/music-art--byuuid-favicon uuid)))))) (and fav (cj/music-art--fetch-to-cache fav key))))))) 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 |
