aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--modules/music-config.el147
-rw-r--r--tests/test-music-config--bar-fill.el63
-rw-r--r--tests/test-music-config--display-name.el139
-rw-r--r--tests/test-music-config--m3u-labels.el62
-rw-r--r--tests/test-music-config--tidy-host.el47
-rw-r--r--tests/test-music-config--track-description.el181
6 files changed, 435 insertions, 204 deletions
diff --git a/modules/music-config.el b/modules/music-config.el
index 22b8aa4f..109f496c 100644
--- a/modules/music-config.el
+++ b/modules/music-config.el
@@ -796,34 +796,125 @@ Dirs added recursively."
(when (and seconds (numberp seconds) (> seconds 0))
(format "%d:%02d" (/ seconds 60) (mod seconds 60))))
-(defun cj/music--track-description (track)
- "Return a human-readable description of TRACK.
-For tagged tracks: \"Artist - Title [M:SS]\".
-For file tracks without tags: filename without path or extension.
-For URL tracks: decoded URL."
+;; ---------------------------- Display-name layer -----------------------------
+;; A track maps to a display NAME (shared by the header's Current line and the
+;; playlist row renderer) plus, on a row, a dim type glyph and right-aligned
+;; meta. The pure pieces (name resolution, #EXTINF-label extraction, host
+;; tidying, progress-bar fill) carry the tests; the glyph, the :align-to meta,
+;; and the disk-backed name-map are exercised live.
+
+(defun cj/music--tidy-host (url)
+ "Return a readable host label for URL: scheme and path dropped, a leading
+\"www.\" removed, and a multi-label host reduced to its last two labels
+\(ice6.somafm.com -> somafm.com). A string with no scheme://host is returned
+unchanged, so a non-URL name shows as-is instead of erroring."
+ (if (string-match "\\`[a-zA-Z]+://\\(?:[^@/]*@\\)?\\([^:/?#]+\\)" url)
+ (let* ((host (replace-regexp-in-string "\\`www\\." "" (match-string 1 url)))
+ (labels (split-string host "\\." t)))
+ (if (> (length labels) 2)
+ (string-join (last labels 2) ".")
+ host))
+ url))
+
+(defun cj/music--m3u-labels (text)
+ "Parse M3U TEXT into an alist of (STREAM-URL . #EXTINF-LABEL).
+A url line takes the label from the most recent #EXTINF; a url with no
+preceding #EXTINF is skipped, and other comment lines (a #RADIOBROWSERUUID)
+between the two are ignored."
+ (let ((pending nil) (pairs '()))
+ (dolist (line (split-string text "[\r\n]+" t) (nreverse pairs))
+ (cond
+ ((string-match "\\`#EXTINF:[^,]*,\\(.*\\)\\'" line)
+ (setq pending (match-string 1 line)))
+ ((string-prefix-p "#" line)) ; other comment — keep PENDING
+ (t (when pending (push (cons line pending) pairs))
+ (setq pending nil))))))
+
+(defvar cj/music--radio-name-map-cache nil
+ "Cached url->#EXTINF-label alist across `cj/music-m3u-roots'.
+Built lazily so a row render never re-scans disk; cleared by
+`cj/music--refresh-radio-name-map' when a station is created or a playlist
+loads.")
+
+(defun cj/music--radio-name-map ()
+ "Alist of stream-url -> station label, unioned across all playlist roots.
+Reads each .m3u once and caches the result."
+ (or cj/music--radio-name-map-cache
+ (setq cj/music--radio-name-map-cache
+ (cl-loop for (_base . path) in (cj/music--get-m3u-files)
+ append (cj/music--m3u-labels
+ (with-temp-buffer
+ (insert-file-contents path)
+ (buffer-string)))))))
+
+(defun cj/music--refresh-radio-name-map ()
+ "Clear the cached radio name-map so the next render rebuilds it."
+ (setq cj/music--radio-name-map-cache nil))
+
+(defun cj/music--display-name (track &optional name-map)
+ "Human display name for TRACK, name only (no duration).
+A tagged track (file or url) shows \"Artist - Title\" or the bare title; an
+untagged file shows its filename; a url track resolves to its #EXTINF label
+from NAME-MAP (an alist of url->label), else a tidied host. Unknown types
+fall back to `emms-track-simple-description'."
(let ((type (emms-track-type track))
(title (emms-track-get track 'info-title))
(artist (emms-track-get track 'info-artist))
- (duration (emms-track-get track 'info-playing-time))
(name (emms-track-name track)))
(cond
- ;; Tagged track with title
- (title
- (let ((dur-str (cj/music--format-duration duration))
- (parts '()))
- (when artist (push artist parts))
- (push title parts)
- (let ((desc (string-join (nreverse parts) " - ")))
- (if dur-str (format "%s [%s]" desc dur-str) desc))))
- ;; File without tags — show clean filename
- ((eq type 'file)
- (file-name-sans-extension (file-name-nondirectory name)))
- ;; URL — decode percent-encoded characters
- ((eq type 'url)
- (decode-coding-string (url-unhex-string name) 'utf-8))
- ;; Fallback
+ (title (if artist (format "%s - %s" artist title) title))
+ ((eq type 'file) (file-name-sans-extension (file-name-nondirectory name)))
+ ((eq type 'url) (or (cdr (assoc name name-map)) (cj/music--tidy-host name)))
(t (emms-track-simple-description track)))))
+(defun cj/music--format-meta (track)
+ "Right-aligned meta string for TRACK's row: a file's duration as \"[M:SS]\",
+empty when there's no duration (a live stream, or an untimed file)."
+ (let ((dur (cj/music--format-duration (emms-track-get track 'info-playing-time))))
+ (if dur (format "[%s]" dur) "")))
+
+(defun cj/music--bar-fill (elapsed total width)
+ "Filled-cell count for a WIDTH-cell progress bar at ELAPSED/TOTAL seconds.
+Returns 0..WIDTH, or the symbol `indeterminate' when TOTAL is nil or
+non-positive (a live stream). A nil ELAPSED counts as zero."
+ (if (or (null total) (<= total 0))
+ 'indeterminate
+ (let ((ratio (min 1.0 (max 0.0 (/ (float (or elapsed 0)) total)))))
+ (round (* ratio width)))))
+
+(defun cj/music--type-glyph (track)
+ "A leading glyph for TRACK: a broadcast icon for a stream, a note for a file.
+Uses nerd-icons when available; otherwise a plain marker, so a TTY or a
+fontless frame never shows a tofu box."
+ (let ((stream (eq (emms-track-type track) 'url)))
+ (or (and (fboundp 'nerd-icons-mdicon)
+ (ignore-errors
+ (nerd-icons-mdicon (if stream "nf-md-broadcast" "nf-md-music_note")
+ :face 'cj/music-header-face)))
+ (if stream "»" "•"))))
+
+(defun cj/music--row-string (track)
+ "Playlist row for TRACK: a dim type glyph, the display name, and the meta
+right-aligned to the window edge with a resize-safe :align-to space.
+This is `emms-track-description-function'."
+ (let* ((name (cj/music--display-name track (cj/music--radio-name-map)))
+ (glyph (cj/music--type-glyph track))
+ (meta (cj/music--format-meta track)))
+ (if (string-empty-p meta)
+ (concat glyph " " name)
+ (concat glyph " " name
+ (propertize " " 'display
+ `(space :align-to (- right ,(1+ (length meta)))))
+ (propertize meta 'face 'cj/music-keyhint-face)))))
+
+(defun cj/music--now-playing-suffix (track)
+ "Trailing status for the header Current line: on-air for a stream, the
+duration for a timed file, empty otherwise."
+ (if (eq (emms-track-type track) 'url)
+ " ◉ on air"
+ (let ((d (cj/music--format-duration (emms-track-get track 'info-playing-time))))
+ (if d (format " %s" d) ""))))
+
;; Multi-line header overlay
(defvar-local cj/music--header-overlay nil
"Overlay displaying the playlist header.")
@@ -840,7 +931,9 @@ For URL tracks: decoded URL."
(emms-player-paused-p "Paused")
(t (let ((track (emms-playlist-current-selected-track)))
(if track
- (cj/music--track-description track)
+ (concat (cj/music--display-name
+ track (cj/music--radio-name-map))
+ (cj/music--now-playing-suffix track))
"Playing")))))
(mode-indicator
(lambda (key label active)
@@ -874,6 +967,11 @@ For URL tracks: decoded URL."
(propertize " : " 'face 'cj/music-header-face)
(propertize "n:by name t:by tag m:enter manually"
'face 'cj/music-keyhint-face)
+ "\n"
+ ;; A full-width rule under the header, resize-safe (redisplay recomputes
+ ;; the :align-to span rather than a hardcoded character count).
+ (propertize " " 'face '(:strike-through t :inherit shadow)
+ 'display '(space :align-to right))
"\n\n")))
(defun cj/music--update-header ()
@@ -949,7 +1047,7 @@ For URL tracks: decoded URL."
;;; Playlist display
;; Track description: show "Artist - Title [M:SS]" instead of file paths
- (setq emms-track-description-function #'cj/music--track-description)
+ (setq emms-track-description-function #'cj/music--row-string)
(add-hook 'emms-playlist-mode-hook #'cj/music--setup-playlist-display)
(add-hook 'emms-player-started-hook #'cj/music--record-random-history)
@@ -1028,6 +1126,7 @@ For URL tracks: decoded URL."
(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))))
;; The manual name+URL creator is bound to m in the radio row below (see the
@@ -1195,6 +1294,8 @@ NAMES). Creates DIR when absent."
(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)
diff --git a/tests/test-music-config--bar-fill.el b/tests/test-music-config--bar-fill.el
new file mode 100644
index 00000000..f6d8dac8
--- /dev/null
+++ b/tests/test-music-config--bar-fill.el
@@ -0,0 +1,63 @@
+;;; test-music-config--bar-fill.el --- Tests for progress-bar fill math -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--bar-fill': the pure computation of how many cells
+;; of a WIDTH-cell progress bar are filled given ELAPSED and TOTAL seconds.
+;; A stream (no duration) is indeterminate; the live elapsed source (mpv) is
+;; wired in a later phase, so this helper only does the clamp-and-round math.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+;;; Normal
+
+(ert-deftest test-music-config--bar-fill-normal-half ()
+ "Normal: halfway through fills half the cells."
+ (should (= (cj/music--bar-fill 30 60 20) 10)))
+
+(ert-deftest test-music-config--bar-fill-normal-quarter ()
+ "Normal: a quarter elapsed rounds to a quarter of the cells."
+ (should (= (cj/music--bar-fill 15 60 20) 5)))
+
+;;; Boundary
+
+(ert-deftest test-music-config--bar-fill-boundary-zero-elapsed ()
+ "Boundary: nothing elapsed fills no cells."
+ (should (= (cj/music--bar-fill 0 60 20) 0)))
+
+(ert-deftest test-music-config--bar-fill-boundary-full ()
+ "Boundary: elapsed equal to total fills every cell."
+ (should (= (cj/music--bar-fill 60 60 20) 20)))
+
+(ert-deftest test-music-config--bar-fill-boundary-over-clamps ()
+ "Boundary: elapsed past total clamps to full, never overflows."
+ (should (= (cj/music--bar-fill 90 60 20) 20)))
+
+(ert-deftest test-music-config--bar-fill-boundary-nil-elapsed ()
+ "Boundary: an unknown elapsed (nil) fills no cells."
+ (should (= (cj/music--bar-fill nil 60 20) 0)))
+
+;;; Error / indeterminate
+
+(ert-deftest test-music-config--bar-fill-indeterminate-nil-total ()
+ "Error: a stream (nil total) is indeterminate, not a cell count."
+ (should (eq (cj/music--bar-fill 30 nil 20) 'indeterminate)))
+
+(ert-deftest test-music-config--bar-fill-indeterminate-zero-total ()
+ "Error: a zero total is indeterminate rather than a divide-by-zero."
+ (should (eq (cj/music--bar-fill 30 0 20) 'indeterminate)))
+
+(provide 'test-music-config--bar-fill)
+;;; test-music-config--bar-fill.el ends here
diff --git a/tests/test-music-config--display-name.el b/tests/test-music-config--display-name.el
new file mode 100644
index 00000000..c1065f3a
--- /dev/null
+++ b/tests/test-music-config--display-name.el
@@ -0,0 +1,139 @@
+;;; test-music-config--display-name.el --- Tests for track display-name -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--display-name' and `cj/music--format-meta'.
+;;
+;; display-name is the pure, name-only resolver shared by the header's Current
+;; line and the playlist row renderer: for a tagged track it returns
+;; "Artist - Title" (no duration -- duration is right-aligned meta, not name);
+;; for an untagged file the clean filename; for a url track the #EXTINF label
+;; from a passed name-map, else a tidied host; unknown types fall back to
+;; emms-track-simple-description.
+;;
+;; format-meta returns the right-aligned meta string for a row: a file's
+;; duration as "[M:SS]", empty otherwise.
+;;
+;; Track independence: `emms-track' returns a track cached by (type . name)
+;; once EMMS is loaded, so two tests sharing a name would share a mutated
+;; object. Every test below uses a UNIQUE track name to stay independent.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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 'emms-playlist-mode)
+(require 'music-config)
+
+;;; Helpers
+
+(defun test-display-name--file (path &optional title artist duration)
+ "Create a file TRACK with PATH and optional TITLE ARTIST DURATION."
+ (let ((track (emms-track 'file path)))
+ (when title (emms-track-set track 'info-title title))
+ (when artist (emms-track-set track 'info-artist artist))
+ (when duration (emms-track-set track 'info-playing-time duration))
+ track))
+
+(defun test-display-name--url (url &optional title artist)
+ "Create a url TRACK with URL and optional TITLE ARTIST."
+ (let ((track (emms-track 'url url)))
+ (when title (emms-track-set track 'info-title title))
+ (when artist (emms-track-set track 'info-artist artist))
+ track))
+
+;;; Normal -- tagged tracks (no duration in the name)
+
+(ert-deftest test-music-config--display-name-normal-artist-title ()
+ "Normal: tagged track shows Artist - Title, no duration bracket."
+ (let ((track (test-display-name--file
+ "/dn/artist-title.flac" "So What" "Miles Davis" 562)))
+ (should (string= (cj/music--display-name track) "Miles Davis - So What"))))
+
+(ert-deftest test-music-config--display-name-normal-title-only ()
+ "Normal: title without artist shows the title alone."
+ (let ((track (test-display-name--file "/dn/title-only.mp3" "Flamenco Sketches" nil 566)))
+ (should (string= (cj/music--display-name track) "Flamenco Sketches"))))
+
+;;; Normal -- untagged file
+
+(ert-deftest test-music-config--display-name-normal-file-no-tags ()
+ "Normal: untagged file shows filename without path or extension."
+ (let ((track (test-display-name--file "/dn/Kind of Blue/02 - Freddie.flac")))
+ (should (string= (cj/music--display-name track) "02 - Freddie"))))
+
+;;; Normal -- url resolves to #EXTINF label from the name-map
+
+(ert-deftest test-music-config--display-name-normal-url-label-from-map ()
+ "Normal: a url track resolves to its #EXTINF label via the name-map."
+ (let ((track (test-display-name--url "https://ice6.somafm.com/groovesalad-256-mp3"))
+ (map '(("https://ice6.somafm.com/groovesalad-256-mp3" . "SomaFM Groove Salad"))))
+ (should (string= (cj/music--display-name track map) "SomaFM Groove Salad"))))
+
+;;; Normal -- url with tags formats like a tagged track
+
+(ert-deftest test-music-config--display-name-normal-url-with-tags ()
+ "Normal: a url track carrying tags uses Artist - Title, not the URL."
+ (let ((track (test-display-name--url "https://tagged.example.com/stream"
+ "Jazz FM" "Radio Station")))
+ (should (string= (cj/music--display-name track) "Radio Station - Jazz FM"))))
+
+;;; Boundary -- url with no label falls back to a tidied host
+
+(ert-deftest test-music-config--display-name-boundary-url-host-fallback ()
+ "Boundary: a url with no label and no map falls back to the tidied host."
+ (let ((track (test-display-name--url "https://ice6.hostonly.somafm.com/gs")))
+ (should (string= (cj/music--display-name track) "somafm.com"))))
+
+(ert-deftest test-music-config--display-name-boundary-url-not-in-map ()
+ "Boundary: a url absent from a non-empty map still falls to the host."
+ (let ((track (test-display-name--url "https://stream.other.net/live"))
+ (map '(("https://ice6.somafm.com/x" . "Groove Salad"))))
+ (should (string= (cj/music--display-name track map) "other.net"))))
+
+(ert-deftest test-music-config--display-name-boundary-unicode-title ()
+ "Boundary: unicode in the title is preserved."
+ (let ((track (test-display-name--file "/dn/unicode.mp3" "夜に駆ける" "YOASOBI" 258)))
+ (should (string= (cj/music--display-name track) "YOASOBI - 夜に駆ける"))))
+
+(ert-deftest test-music-config--display-name-boundary-file-multiple-dots ()
+ "Boundary: only the final extension is stripped."
+ (let ((track (test-display-name--file "/dn/disc.1.track.03.flac")))
+ (should (string= (cj/music--display-name track) "disc.1.track.03"))))
+
+;;; Error -- unknown type falls back without erroring
+
+(ert-deftest test-music-config--display-name-error-unknown-type ()
+ "Error: an unknown track type falls back to a string, no error."
+ (let* ((track (emms-track 'streamlist "https://unknown.example.com/playlist.m3u"))
+ (result (cj/music--display-name track)))
+ (should (stringp result))
+ (should (string-match-p "example\\.com" result))))
+
+;;; format-meta
+
+(ert-deftest test-music-config--format-meta-normal-file-duration ()
+ "Normal: a file with a duration yields the bracketed M:SS meta."
+ (let ((track (test-display-name--file "/dn/meta-dur.flac" "So What" "Miles" 562)))
+ (should (string= (cj/music--format-meta track) "[9:22]"))))
+
+(ert-deftest test-music-config--format-meta-boundary-no-duration ()
+ "Boundary: a track with no duration yields an empty meta string."
+ (let ((track (test-display-name--file "/dn/meta-nodur.flac" "So What" "Miles")))
+ (should (string= (cj/music--format-meta track) ""))))
+
+(ert-deftest test-music-config--format-meta-boundary-url-no-meta ()
+ "Boundary: a url stream with no duration yields empty meta."
+ (let ((track (test-display-name--url "https://meta.example.com/stream")))
+ (should (string= (cj/music--format-meta track) ""))))
+
+(provide 'test-music-config--display-name)
+;;; test-music-config--display-name.el ends here
diff --git a/tests/test-music-config--m3u-labels.el b/tests/test-music-config--m3u-labels.el
new file mode 100644
index 00000000..02f328cd
--- /dev/null
+++ b/tests/test-music-config--m3u-labels.el
@@ -0,0 +1,62 @@
+;;; test-music-config--m3u-labels.el --- Tests for #EXTINF label extraction -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--m3u-labels': parse .m3u text into an alist of
+;; (stream-url . #EXTINF-label) pairs. This is the pure core that lets a url
+;; track resolve to its station name (the label written at creation) instead
+;; of the raw stream URL.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+(ert-deftest test-music-config--m3u-labels-normal-single ()
+ "Normal: one #EXTINF + url pair yields one (url . label) cons."
+ (let ((text "#EXTM3U\n#EXTINF:1,SomaFM Groove Salad\nhttps://ice6.somafm.com/gs\n"))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://ice6.somafm.com/gs" . "SomaFM Groove Salad"))))))
+
+(ert-deftest test-music-config--m3u-labels-normal-uuid-line-between ()
+ "Normal: a #RADIOBROWSERUUID line between #EXTINF and the url is skipped."
+ (let ((text (concat "#EXTM3U\n#EXTINF:1,Jazz24\n"
+ "#RADIOBROWSERUUID:abc-123\nhttps://jazz.example/live\n")))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://jazz.example/live" . "Jazz24"))))))
+
+(ert-deftest test-music-config--m3u-labels-normal-multiple ()
+ "Normal: multiple stations each yield their own pair."
+ (let ((text (concat "#EXTM3U\n"
+ "#EXTINF:1,Station A\nhttps://a.example/1\n"
+ "#EXTINF:-1,Station B\nhttps://b.example/2\n")))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://a.example/1" . "Station A")
+ ("https://b.example/2" . "Station B"))))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-url-without-extinf ()
+ "Boundary: a bare url with no preceding #EXTINF produces no pair."
+ (let ((text "#EXTM3U\nhttps://plain.example/stream\n"))
+ (should (null (cj/music--m3u-labels text)))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-empty ()
+ "Boundary: empty text yields nil."
+ (should (null (cj/music--m3u-labels ""))))
+
+(ert-deftest test-music-config--m3u-labels-boundary-comma-in-name ()
+ "Boundary: a comma inside the label is preserved (split on the first only)."
+ (let ((text "#EXTINF:1,Radio, the Good Kind\nhttps://x.example/s\n"))
+ (should (equal (cj/music--m3u-labels text)
+ '(("https://x.example/s" . "Radio, the Good Kind"))))))
+
+(provide 'test-music-config--m3u-labels)
+;;; test-music-config--m3u-labels.el ends here
diff --git a/tests/test-music-config--tidy-host.el b/tests/test-music-config--tidy-host.el
new file mode 100644
index 00000000..92b104a6
--- /dev/null
+++ b/tests/test-music-config--tidy-host.el
@@ -0,0 +1,47 @@
+;;; test-music-config--tidy-host.el --- Tests for stream-URL host tidying -*- coding: utf-8; lexical-binding: t; -*-
+;;
+;; Author: Craig Jennings <c@cjennings.net>
+;;
+;;; Commentary:
+;; Unit tests for `cj/music--tidy-host': reduce a stream URL to a readable host
+;; label (scheme dropped, a leading "www." removed), used as the last-resort
+;; display name for a url track with no #EXTINF label.
+;;
+;;; Code:
+
+(require 'ert)
+
+(defvar-keymap cj/custom-keymap :doc "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)
+
+(ert-deftest test-music-config--tidy-host-normal ()
+ "Normal: scheme and path are dropped, host kept."
+ (should (string= (cj/music--tidy-host "https://ice6.somafm.com/groovesalad-256-mp3")
+ "somafm.com")))
+
+(ert-deftest test-music-config--tidy-host-normal-http ()
+ "Normal: plain http host with a port keeps the host, drops the port."
+ (should (string= (cj/music--tidy-host "http://stream.example.org:8000/live")
+ "example.org")))
+
+(ert-deftest test-music-config--tidy-host-boundary-strip-www ()
+ "Boundary: a leading www. is stripped."
+ (should (string= (cj/music--tidy-host "https://www.radioparadise.com/m3u/mp3-128.m3u")
+ "radioparadise.com")))
+
+(ert-deftest test-music-config--tidy-host-boundary-bare-domain ()
+ "Boundary: a two-label domain is returned unchanged."
+ (should (string= (cj/music--tidy-host "http://somafm.com/") "somafm.com")))
+
+(ert-deftest test-music-config--tidy-host-error-not-a-url ()
+ "Error: a non-URL string is returned as-is rather than erroring."
+ (should (string= (cj/music--tidy-host "not a url") "not a url")))
+
+(provide 'test-music-config--tidy-host)
+;;; test-music-config--tidy-host.el ends here
diff --git a/tests/test-music-config--track-description.el b/tests/test-music-config--track-description.el
deleted file mode 100644
index a1a1cc6d..00000000
--- a/tests/test-music-config--track-description.el
+++ /dev/null
@@ -1,181 +0,0 @@
-;;; test-music-config--track-description.el --- Tests for track description -*- coding: utf-8; lexical-binding: t; -*-
-;;
-;; Author: Craig Jennings <c@cjennings.net>
-;;
-;;; Commentary:
-;; Unit tests for cj/music--track-description function.
-;; Tests the custom track description that replaces EMMS's default file-path display
-;; with human-readable formats based on track type and available metadata.
-;;
-;; Track construction: EMMS tracks are alists created with `emms-track' and
-;; populated with `emms-track-set'. No playlist buffer or player state needed.
-;;
-;; Test organization:
-;; - Normal Cases: Tagged tracks (artist+title+duration), partial metadata, file fallback, URL
-;; - Boundary Cases: Empty strings, missing fields, special characters, long names
-;; - Error Cases: Unknown track type fallback
-;;
-;;; Code:
-
-(require 'ert)
-
-;; Stub missing dependencies before loading music-config
-(defvar-keymap cj/custom-keymap
- :doc "Stub keymap for testing")
-
-;; Add EMMS elpa directory to load path for batch testing
-(let ((emms-dir (car (file-expand-wildcards
- (expand-file-name "elpa/emms-*" user-emacs-directory)))))
- (when emms-dir
- (add-to-list 'load-path emms-dir)))
-
-(require 'emms)
-(require 'emms-playlist-mode)
-(require 'music-config)
-
-;;; Test helpers
-
-(defun test-track-description--make-file-track (path &optional title artist duration)
- "Create a file TRACK with PATH and optional metadata TITLE, ARTIST, DURATION."
- (let ((track (emms-track 'file path)))
- (when title (emms-track-set track 'info-title title))
- (when artist (emms-track-set track 'info-artist artist))
- (when duration (emms-track-set track 'info-playing-time duration))
- track))
-
-(defun test-track-description--make-url-track (url &optional title artist duration)
- "Create a URL TRACK with URL and optional metadata TITLE, ARTIST, DURATION."
- (let ((track (emms-track 'url url)))
- (when title (emms-track-set track 'info-title title))
- (when artist (emms-track-set track 'info-artist artist))
- (when duration (emms-track-set track 'info-playing-time duration))
- track))
-
-;;; Normal Cases — Tagged tracks (artist + title + duration)
-
-(ert-deftest test-music-config--track-description-normal-full-metadata ()
- "Validate track with artist, title, and duration shows all three."
- (let ((track (test-track-description--make-file-track
- "/music/Kind of Blue/01 - So What.flac"
- "So What" "Miles Davis" 562)))
- (should (string= (cj/music--track-description track)
- "Miles Davis - So What [9:22]"))))
-
-(ert-deftest test-music-config--track-description-normal-title-and-artist-no-duration ()
- "Validate track with artist and title but no duration omits bracket."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-nodur.mp3" "Blue in Green" "Miles Davis")))
- (should (string= (cj/music--track-description track)
- "Miles Davis - Blue in Green"))))
-
-(ert-deftest test-music-config--track-description-normal-title-only ()
- "Validate track with title but no artist shows title alone."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-noartist.mp3" "Flamenco Sketches" nil 566)))
- (should (string= (cj/music--track-description track)
- "Flamenco Sketches [9:26]"))))
-
-(ert-deftest test-music-config--track-description-normal-title-only-no-duration ()
- "Validate track with only title shows just the title."
- (let ((track (test-track-description--make-file-track
- "/test/uncached-titleonly.mp3" "All Blues")))
- (should (string= (cj/music--track-description track)
- "All Blues"))))
-
-;;; Normal Cases — File tracks without tags
-
-(ert-deftest test-music-config--track-description-normal-file-no-tags ()
- "Validate untagged file shows filename without path or extension."
- (let ((track (test-track-description--make-file-track
- "/music/Kind of Blue/02 - Freddie Freeloader.flac")))
- (should (string= (cj/music--track-description track)
- "02 - Freddie Freeloader"))))
-
-(ert-deftest test-music-config--track-description-normal-file-nested-path ()
- "Validate deeply nested path still shows only the filename."
- (let ((track (test-track-description--make-file-track
- "/music/Jazz/Miles Davis/Kind of Blue/01 - So What.mp3")))
- (should (string= (cj/music--track-description track)
- "01 - So What"))))
-
-;;; Normal Cases — URL tracks
-
-(ert-deftest test-music-config--track-description-normal-url-plain ()
- "Validate plain URL is shown as-is."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/stream")))
- (should (string= (cj/music--track-description track)
- "https://radio.example.com/stream"))))
-
-(ert-deftest test-music-config--track-description-normal-url-percent-encoded ()
- "Validate percent-encoded URL characters are decoded."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/my%20station%21")))
- (should (string= (cj/music--track-description track)
- "https://radio.example.com/my station!"))))
-
-(ert-deftest test-music-config--track-description-normal-url-with-tags ()
- "Validate URL track with tags uses tag display, not URL."
- (let ((track (test-track-description--make-url-track
- "https://radio.example.com/stream"
- "Jazz FM" "Radio Station" 0)))
- ;; Duration 0 → nil from format-duration, so no bracket
- (should (string= (cj/music--track-description track)
- "Radio Station - Jazz FM"))))
-
-;;; Boundary Cases
-
-(ert-deftest test-music-config--track-description-boundary-empty-title-string ()
- "Validate empty title string is still truthy, shows empty result."
- (let ((track (test-track-description--make-file-track
- "/music/track.mp3" "" "Artist")))
- ;; Empty string is non-nil, so title branch is taken
- (should (string= (cj/music--track-description track)
- "Artist - "))))
-
-(ert-deftest test-music-config--track-description-boundary-file-no-extension ()
- "Validate file without extension shows full filename."
- (let ((track (test-track-description--make-file-track "/music/README")))
- (should (string= (cj/music--track-description track)
- "README"))))
-
-(ert-deftest test-music-config--track-description-boundary-file-multiple-dots ()
- "Validate file with multiple dots strips only the final extension."
- (let ((track (test-track-description--make-file-track
- "/music/disc.1.track.03.flac")))
- (should (string= (cj/music--track-description track)
- "disc.1.track.03"))))
-
-(ert-deftest test-music-config--track-description-boundary-unicode-title ()
- "Validate unicode characters in metadata are preserved."
- (let ((track (test-track-description--make-file-track
- "/music/track.mp3" "夜に駆ける" "YOASOBI" 258)))
- (should (string= (cj/music--track-description track)
- "YOASOBI - 夜に駆ける [4:18]"))))
-
-(ert-deftest test-music-config--track-description-boundary-url-utf8-percent-encoded ()
- "Validate percent-encoded UTF-8 in URL is decoded correctly."
- (let ((track (test-track-description--make-url-track
- "https://example.com/caf%C3%A9")))
- (should (string= (cj/music--track-description track)
- "https://example.com/café"))))
-
-(ert-deftest test-music-config--track-description-boundary-short-duration ()
- "Validate 1-second track formats correctly in bracket."
- (let ((track (test-track-description--make-file-track
- "/music/t.mp3" "Beep" nil 1)))
- (should (string= (cj/music--track-description track)
- "Beep [0:01]"))))
-
-;;; Error Cases
-
-(ert-deftest test-music-config--track-description-error-unknown-type-fallback ()
- "Validate unknown track type uses emms-track-simple-description fallback."
- (let ((track (emms-track 'streamlist "https://example.com/playlist.m3u")))
- ;; Should not error; falls through to simple-description
- (let ((result (cj/music--track-description track)))
- (should (stringp result))
- (should (string-match-p "example\\.com" result)))))
-
-(provide 'test-music-config--track-description)
-;;; test-music-config--track-description.el ends here