diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/test-music-config--append-track-to-m3u-file.el | 187 | ||||
| -rw-r--r-- | tests/test-music-config--collect-entries-recursive.el | 245 | ||||
| -rw-r--r-- | tests/test-music-config--completion-table.el | 134 | ||||
| -rw-r--r-- | tests/test-music-config--get-m3u-basenames.el | 121 | ||||
| -rw-r--r-- | tests/test-music-config--get-m3u-files.el | 150 | ||||
| -rw-r--r-- | tests/test-music-config--m3u-file-tracks.el | 193 | ||||
| -rw-r--r-- | tests/test-music-config--safe-filename.el | 97 | ||||
| -rw-r--r-- | tests/test-music-config--valid-directory-p.el | 139 | ||||
| -rw-r--r-- | tests/test-music-config--valid-file-p.el | 99 | ||||
| -rw-r--r-- | tests/test-org-contacts-capture-finalize.el | 217 |
10 files changed, 1582 insertions, 0 deletions
diff --git a/tests/test-music-config--append-track-to-m3u-file.el b/tests/test-music-config--append-track-to-m3u-file.el new file mode 100644 index 00000000..2bf3e87d --- /dev/null +++ b/tests/test-music-config--append-track-to-m3u-file.el @@ -0,0 +1,187 @@ +;;; test-music-config--append-track-to-m3u-file.el --- Tests for appending tracks to M3U files -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--append-track-to-m3u-file function. +;; Tests the pure, deterministic helper that appends track paths to M3U files. +;; +;; Test organization: +;; - Normal Cases: Standard append operations +;; - Boundary Cases: Edge conditions (unicode, long paths, special chars) +;; - Error Cases: File errors (missing, read-only, directory instead of file) +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--append-track-to-m3u-file-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--append-track-to-m3u-file-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-empty-file-appends-track () + "Append to brand new empty M3U file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/artist/song.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-with-newline-appends-track () + "Append to file with existing content ending with newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "/home/user/music/first.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-without-newline-appends-track () + "Append to file without trailing newline adds leading newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "/home/user/music/first.mp3") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content "\n" track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-multiple-appends-all-succeed () + "Multiple appends to same file all succeed (allows duplicates)." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track1 "/home/user/music/track1.mp3") + (track2 "/home/user/music/track2.mp3") + (track1-duplicate "/home/user/music/track1.mp3")) + (cj/music--append-track-to-m3u-file track1 m3u-file) + (cj/music--append-track-to-m3u-file track2 m3u-file) + (cj/music--append-track-to-m3u-file track1-duplicate m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (let ((content (buffer-string))) + (should (string= content + (concat track1 "\n" track2 "\n" track1-duplicate "\n")))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-very-long-path-appends-successfully () + "Append very long track path without truncation." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + ;; Create a path that's ~500 chars long + (track-path (concat "/home/user/music/" + (make-string 450 ?a) + "/song.mp3"))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))) + (should (= (length (buffer-string)) (1+ (length track-path)))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-unicode-appends-successfully () + "Append path with unicode characters preserves UTF-8 encoding." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/中文/artist-名前/song🎵.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-spaces-appends-successfully () + "Append path with spaces and special characters." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/Artist Name/Album (2024)/01 - Song's Title [Remix].mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-m3u-with-comments-appends-after () + "Append to M3U file containing comments and metadata." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "#EXTM3U\n#EXTINF:-1,Radio Station\nhttp://stream.url/radio\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/local-track.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-error-nonexistent-file-signals-error () + "Signal error when M3U file doesn't exist." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file "/nonexistent/path/to/playlist.m3u") + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-readonly-file-signals-error () + "Signal error when M3U file is read-only." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/song.mp3")) + ;; Make file read-only + (set-file-modes m3u-file #o444) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-directory-not-file-signals-error () + "Signal error when path points to directory instead of file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-dir (cj/create-test-subdirectory "test-playlist-dir")) + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-dir) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(provide 'test-music-config--append-track-to-m3u-file) +;;; test-music-config--append-track-to-m3u-file.el ends here diff --git a/tests/test-music-config--collect-entries-recursive.el b/tests/test-music-config--collect-entries-recursive.el new file mode 100644 index 00000000..d71ceab6 --- /dev/null +++ b/tests/test-music-config--collect-entries-recursive.el @@ -0,0 +1,245 @@ +;;; test-music-config--collect-entries-recursive.el --- Tests for recursive music collection -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--collect-entries-recursive function. +;; Tests the recursive helper that collects music files and directories. +;; +;; Test organization: +;; - Normal Cases: Single level, nested directories, mixed files +;; - Boundary Cases: Hidden files/dirs, non-music files, empty dirs, sorting +;; - Error Cases: Empty root, nonexistent root +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--collect-entries-recursive-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--collect-entries-recursive-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--collect-entries-recursive-normal-single-level-files-and-dirs () + "Collect music files and subdirectories at single level." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files at root + (cj/create-directory-or-file-ensuring-parents "music/song1.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/song2.flac" "") + ;; Create subdirectories + (cj/create-directory-or-file-ensuring-parents "music/artist1/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist2/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist1/" result)) + (should (member "artist2/" result)) + (should (member "song1.mp3" result)) + (should (member "song2.flac" result)) + (should (= (length result) 4)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-nested-directories () + "Collect nested directories multiple levels deep." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create nested structure + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/disc1/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist/" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/disc1/" result)) + (should (= (length result) 3)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-mixed-files-at-multiple-levels () + "Collect music files at root, subdirs, and nested subdirs." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Root level file + (cj/create-directory-or-file-ensuring-parents "music/root-track.mp3" "") + ;; Subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/track1.mp3" "") + ;; Nested subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/track2.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "root-track.mp3" result)) + (should (member "artist/" result)) + (should (member "artist/track1.mp3" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/track2.mp3" result)) + (should (= (length result) 5)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-directories-skipped () + "Hidden directories and their contents are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden directory with music file + (cj/create-directory-or-file-ensuring-parents "music/.hidden/" "") + (cj/create-directory-or-file-ensuring-parents "music/.hidden/secret.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden/" result)) + (should-not (member ".hidden/secret.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-files-skipped () + "Hidden files at root are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden file (note: directory-files regex "^[^.].*" should skip it) + (cj/create-directory-or-file-ensuring-parents "music/.hidden-track.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden-track.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-non-music-files-excluded () + "Non-music files are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Music file + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + ;; Non-music files + (cj/create-directory-or-file-ensuring-parents "music/readme.txt" "") + (cj/create-directory-or-file-ensuring-parents "music/cover.jpg" "") + (cj/create-directory-or-file-ensuring-parents "music/info.pdf" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "song.mp3" result)) + (should-not (member "readme.txt" result)) + (should-not (member "cover.jpg" result)) + (should-not (member "info.pdf" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-empty-directories-included () + "Empty subdirectories are still listed with trailing slash." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Empty subdirectories + (cj/create-directory-or-file-ensuring-parents "music/empty-artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/another-empty/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "empty-artist/" result)) + (should (member "another-empty/" result)) + (should (= (length result) 2)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-sorted-output () + "Output is sorted alphabetically (case-insensitive)." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files in non-alphabetical order + (cj/create-directory-or-file-ensuring-parents "music/zebra.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/Alpha.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/beta.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Should be sorted alphabetically (case-insensitive) + (should (equal result '("Alpha.mp3" "beta.mp3" "zebra.mp3"))))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-directories-have-trailing-slash () + "Directories have trailing slash, files don't." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Directory has trailing slash + (should (cl-some (lambda (entry) (string-suffix-p "/" entry)) result)) + ;; File doesn't have trailing slash + (should (cl-some (lambda (entry) (not (string-suffix-p "/" entry))) result)) + ;; Specifically check + (should (member "artist/" result)) + (should (member "song.mp3" result)) + (should-not (member "song.mp3/" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-all-music-extensions () + "All configured music extensions are collected." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create file for each extension: aac, flac, m4a, mp3, ogg, opus, wav + (cj/create-directory-or-file-ensuring-parents "music/track.aac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.flac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.m4a" "") + (cj/create-directory-or-file-ensuring-parents "music/track.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/track.ogg" "") + (cj/create-directory-or-file-ensuring-parents "music/track.opus" "") + (cj/create-directory-or-file-ensuring-parents "music/track.wav" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (= (length result) 7)) + (should (member "track.aac" result)) + (should (member "track.flac" result)) + (should (member "track.m4a" result)) + (should (member "track.mp3" result)) + (should (member "track.ogg" result)) + (should (member "track.opus" result)) + (should (member "track.wav" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--collect-entries-recursive-error-empty-root-returns-empty () + "Empty root directory returns empty list." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "empty-music"))) + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (null result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-error-nonexistent-root-returns-empty () + "Nonexistent directory returns empty list." + (let ((result (cj/music--collect-entries-recursive "/nonexistent/path/to/music"))) + (should (null result)))) + +(provide 'test-music-config--collect-entries-recursive) +;;; test-music-config--collect-entries-recursive.el ends here diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el new file mode 100644 index 00000000..5be0479d --- /dev/null +++ b/tests/test-music-config--completion-table.el @@ -0,0 +1,134 @@ +;;; test-music-config--completion-table.el --- Tests for completion table generation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--completion-table function. +;; Tests the completion table generator that creates custom completion tables. +;; +;; Test organization: +;; - Normal Cases: Metadata, completions, case-insensitive matching +;; - Boundary Cases: Empty candidates, partial matching, exact matches +;; - Error Cases: Nil candidates +;; +;;; 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--completion-table-normal-metadata-action-returns-metadata () + "Completion table returns metadata when action is 'metadata." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil 'metadata))) + (should (eq (car result) 'metadata)) + ;; Check metadata contains expected properties + (should (equal (alist-get 'display-sort-function (cdr result)) 'identity)) + (should (equal (alist-get 'cycle-sort-function (cdr result)) 'identity)) + (should (eq (alist-get 'completion-ignore-case (cdr result)) t)))) + +(ert-deftest test-music-config--completion-table-normal-t-action-returns-all-completions () + "Completion table returns all matching completions when action is t." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil t))) + ;; Empty string should match all candidates + (should (equal (sort result #'string<) '("Classical" "Jazz" "Rock"))))) + +(ert-deftest test-music-config--completion-table-normal-nil-action-tries-completion () + "Completion table tries completion when action is nil." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Roc" nil nil))) + ;; Should return completion attempt for "Roc" -> "Rock" + (should (stringp result)) + (should (string-prefix-p "Roc" result)))) + +(ert-deftest test-music-config--completion-table-normal-case-insensitive-metadata () + "Completion table metadata indicates case-insensitive completion." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (metadata (funcall table "" nil 'metadata))) + ;; Metadata should indicate case-insensitive + (should (eq (alist-get 'completion-ignore-case (cdr metadata)) t)))) + +;;; Boundary Cases + +(ert-deftest test-music-config--completion-table-boundary-empty-candidates () + "Completion table with empty candidate list returns no completions." + (let* ((candidates '()) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-single-candidate () + "Completion table with single candidate returns it on match." + (let* ((candidates '("OnlyOne")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Only" nil t))) + (should (equal result '("OnlyOne"))))) + +(ert-deftest test-music-config--completion-table-boundary-partial-matching () + "Completion table matches multiple candidates with common prefix." + (let* ((candidates '("playlist1" "playlist2" "jazz")) + (table (cj/music--completion-table candidates)) + (result (funcall table "play" nil t))) + (should (= (length result) 2)) + (should (member "playlist1" result)) + (should (member "playlist2" result)) + (should-not (member "jazz" result)))) + +(ert-deftest test-music-config--completion-table-boundary-no-matches () + "Completion table returns empty when no candidates match." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Metal" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-exact-match () + "Completion table returns t for exact match with nil action." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Jazz" nil nil))) + ;; Exact match with nil action returns t + (should (eq result t)))) + +(ert-deftest test-music-config--completion-table-boundary-mixed-case-candidates () + "Completion table with mixed-case duplicate candidates." + (let* ((candidates '("Rock" "ROCK" "rock")) + (table (cj/music--completion-table candidates)) + (result (funcall table "R" nil t))) + ;; All start with "R", but exact case matters for complete-with-action + ;; Only exact case match "R" prefix + (should (member "Rock" result)) + (should (member "ROCK" result)) + ;; "rock" doesn't match "R" prefix (lowercase) + (should-not (member "rock" result)))) + +(ert-deftest test-music-config--completion-table-boundary-unicode-candidates () + "Completion table handles unicode characters in candidates." + (let* ((candidates '("中文" "日本語" "한국어")) + (table (cj/music--completion-table candidates)) + (result (funcall table "中" nil t))) + (should (member "中文" result)))) + +;;; Error Cases + +(ert-deftest test-music-config--completion-table-error-nil-candidates-handles-gracefully () + "Completion table with nil candidates handles gracefully." + (let* ((candidates nil) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + ;; Should not crash, returns empty + (should (null result)))) + +(provide 'test-music-config--completion-table) +;;; test-music-config--completion-table.el ends here diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el new file mode 100644 index 00000000..91c8af70 --- /dev/null +++ b/tests/test-music-config--get-m3u-basenames.el @@ -0,0 +1,121 @@ +;;; test-music-config--get-m3u-basenames.el --- Tests for M3U basename extraction -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-basenames function. +;; Tests the helper that extracts M3U basenames (without .m3u extension). +;; +;; Test organization: +;; - Normal Cases: Multiple files, single file +;; - Boundary Cases: Empty directory, extension removal +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-basenames-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-basenames-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-basenames-normal-multiple-files-returns-basenames () + "Extract basenames from multiple M3U files without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "rock.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "jazz.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "classical.m3u"))) + (rename-file file1 (expand-file-name "rock.m3u" test-dir)) + (rename-file file2 (expand-file-name "jazz.m3u" test-dir)) + (rename-file file3 (expand-file-name "classical.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 3)) + ;; Sort for consistent comparison + (let ((sorted-result (sort result #'string<))) + (should (equal sorted-result '("classical" "jazz" "rock"))))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-normal-single-file-returns-basename () + "Extract basename from single M3U file without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "favorites.m3u"))) + (rename-file file1 (expand-file-name "favorites.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 1)) + (should (equal (car result) "favorites"))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-basenames-boundary-empty-directory-returns-empty () + "Extract basenames from empty directory returns empty list." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (null result))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-extension-removed () + "Basenames have .m3u extension removed." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "playlist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("playlist"))) + ;; Verify no .m3u extension present + (should-not (string-match-p "\\.m3u" (car result)))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-spaces-in-filename-preserved () + "Basenames with spaces preserve the spaces." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("My Favorite Songs")))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-basenames) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-basenames) +;;; test-music-config--get-m3u-basenames.el ends here diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el new file mode 100644 index 00000000..2d31d554 --- /dev/null +++ b/tests/test-music-config--get-m3u-files.el @@ -0,0 +1,150 @@ +;;; test-music-config--get-m3u-files.el --- Tests for M3U file discovery -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-files function. +;; Tests the helper that discovers M3U files in the music directory. +;; +;; Test organization: +;; - Normal Cases: Multiple M3U files, single file +;; - Boundary Cases: Empty directory, non-M3U files, various filenames +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-files-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-files-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-files-normal-multiple-files-returns-list () + "Discover multiple M3U files returns list of (basename . fullpath) conses." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "playlist1.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "playlist2.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "playlist3.m3u"))) + ;; Move files to test-dir + (rename-file file1 (expand-file-name "playlist1.m3u" test-dir)) + (rename-file file2 (expand-file-name "playlist2.m3u" test-dir)) + (rename-file file3 (expand-file-name "playlist3.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 3)) + ;; Check structure: list of (basename . fullpath) conses + ;; Sort for consistent comparison (directory-files order is filesystem-dependent) + (let ((basenames (sort (mapcar #'car result) #'string<)) + (fullpaths (sort (mapcar #'cdr result) #'string<))) + (should (equal basenames '("playlist1.m3u" "playlist2.m3u" "playlist3.m3u"))) + (should (equal fullpaths + (list (expand-file-name "playlist1.m3u" test-dir) + (expand-file-name "playlist2.m3u" test-dir) + (expand-file-name "playlist3.m3u" test-dir)))))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-normal-single-file-returns-list () + "Discover single M3U file returns single-item list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u"))) + (rename-file file1 (expand-file-name "myplaylist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "myplaylist.m3u")) + (should (equal (cdar result) (expand-file-name "myplaylist.m3u" test-dir)))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-files-boundary-empty-directory-returns-empty () + "Discover M3U files in empty directory returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-non-m3u-files-ignored () + "Directory with non-M3U files returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed-files")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3")) + (json-file (cj/create-temp-test-file-with-content "" "data.json"))) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + (rename-file json-file (expand-file-name "data.json" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-m3u-with-spaces-included () + "M3U files with spaces in name are discovered." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "My Favorite Songs.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-mixed-m3u-and-other-files () + "Directory with both M3U and non-M3U files returns only M3U files." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed")) + (m3u-file (cj/create-temp-test-file-with-content "" "playlist.m3u")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3"))) + (rename-file m3u-file (expand-file-name "playlist.m3u" test-dir)) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "playlist.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-files) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-files) +;;; test-music-config--get-m3u-files.el ends here diff --git a/tests/test-music-config--m3u-file-tracks.el b/tests/test-music-config--m3u-file-tracks.el new file mode 100644 index 00000000..badc9817 --- /dev/null +++ b/tests/test-music-config--m3u-file-tracks.el @@ -0,0 +1,193 @@ +;;; test-music-config--m3u-file-tracks.el --- Tests for M3U file parsing -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--m3u-file-tracks function. +;; Tests the M3U parser that extracts track paths from playlist files. +;; +;; Test organization: +;; - Normal Cases: Absolute paths, relative paths, URLs (http/https/mms) +;; - Boundary Cases: Empty lines, whitespace, comments, order preservation +;; - Error Cases: Nonexistent files, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--m3u-file-tracks-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--m3u-file-tracks-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--m3u-file-tracks-normal-absolute-paths-returns-list () + "Parse M3U with absolute paths returns list in order." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n/home/user/music/track2.mp3\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-relative-paths-expanded () + "Parse M3U with relative paths expands them relative to M3U directory." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "artist/track1.mp3\nartist/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list (expand-file-name "artist/track1.mp3" m3u-dir) + (expand-file-name "artist/track2.mp3" m3u-dir))))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-http-urls-preserved () + "Parse M3U with http:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "http://example.com/stream1.mp3\nhttp://example.com/stream2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("http://example.com/stream1.mp3" + "http://example.com/stream2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-https-urls-preserved () + "Parse M3U with https:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "https://secure.example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("https://secure.example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mms-urls-preserved () + "Parse M3U with mms:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "mms://radio.example.com/stream\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("mms://radio.example.com/stream")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mixed-paths-and-urls () + "Parse M3U with mix of absolute, relative, and URLs handles all correctly." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/local.mp3\nartist/relative.mp3\nhttp://example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list "/home/user/music/local.mp3" + (expand-file-name "artist/relative.mp3" m3u-dir) + "http://example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-lines-ignored () + "Parse M3U with empty lines ignores them and returns tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n\n/home/user/music/track2.mp3\n\n\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-whitespace-only-lines-ignored () + "Parse M3U with whitespace-only lines ignores them." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n \n\t\t\n/home/user/music/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-comments-ignored () + "Parse M3U with comment lines ignores them, returns only tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Track Title\n/home/user/music/track.mp3\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-leading-trailing-whitespace-trimmed () + "Parse M3U with whitespace around paths trims it." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content " /home/user/music/track1.mp3 \n\t/home/user/music/track2.mp3\t\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-file-returns-nil () + "Parse empty M3U file returns nil." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-only-comments-returns-empty () + "Parse M3U with only comments returns empty list." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Title\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-preserves-order () + "Parse M3U preserves track order (tests nreverse)." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/track1.mp3\n/track2.mp3\n/track3.mp3\n/track4.mp3\n/track5.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/track1.mp3" "/track2.mp3" "/track3.mp3" "/track4.mp3" "/track5.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--m3u-file-tracks-error-nonexistent-file-returns-nil () + "Parse nonexistent file returns nil." + (should (null (cj/music--m3u-file-tracks "/nonexistent/path/playlist.m3u")))) + +(ert-deftest test-music-config--m3u-file-tracks-error-nil-input-returns-nil () + "Parse nil input returns nil gracefully." + (should (null (cj/music--m3u-file-tracks nil)))) + +(provide 'test-music-config--m3u-file-tracks) +;;; test-music-config--m3u-file-tracks.el ends here diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el new file mode 100644 index 00000000..8105ee15 --- /dev/null +++ b/tests/test-music-config--safe-filename.el @@ -0,0 +1,97 @@ +;;; 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--valid-directory-p.el b/tests/test-music-config--valid-directory-p.el new file mode 100644 index 00000000..21c2b240 --- /dev/null +++ b/tests/test-music-config--valid-directory-p.el @@ -0,0 +1,139 @@ +;;; test-music-config--valid-directory-p.el --- Tests for directory validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-directory-p function. +;; Tests the pure helper that validates non-hidden directories. +;; +;; Test organization: +;; - Normal Cases: Valid visible directories +;; - Boundary Cases: Trailing slashes, dots in names, hidden directories +;; - Error Cases: Files (not dirs), nonexistent paths, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--valid-directory-p-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--valid-directory-p-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-directory-p-normal-visible-directory-returns-true () + "Validate visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-normal-nested-directory-returns-true () + "Validate nested visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir/nested"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-directory-p-boundary-trailing-slash-returns-true () + "Validate directory with trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (file-name-as-directory test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-no-trailing-slash-returns-true () + "Validate directory without trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (directory-file-name test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-dot-in-middle-returns-true () + "Validate directory with dot in middle of name returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "my.music.dir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-directory-returns-nil () + "Validate hidden directory (starting with dot) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory ".hidden"))) + (should-not (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-current-dir-dot-returns-nil () + "Validate current directory '.' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + ;; Change to test dir and check "." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p ".")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-parent-dir-dotdot-returns-nil () + "Validate parent directory '..' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir"))) + ;; Change to subdir and check ".." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p "..")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-subdir-basename-check () + "Validate hidden subdirectory returns nil based on basename." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((hidden-dir (cj/create-test-subdirectory "visible/.hidden"))) + (should-not (cj/music--valid-directory-p hidden-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-directory-p-error-regular-file-returns-nil () + "Validate regular file (not directory) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-file (cj/create-temp-test-file "testfile-"))) + (should-not (cj/music--valid-directory-p test-file))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-error-nonexistent-path-returns-nil () + "Validate nonexistent path returns nil." + (should-not (cj/music--valid-directory-p "/nonexistent/path/to/directory"))) + +(ert-deftest test-music-config--valid-directory-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-directory-p nil))) + +(ert-deftest test-music-config--valid-directory-p-error-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-directory-p ""))) + +(provide 'test-music-config--valid-directory-p) +;;; test-music-config--valid-directory-p.el ends here diff --git a/tests/test-music-config--valid-file-p.el b/tests/test-music-config--valid-file-p.el new file mode 100644 index 00000000..8099c50c --- /dev/null +++ b/tests/test-music-config--valid-file-p.el @@ -0,0 +1,99 @@ +;;; test-music-config--valid-file-p.el --- Tests for music file validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-file-p function. +;; Tests the pure, deterministic helper that validates music file extensions. +;; +;; Test organization: +;; - Normal Cases: Valid music extensions (case-insensitive) +;; - Boundary Cases: Edge conditions (no extension, dots in path, empty strings) +;; - Error Cases: Invalid extensions, 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--valid-file-p-normal-mp3-extension-returns-true () + "Validate mp3 file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-normal-flac-extension-returns-true () + "Validate flac file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.flac"))) + +(ert-deftest test-music-config--valid-file-p-normal-all-extensions-return-true () + "Validate all configured music extensions return non-nil." + ;; Test each extension from cj/music-file-extensions + (dolist (ext '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")) + (should (cj/music--valid-file-p (format "/path/to/song.%s" ext))))) + +(ert-deftest test-music-config--valid-file-p-normal-uppercase-extension-returns-true () + "Validate uppercase extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.MP3"))) + +(ert-deftest test-music-config--valid-file-p-normal-mixed-case-extension-returns-true () + "Validate mixed-case extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.Mp3")) + (should (cj/music--valid-file-p "/path/to/song.FLaC"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-file-p-boundary-dots-in-path-returns-true () + "Validate file with dots in directory path uses only last extension." + (should (cj/music--valid-file-p "/path/with.dots/in.directory/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-multiple-extensions-uses-last () + "Validate file with multiple extensions uses rightmost extension." + (should (cj/music--valid-file-p "/path/to/song.backup.mp3")) + (should (cj/music--valid-file-p "/path/to/song.old.flac"))) + +(ert-deftest test-music-config--valid-file-p-boundary-just-filename-with-extension-returns-true () + "Validate bare filename without path returns non-nil." + (should (cj/music--valid-file-p "song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-no-extension-returns-nil () + "Validate file without extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/song"))) + +(ert-deftest test-music-config--valid-file-p-boundary-dot-at-end-returns-nil () + "Validate file ending with dot (empty extension) returns nil." + (should-not (cj/music--valid-file-p "/path/to/song."))) + +(ert-deftest test-music-config--valid-file-p-boundary-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-file-p ""))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-file-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-file-p nil))) + +(ert-deftest test-music-config--valid-file-p-error-non-music-extension-returns-nil () + "Validate non-music file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/document.txt")) + (should-not (cj/music--valid-file-p "/path/to/readme.md"))) + +(ert-deftest test-music-config--valid-file-p-error-image-extension-returns-nil () + "Validate image file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/cover.jpg")) + (should-not (cj/music--valid-file-p "/path/to/artwork.png"))) + +(ert-deftest test-music-config--valid-file-p-error-video-extension-returns-nil () + "Validate video file extension returns nil (mp4 not in list, only m4a)." + (should-not (cj/music--valid-file-p "/path/to/video.mp4")) + (should-not (cj/music--valid-file-p "/path/to/clip.mkv"))) + +(provide 'test-music-config--valid-file-p) +;;; test-music-config--valid-file-p.el ends here diff --git a/tests/test-org-contacts-capture-finalize.el b/tests/test-org-contacts-capture-finalize.el new file mode 100644 index 00000000..d379a912 --- /dev/null +++ b/tests/test-org-contacts-capture-finalize.el @@ -0,0 +1,217 @@ +;;; test-org-contacts-capture-finalize.el --- Tests for org-contacts capture template finalization -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;;; Commentary: + +;; Unit tests for the org-contacts capture template finalization function +;; that automatically inserts birthday timestamps. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'org) + +;; Define the function to test (copied from org-contacts-config.el) +(defun cj/org-contacts-finalize-birthday-timestamp () + "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set. +This function is called during `org-capture' finalization to automatically +insert a plain timestamp for birthdays, enabling them to appear in org-agenda +without requiring org-contacts to be loaded in the async subprocess." + (when (string= (plist-get org-capture-plist :key) "C") + (save-excursion + (goto-char (point-min)) + ;; Find the properties drawer + (when (re-search-forward "^:PROPERTIES:" nil t) + (let ((drawer-start (point)) + (drawer-end (save-excursion + (when (re-search-forward "^:END:" nil t) + (point))))) + (when drawer-end + ;; Get BIRTHDAY property value + (goto-char drawer-start) + (when (re-search-forward "^:BIRTHDAY:[ \t]*\\(.+\\)$" drawer-end t) + (let ((birthday-value (string-trim (match-string 1)))) + ;; Only process non-empty birthdays + (when (and birthday-value + (not (string-blank-p birthday-value))) + ;; Parse birthday and create timestamp + (let* ((parsed (cond + ;; Format: YYYY-MM-DD + ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-value) + (list (string-to-number (match-string 1 birthday-value)) + (string-to-number (match-string 2 birthday-value)) + (string-to-number (match-string 3 birthday-value)))) + ;; Format: MM-DD + ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-value) + (list nil + (string-to-number (match-string 1 birthday-value)) + (string-to-number (match-string 2 birthday-value)))) + (t nil))) + (year (when parsed (or (nth 0 parsed) (nth 5 (decode-time))))) + (month (when parsed (nth 1 parsed))) + (day (when parsed (nth 2 parsed)))) + (when (and year month day) + ;; Create timestamp + (let* ((time (encode-time 0 0 0 day month year)) + (dow (format-time-string "%a" time)) + (date-str (format "%04d-%02d-%02d" year month day)) + (timestamp (format "<%s %s +1y>" date-str dow))) + ;; Insert after :END: if not already present + (goto-char drawer-end) + (let ((heading-end (save-excursion (outline-next-heading) (point)))) + (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t) + (goto-char drawer-end) + (end-of-line) + (insert "\n" timestamp))))))))))))) + +;;; Tests for birthday timestamp finalization + +(ert-deftest test-contacts-capture-finalize-with-full-birthday () + "Test that finalize adds timestamp for YYYY-MM-DD birthday." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + ;; Simulate capture context + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + ;; Timestamp should be after :END: + (should (string-match-p ":END:\n<1985-03-15" content)))))) + +(ert-deftest test-contacts-capture-finalize-with-partial-birthday () + "Test that finalize adds timestamp for MM-DD birthday with current year." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 07-04\n") + (insert ":END:\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp with current year + (should (string-match-p (format "<%d-07-04 [A-Za-z]\\{3\\} \\+1y>" current-year) content))))))) + +(ert-deftest test-contacts-capture-finalize-without-birthday () + "Test that finalize does nothing when no birthday property." + (with-temp-buffer + (org-mode) + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-with-empty-birthday () + "Test that finalize skips empty birthday values." + (with-temp-buffer + (org-mode) + (insert "* David Davis\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: \n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-prevents-duplicates () + "Test that finalize doesn't add duplicate timestamps." + (with-temp-buffer + (org-mode) + (insert "* Eve Evans\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "<2000-01-01 Sat +1y>\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Should have exactly one timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max))))))) + +(ert-deftest test-contacts-capture-finalize-only-for-contact-template () + "Test that finalize only runs for 'C' template key." + (with-temp-buffer + (org-mode) + (insert "* Task with birthday property\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "t"))) ; Different template key + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-contacts-capture-finalize-preserves-existing-content () + "Test that finalize preserves all existing content." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":PHONE: 555-1234\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":NICKNAME: Ali\n") + (insert ":NOTE: Met at conference\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; All properties should still be present + (should (string-search ":EMAIL: alice@example.com" content)) + (should (string-search ":PHONE: 555-1234" content)) + (should (string-search ":BIRTHDAY: 1985-03-15" content)) + (should (string-search ":NICKNAME: Ali" content)) + (should (string-search ":NOTE: Met at conference" content)) + ;; Added timestamp should still be there + (should (string-search "Added: [2025-11-01 Fri 20:30]" content)) + ;; Birthday timestamp should be added + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)))))) + +(provide 'test-org-contacts-capture-finalize) +;;; test-org-contacts-capture-finalize.el ends here |
