diff options
| -rw-r--r-- | modules/music-config.el | 147 | ||||
| -rw-r--r-- | tests/test-music-config--bar-fill.el | 63 | ||||
| -rw-r--r-- | tests/test-music-config--display-name.el | 139 | ||||
| -rw-r--r-- | tests/test-music-config--m3u-labels.el | 62 | ||||
| -rw-r--r-- | tests/test-music-config--tidy-host.el | 47 | ||||
| -rw-r--r-- | tests/test-music-config--track-description.el | 181 |
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 |
