summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--tests/test-music-config--append-track-to-m3u-file.el187
-rw-r--r--tests/test-music-config--collect-entries-recursive.el245
-rw-r--r--tests/test-music-config--completion-table.el134
-rw-r--r--tests/test-music-config--get-m3u-basenames.el121
-rw-r--r--tests/test-music-config--get-m3u-files.el150
-rw-r--r--tests/test-music-config--m3u-file-tracks.el193
-rw-r--r--tests/test-music-config--safe-filename.el97
-rw-r--r--tests/test-music-config--valid-directory-p.el139
-rw-r--r--tests/test-music-config--valid-file-p.el99
-rw-r--r--tests/test-org-contacts-capture-finalize.el217
-rw-r--r--todo.org1
11 files changed, 1583 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
diff --git a/todo.org b/todo.org
index d74524e2..4eab4122 100644
--- a/todo.org
+++ b/todo.org
@@ -18,6 +18,7 @@ V2MOM is located at: [[file:docs/emacs-config-v2mom.org][emacs-config-v2mom.org]
Research/ideas that don't serve vision: [[file:docs/someday-maybe.org][someday-maybe.org]]
* Method 1: Make Using Emacs Frictionless [7/13]
+
** DONE [#A] Remove network check from startup (saves 1+ seconds)
CLOSED: [2025-10-31 Fri]