summaryrefslogtreecommitdiff
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/fixtures/grammar-correct.txt5
-rw-r--r--tests/fixtures/grammar-errors-basic.txt7
-rw-r--r--tests/fixtures/grammar-errors-punctuation.txt5
-rw-r--r--tests/fixtures/pactl-output-empty.txt0
-rw-r--r--tests/fixtures/pactl-output-inputs-only.txt3
-rw-r--r--tests/fixtures/pactl-output-malformed.txt4
-rw-r--r--tests/fixtures/pactl-output-monitors-only.txt3
-rw-r--r--tests/fixtures/pactl-output-normal.txt6
-rw-r--r--tests/fixtures/pactl-output-single.txt1
-rw-r--r--tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el187
-rw-r--r--tests/test-custom-buffer-file-copy-to-top-of-buffer.el186
-rw-r--r--tests/test-flycheck-languagetool-setup.el71
-rw-r--r--tests/test-integration-grammar-checking.el190
-rw-r--r--tests/test-integration-recording-device-workflow.el232
-rw-r--r--tests/test-integration-transcription.el145
-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--tests/test-transcription-audio-file.el83
-rw-r--r--tests/test-transcription-config--transcription-script-path.el106
-rw-r--r--tests/test-transcription-counter.el98
-rw-r--r--tests/test-transcription-duration.el58
-rw-r--r--tests/test-transcription-log-cleanup.el44
-rw-r--r--tests/test-transcription-paths.el80
-rw-r--r--tests/test-video-audio-recording-check-ffmpeg.el46
-rw-r--r--tests/test-video-audio-recording-detect-mic-device.el152
-rw-r--r--tests/test-video-audio-recording-detect-system-device.el151
-rw-r--r--tests/test-video-audio-recording-friendly-state.el65
-rw-r--r--tests/test-video-audio-recording-get-devices.el142
-rw-r--r--tests/test-video-audio-recording-group-devices-by-hardware.el194
-rw-r--r--tests/test-video-audio-recording-parse-pactl-output.el157
-rw-r--r--tests/test-video-audio-recording-parse-sources.el98
39 files changed, 4101 insertions, 0 deletions
diff --git a/tests/fixtures/grammar-correct.txt b/tests/fixtures/grammar-correct.txt
new file mode 100644
index 00000000..bea335e8
--- /dev/null
+++ b/tests/fixtures/grammar-correct.txt
@@ -0,0 +1,5 @@
+This is a well-written sentence with no grammar errors.
+
+The quick brown fox jumps over the lazy dog.
+
+Everything here follows standard English grammar rules.
diff --git a/tests/fixtures/grammar-errors-basic.txt b/tests/fixtures/grammar-errors-basic.txt
new file mode 100644
index 00000000..c2f72c12
--- /dev/null
+++ b/tests/fixtures/grammar-errors-basic.txt
@@ -0,0 +1,7 @@
+This are a test of basic grammar errors.
+
+I could of done better with this sentence.
+
+Their going to the store to buy there groceries.
+
+The dog wagged it's tail happily.
diff --git a/tests/fixtures/grammar-errors-punctuation.txt b/tests/fixtures/grammar-errors-punctuation.txt
new file mode 100644
index 00000000..37de646a
--- /dev/null
+++ b/tests/fixtures/grammar-errors-punctuation.txt
@@ -0,0 +1,5 @@
+This sentence is missing punctuation at the end
+
+Multiple spaces between words should be detected.
+
+A sentence with,incorrect comma,placement and usage.
diff --git a/tests/fixtures/pactl-output-empty.txt b/tests/fixtures/pactl-output-empty.txt
new file mode 100644
index 00000000..e69de29b
--- /dev/null
+++ b/tests/fixtures/pactl-output-empty.txt
diff --git a/tests/fixtures/pactl-output-inputs-only.txt b/tests/fixtures/pactl-output-inputs-only.txt
new file mode 100644
index 00000000..1840b37c
--- /dev/null
+++ b/tests/fixtures/pactl-output-inputs-only.txt
@@ -0,0 +1,3 @@
+50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
+79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED
+100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED
diff --git a/tests/fixtures/pactl-output-malformed.txt b/tests/fixtures/pactl-output-malformed.txt
new file mode 100644
index 00000000..a37b8dd6
--- /dev/null
+++ b/tests/fixtures/pactl-output-malformed.txt
@@ -0,0 +1,4 @@
+This is not valid pactl output
+Some random text
+50 incomplete-line-missing-fields
+Another bad line with only two tabs
diff --git a/tests/fixtures/pactl-output-monitors-only.txt b/tests/fixtures/pactl-output-monitors-only.txt
new file mode 100644
index 00000000..be29ebe8
--- /dev/null
+++ b/tests/fixtures/pactl-output-monitors-only.txt
@@ -0,0 +1,3 @@
+49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED
+81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz RUNNING
+99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED
diff --git a/tests/fixtures/pactl-output-normal.txt b/tests/fixtures/pactl-output-normal.txt
new file mode 100644
index 00000000..6d8d955b
--- /dev/null
+++ b/tests/fixtures/pactl-output-normal.txt
@@ -0,0 +1,6 @@
+49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED
+50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
+79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED
+81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz SUSPENDED
+99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED
+100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED
diff --git a/tests/fixtures/pactl-output-single.txt b/tests/fixtures/pactl-output-single.txt
new file mode 100644
index 00000000..d1d1c254
--- /dev/null
+++ b/tests/fixtures/pactl-output-single.txt
@@ -0,0 +1 @@
+50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED
diff --git a/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el b/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el
new file mode 100644
index 00000000..0c41761e
--- /dev/null
+++ b/tests/test-custom-buffer-file-copy-to-bottom-of-buffer.el
@@ -0,0 +1,187 @@
+;;; test-custom-buffer-file-copy-to-bottom-of-buffer.el --- Tests for cj/copy-to-bottom-of-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/copy-to-bottom-of-buffer function from custom-buffer-file.el
+;;
+;; This function copies all text from point to the end of the current buffer
+;; to the kill ring without modifying the buffer.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-buffer-file)
+
+;;; Setup and Teardown
+
+(defun test-copy-to-bottom-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-copy-to-bottom-teardown ()
+ "Clean up test environment."
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-normal-point-in-middle-copies-to-end ()
+ "Should copy from point to end when point in middle."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (forward-line 1) ; Point at start of "Line 2"
+ (let ((original-content (buffer-string)))
+ (cj/copy-to-bottom-of-buffer)
+ ;; Buffer should be unchanged
+ (should (equal (buffer-string) original-content))
+ ;; Kill ring should contain from point to end
+ (should (equal (car kill-ring) "Line 2\nLine 3"))))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-normal-single-line-copies-partial ()
+ "Should copy partial line content from middle of line."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello World")
+ (goto-char (point-min))
+ (forward-char 6) ; Point after "Hello "
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Hello World"))
+ (should (equal (car kill-ring) "World")))
+ (test-copy-to-bottom-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-at-beginning-copies-all ()
+ "Should copy entire buffer when point at beginning."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Line 1\nLine 2\nLine 3"))
+ (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3")))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-at-end-copies-empty ()
+ "Should copy empty string when point at end."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-max))
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Line 1\nLine 2\nLine 3"))
+ (should (equal (car kill-ring) "")))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-empty-buffer-copies-empty ()
+ "Should copy empty string in empty buffer."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) ""))
+ (should (equal (car kill-ring) "")))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-point-second-to-last-char-copies-one ()
+ "Should copy last character when point at second-to-last."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello")
+ (goto-char (1- (point-max))) ; Before 'o'
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Hello"))
+ (should (equal (car kill-ring) "o")))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-unicode-content-copies-correctly ()
+ "Should handle unicode content correctly."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello 👋\nمرحبا\nWorld")
+ (goto-char (point-min))
+ (forward-line 1)
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Hello 👋\nمرحبا\nWorld"))
+ (should (equal (car kill-ring) "مرحبا\nWorld")))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-narrowed-buffer-respects-narrowing ()
+ "Should respect narrowing and only copy within narrowed region."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3\nLine 4")
+ (goto-char (point-min))
+ (forward-line 1)
+ (let ((start (point)))
+ (forward-line 2)
+ (narrow-to-region start (point))
+ (goto-char (point-min))
+ (forward-line 1) ; Point at "Line 3"
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "Line 2\nLine 3\n"))
+ (should (equal (car kill-ring) "Line 3\n"))))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-whitespace-only-copies-whitespace ()
+ "Should copy whitespace-only content."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert " \n\t\t\n ")
+ (goto-char (point-min))
+ (forward-char 4) ; After first newline
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) " \n\t\t\n "))
+ (should (equal (car kill-ring) "\t\t\n ")))
+ (test-copy-to-bottom-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-boundary-single-character-copies-char ()
+ "Should copy single character buffer."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "x")
+ (goto-char (point-min))
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (buffer-string) "x"))
+ (should (equal (car kill-ring) "x")))
+ (test-copy-to-bottom-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-custom-buffer-file-copy-to-bottom-of-buffer-error-read-only-buffer-succeeds ()
+ "Should work in read-only buffer since it doesn't modify content."
+ (test-copy-to-bottom-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Read-only content")
+ (read-only-mode 1)
+ (goto-char (point-min))
+ (cj/copy-to-bottom-of-buffer)
+ (should (equal (car kill-ring) "Read-only content")))
+ (test-copy-to-bottom-teardown)))
+
+(provide 'test-custom-buffer-file-copy-to-bottom-of-buffer)
+;;; test-custom-buffer-file-copy-to-bottom-of-buffer.el ends here
diff --git a/tests/test-custom-buffer-file-copy-to-top-of-buffer.el b/tests/test-custom-buffer-file-copy-to-top-of-buffer.el
new file mode 100644
index 00000000..0f09f26d
--- /dev/null
+++ b/tests/test-custom-buffer-file-copy-to-top-of-buffer.el
@@ -0,0 +1,186 @@
+;;; test-custom-buffer-file-copy-to-top-of-buffer.el --- Tests for cj/copy-to-top-of-buffer -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/copy-to-top-of-buffer function from custom-buffer-file.el
+;;
+;; This function copies all text from the beginning of the buffer to point
+;; to the kill ring without modifying the buffer.
+
+;;; Code:
+
+(require 'ert)
+(require 'testutil-general)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub ps-print package
+(provide 'ps-print)
+
+;; Now load the actual production module
+(require 'custom-buffer-file)
+
+;;; Setup and Teardown
+
+(defun test-copy-to-top-setup ()
+ "Set up test environment."
+ (setq kill-ring nil))
+
+(defun test-copy-to-top-teardown ()
+ "Clean up test environment."
+ (setq kill-ring nil))
+
+;;; Normal Cases
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-normal-point-in-middle-copies-from-beginning ()
+ "Should copy from beginning to point when point in middle."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (forward-line 2) ; Point at start of "Line 3"
+ (let ((original-content (buffer-string)))
+ (cj/copy-to-top-of-buffer)
+ ;; Buffer should be unchanged
+ (should (equal (buffer-string) original-content))
+ ;; Kill ring should contain from beginning to point
+ (should (equal (car kill-ring) "Line 1\nLine 2\n"))))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-normal-single-line-copies-partial ()
+ "Should copy partial line content from beginning to middle of line."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello World")
+ (goto-char (point-min))
+ (forward-char 5) ; Point after "Hello"
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "Hello World"))
+ (should (equal (car kill-ring) "Hello")))
+ (test-copy-to-top-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-end-copies-all ()
+ "Should copy entire buffer when point at end."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-max))
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "Line 1\nLine 2\nLine 3"))
+ (should (equal (car kill-ring) "Line 1\nLine 2\nLine 3")))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-beginning-copies-empty ()
+ "Should copy empty string when point at beginning."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3")
+ (goto-char (point-min))
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "Line 1\nLine 2\nLine 3"))
+ (should (equal (car kill-ring) "")))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-empty-buffer-copies-empty ()
+ "Should copy empty string in empty buffer."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) ""))
+ (should (equal (car kill-ring) "")))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-point-at-second-char-copies-one ()
+ "Should copy first character when point at second character."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello")
+ (goto-char (1+ (point-min))) ; After 'H'
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "Hello"))
+ (should (equal (car kill-ring) "H")))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-unicode-content-copies-correctly ()
+ "Should handle unicode content correctly."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Hello 👋\nمرحبا\nWorld")
+ (goto-char (point-min))
+ (forward-line 2) ; Point at start of "World"
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "Hello 👋\nمرحبا\nWorld"))
+ (should (equal (car kill-ring) "Hello 👋\nمرحبا\n")))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-narrowed-buffer-respects-narrowing ()
+ "Should respect narrowing and only copy within narrowed region."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Line 1\nLine 2\nLine 3\nLine 4")
+ (goto-char (point-min))
+ (forward-line 1)
+ (let ((start (point)))
+ (forward-line 2)
+ (narrow-to-region start (point))
+ (goto-char (point-max)) ; Point at end of narrowed region
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "Line 2\nLine 3\n"))
+ (should (equal (car kill-ring) "Line 2\nLine 3\n"))))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-whitespace-only-copies-whitespace ()
+ "Should copy whitespace-only content."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert " \n\t\t\n ")
+ (goto-char (point-min))
+ (forward-char 7) ; After second newline
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) " \n\t\t\n "))
+ (should (equal (car kill-ring) " \n\t\t\n")))
+ (test-copy-to-top-teardown)))
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-boundary-single-character-copies-char ()
+ "Should copy single character buffer."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "x")
+ (goto-char (point-max))
+ (cj/copy-to-top-of-buffer)
+ (should (equal (buffer-string) "x"))
+ (should (equal (car kill-ring) "x")))
+ (test-copy-to-top-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-custom-buffer-file-copy-to-top-of-buffer-error-read-only-buffer-succeeds ()
+ "Should work in read-only buffer since it doesn't modify content."
+ (test-copy-to-top-setup)
+ (unwind-protect
+ (with-temp-buffer
+ (insert "Read-only content")
+ (goto-char (point-max))
+ (read-only-mode 1)
+ (cj/copy-to-top-of-buffer)
+ (should (equal (car kill-ring) "Read-only content")))
+ (test-copy-to-top-teardown)))
+
+(provide 'test-custom-buffer-file-copy-to-top-of-buffer)
+;;; test-custom-buffer-file-copy-to-top-of-buffer.el ends here
diff --git a/tests/test-flycheck-languagetool-setup.el b/tests/test-flycheck-languagetool-setup.el
new file mode 100644
index 00000000..a719e822
--- /dev/null
+++ b/tests/test-flycheck-languagetool-setup.el
@@ -0,0 +1,71 @@
+;;; test-flycheck-languagetool-setup.el --- Unit tests for LanguageTool setup -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests verifying LanguageTool installation and wrapper script setup.
+;; Focus: Testing OUR code (wrapper script, file setup), not flycheck internals.
+;;
+;; We trust that flycheck works correctly (it's an external framework).
+;; These tests verify:
+;; - LanguageTool is installed and accessible
+;; - Our wrapper script exists, is executable, and has correct structure
+;; - Python 3 dependency is available
+;;
+;; Categories: Normal (installation checks), Boundary (script structure), Error (missing dependencies)
+
+;;; Code:
+
+(require 'ert)
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-exists ()
+ "Test that languagetool-flycheck wrapper script exists."
+ (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")))
+ (should (file-exists-p wrapper-path))))
+
+(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-executable ()
+ "Test that languagetool-flycheck wrapper script is executable."
+ (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")))
+ (should (file-executable-p wrapper-path))))
+
+(ert-deftest test-flycheck-languagetool-setup-normal-languagetool-installed ()
+ "Test that languagetool command is available in PATH."
+ (should (executable-find "languagetool")))
+
+(ert-deftest test-flycheck-languagetool-setup-normal-python3-available ()
+ "Test that python3 is available for wrapper script."
+ (should (executable-find "python3")))
+
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-flycheck-languagetool-setup-boundary-wrapper-script-format ()
+ "Test that wrapper script has correct shebang and structure."
+ (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")))
+ (with-temp-buffer
+ (insert-file-contents wrapper-path)
+ (goto-char (point-min))
+ ;; Check shebang
+ (should (looking-at "#!/usr/bin/env python3"))
+ ;; Check it contains required imports
+ (should (search-forward "import json" nil t))
+ (should (search-forward "import subprocess" nil t)))))
+
+;; ----------------------------- Error Cases -----------------------------------
+
+(ert-deftest test-flycheck-languagetool-setup-error-missing-file-argument ()
+ "Test that wrapper script requires file argument.
+When called without arguments, wrapper should exit with error."
+ (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))
+ (exit-code nil))
+ (with-temp-buffer
+ (setq exit-code (call-process wrapper nil t nil))
+ ;; Should exit with non-zero status when no file provided
+ (should-not (= 0 exit-code))
+ ;; Should print usage message to stderr (captured in buffer)
+ (goto-char (point-min))
+ (should (or (search-forward "Usage:" nil t)
+ (search-forward "FILE" nil t))))))
+
+(provide 'test-flycheck-languagetool-setup)
+;;; test-flycheck-languagetool-setup.el ends here
diff --git a/tests/test-integration-grammar-checking.el b/tests/test-integration-grammar-checking.el
new file mode 100644
index 00000000..8948c17a
--- /dev/null
+++ b/tests/test-integration-grammar-checking.el
@@ -0,0 +1,190 @@
+;;; test-integration-grammar-checking.el --- Integration tests for grammar checking -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests for the LanguageTool wrapper script with real grammar checking.
+;; Tests the integration: test fixture → wrapper script → LanguageTool → formatted output
+;;
+;; Components integrated:
+;; - scripts/languagetool-flycheck (our wrapper script)
+;; - languagetool command (external grammar checker)
+;; - Test fixtures with known grammar errors
+;; - Output formatting (JSON → flycheck format)
+;;
+;; Focus: Testing OUR integration code (wrapper), not flycheck framework.
+;; We trust that flycheck works; we test that our wrapper produces correct output.
+;;
+;; Categories: Normal workflow, Boundary cases, Error handling
+
+;;; Code:
+
+(require 'ert)
+
+;; ----------------------------- Test Helpers ----------------------------------
+
+(defun test-integration-grammar--fixture-path (filename)
+ "Return absolute path to test fixture FILENAME."
+ (expand-file-name (concat "tests/fixtures/" filename)
+ user-emacs-directory))
+
+(defun test-integration-grammar--wrapper-output (file-path)
+ "Run languagetool-flycheck wrapper directly on FILE-PATH.
+Returns output as string."
+ (let ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")))
+ (with-temp-buffer
+ (call-process wrapper nil t nil file-path)
+ (buffer-string))))
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-integration-grammar-checking-normal-wrapper-detects-errors ()
+ "Test that wrapper script detects grammar errors in fixture.
+
+Components integrated:
+- scripts/languagetool-flycheck (wrapper script)
+- languagetool command (external checker)
+- Test fixture with known errors"
+ (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt"))
+ (output (test-integration-grammar--wrapper-output fixture)))
+ ;; Should detect "This are" error
+ (should (string-match-p "PLURAL_VERB_AFTER_THIS\\|This are" output))
+ ;; Should detect "could of" error
+ (should (string-match-p "COULD_OF\\|could of" output))
+ ;; Output should be in flycheck format (filename:line:column:)
+ (should (string-match-p "grammar-errors-basic\\.txt:[0-9]+:[0-9]+:" output))))
+
+(ert-deftest test-integration-grammar-checking-normal-wrapper-format ()
+ "Test that wrapper outputs flycheck-compatible format.
+
+Components integrated:
+- scripts/languagetool-flycheck (output formatting)
+- languagetool command (JSON parsing)"
+ (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt"))
+ (output (test-integration-grammar--wrapper-output fixture))
+ (lines (split-string output "\n" t)))
+ (dolist (line lines)
+ ;; Each line should match: filename:line:column: message
+ (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line)))))
+
+(ert-deftest test-integration-grammar-checking-normal-correct-text-no-errors ()
+ "Test that grammatically correct text produces no errors.
+
+Components integrated:
+- scripts/languagetool-flycheck (wrapper script)
+- languagetool command (validation)
+- Test fixture with correct grammar"
+ (let* ((fixture (test-integration-grammar--fixture-path "grammar-correct.txt"))
+ (output (test-integration-grammar--wrapper-output fixture)))
+ ;; Correct grammar should produce no output (or only whitespace)
+ (should (or (string-empty-p (string-trim output))
+ (= 0 (length (string-trim output)))))))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-integration-grammar-checking-boundary-empty-file ()
+ "Test that empty file produces no errors.
+
+Components integrated:
+- scripts/languagetool-flycheck (empty input handling)
+- languagetool command"
+ (let ((temp-file (make-temp-file "grammar-test-" nil ".txt")))
+ (unwind-protect
+ (let ((output (test-integration-grammar--wrapper-output temp-file)))
+ (should (or (string-empty-p (string-trim output))
+ (= 0 (length (string-trim output))))))
+ (delete-file temp-file))))
+
+(ert-deftest test-integration-grammar-checking-boundary-single-word ()
+ "Test that single word file produces no errors.
+
+Components integrated:
+- scripts/languagetool-flycheck (minimal input)
+- languagetool command"
+ (let ((temp-file (make-temp-file "grammar-test-" nil ".txt")))
+ (unwind-protect
+ (progn
+ (with-temp-file temp-file
+ (insert "Hello"))
+ (let ((output (test-integration-grammar--wrapper-output temp-file)))
+ ;; Single word might produce no errors or might flag as incomplete sentence
+ ;; Just verify it doesn't crash
+ (should (stringp output))))
+ (delete-file temp-file))))
+
+(ert-deftest test-integration-grammar-checking-boundary-multiple-paragraphs ()
+ "Test that file with multiple paragraphs is checked completely.
+
+Components integrated:
+- scripts/languagetool-flycheck (multi-paragraph handling)
+- languagetool command (full file processing)"
+ (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt"))
+ (output (test-integration-grammar--wrapper-output fixture))
+ (lines (split-string output "\n" t)))
+ ;; Should detect errors in multiple lines
+ ;; Check that we have multiple error reports with different line numbers
+ (let ((line-numbers '()))
+ (dolist (line lines)
+ (when (string-match ":[0-9]+:" line)
+ (let ((line-num (string-to-number
+ (nth 1 (split-string line ":")))))
+ (push line-num line-numbers))))
+ ;; Should have errors from multiple lines
+ (should (> (length (delete-dups line-numbers)) 1)))))
+
+;; ----------------------------- Error Cases -----------------------------------
+
+(ert-deftest test-integration-grammar-checking-error-nonexistent-file ()
+ "Test that wrapper handles nonexistent file with error.
+
+Components integrated:
+- scripts/languagetool-flycheck (error handling)
+- File system (missing file)
+- Python exception handling"
+ (let* ((nonexistent "/tmp/this-file-does-not-exist-12345.txt")
+ (wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))
+ (exit-code nil)
+ (output nil))
+ (with-temp-buffer
+ (setq exit-code (call-process wrapper nil t nil nonexistent))
+ (setq output (buffer-string)))
+ ;; LanguageTool/Python should handle the error
+ ;; Check that we get output (error message or error in flycheck format)
+ (should (stringp output))
+ ;; Output should contain some indication of the error (filename or error marker)
+ (should (or (string-match-p nonexistent output)
+ (string-match-p "error" output)
+ (string-match-p "Error" output)
+ ;; Or it might report no errors for a nonexistent file
+ (string-empty-p (string-trim output))))))
+
+(ert-deftest test-integration-grammar-checking-error-no-file-argument ()
+ "Test that wrapper requires file argument.
+
+Components integrated:
+- scripts/languagetool-flycheck (argument validation)"
+ (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))
+ (exit-code nil))
+ (with-temp-buffer
+ (setq exit-code (call-process wrapper nil t nil))
+ ;; Should exit with non-zero status when no file provided
+ (should-not (= 0 exit-code)))))
+
+;; ----------------------------- Integration with Real Files -------------------
+
+(ert-deftest test-integration-grammar-checking-integration-comprehensive-errors ()
+ "Test that wrapper catches multiple types of grammar errors in one file.
+
+Components integrated:
+- scripts/languagetool-flycheck (our wrapper)
+- languagetool command (comprehensive checking)
+- Test fixture with various error types"
+ (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt"))
+ (output (test-integration-grammar--wrapper-output fixture))
+ (lines (split-string output "\n" t)))
+ ;; Should detect multiple errors (at least 3-4 in the fixture)
+ (should (>= (length lines) 3))
+ ;; All lines should be properly formatted
+ (dolist (line lines)
+ (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line)))))
+
+(provide 'test-integration-grammar-checking)
+;;; test-integration-grammar-checking.el ends here
diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el
new file mode 100644
index 00000000..ba92d700
--- /dev/null
+++ b/tests/test-integration-recording-device-workflow.el
@@ -0,0 +1,232 @@
+;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests covering the complete device detection and grouping workflow.
+;;
+;; This tests the full pipeline from raw pactl output through parsing, grouping,
+;; and friendly name assignment. The workflow enables users to select audio devices
+;; for recording calls/meetings.
+;;
+;; Components integrated:
+;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data)
+;; - cj/recording-parse-sources (shell command wrapper)
+;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device)
+;; - cj/recording-friendly-state (convert technical state names)
+;; - Bluetooth MAC address normalization (colons → underscores)
+;; - Device name pattern matching (USB, PCI, Bluetooth)
+;; - Friendly name assignment (user-facing device names)
+;;
+;; Critical integration points:
+;; - Parse output must produce data that group-devices can process
+;; - Bluetooth MAC normalization must work across parse→group boundary
+;; - Incomplete devices (only mic OR only monitor) must be filtered
+;; - Friendly names must correctly identify device types
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases - Complete Workflow
+
+(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices ()
+ "Test complete workflow from pactl output to grouped devices.
+
+When pactl output contains all three device types (built-in, USB, Bluetooth),
+the workflow should parse, group, and assign friendly names to all devices.
+
+Components integrated:
+- cj/recording--parse-pactl-output (parsing)
+- cj/recording-group-devices-by-hardware (grouping + MAC normalization)
+- Device pattern matching (USB/PCI/Bluetooth detection)
+- Friendly name assignment
+
+Validates:
+- All three device types are detected
+- Bluetooth MAC addresses normalized (colons → underscores)
+- Each device has both mic and monitor
+- Friendly names correctly assigned
+- Complete data flow: raw output → parsed list → grouped pairs"
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ ;; Test parse step
+ (let ((parsed (cj/recording-parse-sources)))
+ (should (= 6 (length parsed)))
+
+ ;; Test group step (receives parsed data)
+ (let ((grouped (cj/recording-group-devices-by-hardware)))
+ (should (= 3 (length grouped)))
+
+ ;; Validate built-in device
+ (let ((built-in (assoc "Built-in Laptop Audio" grouped)))
+ (should built-in)
+ (should (string-prefix-p "alsa_input.pci" (cadr built-in)))
+ (should (string-prefix-p "alsa_output.pci" (cddr built-in))))
+
+ ;; Validate USB device
+ (let ((usb (assoc "Jabra SPEAK 510 USB" grouped)))
+ (should usb)
+ (should (string-match-p "Jabra" (cadr usb)))
+ (should (string-match-p "Jabra" (cddr usb))))
+
+ ;; Validate Bluetooth device (CRITICAL: MAC normalization)
+ (let ((bluetooth (assoc "Bluetooth Headset" grouped)))
+ (should bluetooth)
+ ;; Input has colons
+ (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth)))
+ ;; Output has underscores
+ (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth)))
+ ;; But they're grouped together!
+ (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth)))
+ (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth)))))))))
+
+(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list ()
+ "Test that friendly state names appear in device list output.
+
+When listing devices, technical state names (SUSPENDED, RUNNING) should be
+converted to friendly names (Ready, Active) for better UX.
+
+Components integrated:
+- cj/recording-parse-sources (parsing with state)
+- cj/recording-friendly-state (state name conversion)
+
+Validates:
+- SUSPENDED → Ready
+- RUNNING → Active
+- State conversion works across the parse workflow"
+ (let ((output (concat
+ "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((parsed (cj/recording-parse-sources)))
+ ;; Verify states are parsed correctly
+ (should (equal "SUSPENDED" (nth 2 (nth 0 parsed))))
+ (should (equal "RUNNING" (nth 2 (nth 1 parsed))))
+
+ ;; Verify friendly conversion works
+ (should (equal "Ready" (cj/recording-friendly-state (nth 2 (nth 0 parsed)))))
+ (should (equal "Active" (cj/recording-friendly-state (nth 2 (nth 1 parsed)))))))))
+
+;;; Boundary Cases - Incomplete Devices
+
+(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered ()
+ "Test that devices with only mic OR only monitor are filtered out.
+
+For call recording, we need BOTH mic and monitor from the same device.
+Incomplete devices should not appear in the grouped output.
+
+Components integrated:
+- cj/recording-parse-sources (parsing all devices)
+- cj/recording-group-devices-by-hardware (filtering incomplete pairs)
+
+Validates:
+- Device with only mic is filtered
+- Device with only monitor is filtered
+- Only complete devices (both mic and monitor) are returned
+- Filtering happens at group stage, not parse stage"
+ (let ((output (concat
+ ;; Complete device
+ "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ ;; Incomplete: USB mic with no monitor
+ "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"
+ ;; Incomplete: Bluetooth monitor with no mic
+ "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ ;; Parse sees all 4 devices
+ (let ((parsed (cj/recording-parse-sources)))
+ (should (= 4 (length parsed)))
+
+ ;; Group returns only 1 complete device
+ (let ((grouped (cj/recording-group-devices-by-hardware)))
+ (should (= 1 (length grouped)))
+ (should (equal "Built-in Laptop Audio" (caar grouped))))))))
+
+;;; Edge Cases - Bluetooth MAC Normalization
+
+(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations ()
+ "Test Bluetooth MAC normalization with different formats.
+
+Bluetooth devices use colons in input names but underscores in output names.
+The grouping must normalize these to match devices correctly.
+
+Components integrated:
+- cj/recording-parse-sources (preserves original MAC format)
+- cj/recording-group-devices-by-hardware (normalizes MAC for matching)
+- Base name extraction (regex patterns)
+- MAC address transformation (underscores → colons)
+
+Validates:
+- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly
+- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly
+- Normalization happens during grouping
+- Devices paired despite format difference
+- Original device names preserved (not mutated)"
+ (let ((output (concat
+ "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
+ "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((parsed (cj/recording-parse-sources)))
+ ;; Original formats preserved in parse
+ (should (string-match-p "11:22:33" (caar parsed)))
+ (should (string-match-p "11_22_33" (caadr parsed)))
+
+ ;; But grouping matches them
+ (let ((grouped (cj/recording-group-devices-by-hardware)))
+ (should (= 1 (length grouped)))
+ (should (equal "Bluetooth Headset" (caar grouped)))
+ ;; Original names preserved
+ (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped)))
+ (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped))))))))
+
+;;; Error Cases - Malformed Data
+
+(ert-deftest test-integration-recording-device-workflow-malformed-output-handled ()
+ "Test that malformed pactl output is handled gracefully.
+
+When pactl output is malformed or unparseable, the workflow should not crash.
+It should return empty results at appropriate stages.
+
+Components integrated:
+- cj/recording--parse-pactl-output (malformed line handling)
+- cj/recording-group-devices-by-hardware (empty input handling)
+
+Validates:
+- Malformed lines are silently skipped during parse
+- Empty parse results don't crash grouping
+- Workflow degrades gracefully
+- No exceptions thrown"
+ (let ((output (test-load-fixture "pactl-output-malformed.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((parsed (cj/recording-parse-sources)))
+ ;; Malformed output produces empty parse
+ (should (null parsed))
+
+ ;; Empty parse produces empty grouping (no crash)
+ (let ((grouped (cj/recording-group-devices-by-hardware)))
+ (should (null grouped)))))))
+
+(provide 'test-integration-recording-device-workflow)
+;;; test-integration-recording-device-workflow.el ends here
diff --git a/tests/test-integration-transcription.el b/tests/test-integration-transcription.el
new file mode 100644
index 00000000..96b617bc
--- /dev/null
+++ b/tests/test-integration-transcription.el
@@ -0,0 +1,145 @@
+;;; test-integration-transcription.el --- Integration tests for transcription -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; End-to-end integration tests for transcription workflow
+;; Tests complete workflow with temporary files and mocked processes
+;; Categories: Normal workflow, Error handling, Cleanup
+
+;;; Code:
+
+(require 'ert)
+(require 'transcription-config)
+
+;; ----------------------------- Test Helpers ----------------------------------
+
+(defun test-transcription--make-mock-audio-file ()
+ "Create a temporary mock audio file for testing.
+Returns the absolute path to the file."
+ (let ((file (make-temp-file "test-audio-" nil ".m4a")))
+ (with-temp-file file
+ (insert "Mock audio data"))
+ file))
+
+(defun test-transcription--cleanup-output-files (audio-file)
+ "Delete transcript and log files associated with AUDIO-FILE."
+ (let* ((outputs (cj/--transcription-output-files audio-file))
+ (txt-file (car outputs))
+ (log-file (cdr outputs)))
+ (when (file-exists-p txt-file)
+ (delete-file txt-file))
+ (when (file-exists-p log-file)
+ (delete-file log-file))))
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-integration-transcription-output-files-created ()
+ "Test that .txt and .log files are created for audio file."
+ (let* ((audio-file (test-transcription--make-mock-audio-file))
+ (outputs (cj/--transcription-output-files audio-file))
+ (txt-file (car outputs))
+ (log-file (cdr outputs)))
+ (unwind-protect
+ (progn
+ ;; Verify output file paths are correct
+ (should (string-suffix-p ".txt" txt-file))
+ (should (string-suffix-p ".log" log-file))
+ (should (string= (file-name-sans-extension txt-file)
+ (file-name-sans-extension audio-file)))
+ (should (string= (file-name-sans-extension log-file)
+ (file-name-sans-extension audio-file))))
+ ;; Cleanup
+ (delete-file audio-file)
+ (test-transcription--cleanup-output-files audio-file))))
+
+(ert-deftest test-integration-transcription-validates-file-exists ()
+ "Test that transcription fails for non-existent file."
+ (should-error
+ (cj/--start-transcription-process "/nonexistent/audio.m4a")
+ :type 'user-error))
+
+(ert-deftest test-integration-transcription-validates-audio-extension ()
+ "Test that transcription fails for non-audio file."
+ (let ((non-audio (make-temp-file "test-" nil ".txt")))
+ (unwind-protect
+ (should-error
+ (cj/--start-transcription-process non-audio)
+ :type 'user-error)
+ (delete-file non-audio))))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-integration-transcription-audio-file-detection ()
+ "Test various audio file extensions are accepted."
+ (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg" "opus"))
+ (let ((audio-file (make-temp-file "test-audio-" nil (concat "." ext))))
+ (unwind-protect
+ (progn
+ (should (cj/--audio-file-p audio-file))
+ ;; Would start transcription if script existed
+ )
+ (delete-file audio-file)))))
+
+(ert-deftest test-integration-transcription-filename-with-spaces ()
+ "Test transcription with audio file containing spaces."
+ (let ((audio-file (make-temp-file "test audio file" nil ".m4a")))
+ (unwind-protect
+ (let* ((outputs (cj/--transcription-output-files audio-file))
+ (txt-file (car outputs))
+ (log-file (cdr outputs)))
+ (should (file-name-absolute-p txt-file))
+ (should (file-name-absolute-p log-file)))
+ (delete-file audio-file))))
+
+(ert-deftest test-integration-transcription-filename-with-special-chars ()
+ "Test transcription with special characters in filename."
+ (let ((audio-file (make-temp-file "test_(final)" nil ".m4a")))
+ (unwind-protect
+ (let* ((outputs (cj/--transcription-output-files audio-file))
+ (txt-file (car outputs)))
+ ;; make-temp-file adds random suffix, so just check it ends with .txt
+ ;; and contains the special chars
+ (should (string-suffix-p ".txt" txt-file))
+ (should (string-match-p "test_(final)" txt-file)))
+ (delete-file audio-file))))
+
+;; ----------------------------- Cleanup Tests ---------------------------------
+
+(ert-deftest test-integration-transcription-cleanup-completed ()
+ "Test that completed transcriptions are removed from tracking."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running)
+ (proc2 "file2.m4a" nil complete)
+ (proc3 "file3.m4a" nil error))))
+ (cj/--cleanup-completed-transcriptions)
+ (should (= 1 (length cj/transcriptions-list)))
+ (should (eq 'running (nth 3 (car cj/transcriptions-list))))))
+
+(ert-deftest test-integration-transcription-cleanup-all-complete ()
+ "Test cleanup when all transcriptions are complete."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil complete)
+ (proc2 "file2.m4a" nil error))))
+ (cj/--cleanup-completed-transcriptions)
+ (should (null cj/transcriptions-list))))
+
+(ert-deftest test-integration-transcription-cleanup-preserves-running ()
+ "Test that running transcriptions are not cleaned up."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running)
+ (proc2 "file2.m4a" nil running))))
+ (cj/--cleanup-completed-transcriptions)
+ (should (= 2 (length cj/transcriptions-list)))))
+
+;; ----------------------------- Backend Tests ---------------------------------
+
+(ert-deftest test-integration-transcription-script-path-exists ()
+ "Test that transcription scripts exist in expected location."
+ (dolist (backend '(local-whisper openai-api))
+ (let ((cj/transcribe-backend backend))
+ (let ((script (cj/--transcription-script-path)))
+ (should (file-name-absolute-p script))
+ ;; Note: Script may not exist in test environment, just check path format
+ (should (string-match-p "scripts/" script))))))
+
+(provide 'test-integration-transcription)
+;;; test-integration-transcription.el ends here
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/tests/test-transcription-audio-file.el b/tests/test-transcription-audio-file.el
new file mode 100644
index 00000000..f40d9ca6
--- /dev/null
+++ b/tests/test-transcription-audio-file.el
@@ -0,0 +1,83 @@
+;;; test-transcription-audio-file.el --- Tests for audio file detection -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--audio-file-p function
+;; Categories: Normal cases, Boundary cases, Error cases
+
+;;; Code:
+
+(require 'ert)
+(require 'transcription-config)
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-cj/--audio-file-p-m4a ()
+ "Test that .m4a files are recognized as audio."
+ (should (cj/--audio-file-p "meeting.m4a")))
+
+(ert-deftest test-cj/--audio-file-p-mp3 ()
+ "Test that .mp3 files are recognized as audio."
+ (should (cj/--audio-file-p "podcast.mp3")))
+
+(ert-deftest test-cj/--audio-file-p-wav ()
+ "Test that .wav files are recognized as audio."
+ (should (cj/--audio-file-p "recording.wav")))
+
+(ert-deftest test-cj/--audio-file-p-flac ()
+ "Test that .flac files are recognized as audio."
+ (should (cj/--audio-file-p "music.flac")))
+
+(ert-deftest test-cj/--audio-file-p-with-path ()
+ "Test audio file recognition with full path."
+ (should (cj/--audio-file-p "/home/user/recordings/meeting.m4a")))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-cj/--audio-file-p-uppercase-extension ()
+ "Test that uppercase extensions are recognized."
+ (should (cj/--audio-file-p "MEETING.M4A")))
+
+(ert-deftest test-cj/--audio-file-p-mixed-case ()
+ "Test that mixed case extensions are recognized."
+ (should (cj/--audio-file-p "podcast.Mp3")))
+
+(ert-deftest test-cj/--audio-file-p-no-extension ()
+ "Test that files without extension are not recognized."
+ (should-not (cj/--audio-file-p "meeting")))
+
+(ert-deftest test-cj/--audio-file-p-empty-string ()
+ "Test that empty string is not recognized as audio."
+ (should-not (cj/--audio-file-p "")))
+
+(ert-deftest test-cj/--audio-file-p-dotfile ()
+ "Test that dotfiles without proper extension are not recognized."
+ (should-not (cj/--audio-file-p ".hidden")))
+
+(ert-deftest test-cj/--audio-file-p-multiple-dots ()
+ "Test file with multiple dots but audio extension."
+ (should (cj/--audio-file-p "meeting.2025-11-04.final.m4a")))
+
+;; ------------------------------ Error Cases ----------------------------------
+
+(ert-deftest test-cj/--audio-file-p-not-audio ()
+ "Test that non-audio files are not recognized."
+ (should-not (cj/--audio-file-p "document.pdf")))
+
+(ert-deftest test-cj/--audio-file-p-text-file ()
+ "Test that text files are not recognized as audio."
+ (should-not (cj/--audio-file-p "notes.txt")))
+
+(ert-deftest test-cj/--audio-file-p-org-file ()
+ "Test that org files are not recognized as audio."
+ (should-not (cj/--audio-file-p "tasks.org")))
+
+(ert-deftest test-cj/--audio-file-p-video-file ()
+ "Test that video files are not recognized as audio."
+ (should-not (cj/--audio-file-p "video.mp4")))
+
+(ert-deftest test-cj/--audio-file-p-nil ()
+ "Test that nil input returns nil."
+ (should-not (cj/--audio-file-p nil)))
+
+(provide 'test-transcription-audio-file)
+;;; test-transcription-audio-file.el ends here
diff --git a/tests/test-transcription-config--transcription-script-path.el b/tests/test-transcription-config--transcription-script-path.el
new file mode 100644
index 00000000..a56cb05c
--- /dev/null
+++ b/tests/test-transcription-config--transcription-script-path.el
@@ -0,0 +1,106 @@
+;;; test-transcription-config--transcription-script-path.el --- Tests for cj/--transcription-script-path -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for the cj/--transcription-script-path function from transcription-config.el
+;;
+;; This function returns the absolute path to the transcription script based on
+;; the current value of cj/transcribe-backend.
+
+;;; Code:
+
+(require 'ert)
+
+;; Add modules directory to load path
+(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory))
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub notification function
+(unless (fboundp 'notifications-notify)
+ (defun notifications-notify (&rest _args)
+ "Stub notification function for testing."
+ nil))
+
+;; Now load the actual production module
+(require 'transcription-config)
+
+;;; Setup and Teardown
+
+(defun test-transcription-script-path-setup ()
+ "Set up test environment."
+ ;; Save original backend setting
+ (setq test-transcription-original-backend cj/transcribe-backend))
+
+(defun test-transcription-script-path-teardown ()
+ "Clean up test environment."
+ ;; Restore original backend setting
+ (setq cj/transcribe-backend test-transcription-original-backend))
+
+;;; Normal Cases
+
+(ert-deftest test-transcription-config--transcription-script-path-normal-openai-api-returns-oai-transcribe ()
+ "Should return oai-transcribe script path for openai-api backend."
+ (test-transcription-script-path-setup)
+ (unwind-protect
+ (progn
+ (setq cj/transcribe-backend 'openai-api)
+ (let ((result (cj/--transcription-script-path)))
+ (should (stringp result))
+ (should (string-suffix-p "scripts/oai-transcribe" result))
+ (should (string-prefix-p (expand-file-name user-emacs-directory) result))))
+ (test-transcription-script-path-teardown)))
+
+(ert-deftest test-transcription-config--transcription-script-path-normal-assemblyai-returns-assemblyai-transcribe ()
+ "Should return assemblyai-transcribe script path for assemblyai backend."
+ (test-transcription-script-path-setup)
+ (unwind-protect
+ (progn
+ (setq cj/transcribe-backend 'assemblyai)
+ (let ((result (cj/--transcription-script-path)))
+ (should (stringp result))
+ (should (string-suffix-p "scripts/assemblyai-transcribe" result))
+ (should (string-prefix-p (expand-file-name user-emacs-directory) result))))
+ (test-transcription-script-path-teardown)))
+
+(ert-deftest test-transcription-config--transcription-script-path-normal-local-whisper-returns-local-whisper ()
+ "Should return local-whisper script path for local-whisper backend."
+ (test-transcription-script-path-setup)
+ (unwind-protect
+ (progn
+ (setq cj/transcribe-backend 'local-whisper)
+ (let ((result (cj/--transcription-script-path)))
+ (should (stringp result))
+ (should (string-suffix-p "scripts/local-whisper" result))
+ (should (string-prefix-p (expand-file-name user-emacs-directory) result))))
+ (test-transcription-script-path-teardown)))
+
+(ert-deftest test-transcription-config--transcription-script-path-normal-returns-absolute-path ()
+ "Should return absolute path starting with user-emacs-directory."
+ (test-transcription-script-path-setup)
+ (unwind-protect
+ (progn
+ (setq cj/transcribe-backend 'openai-api)
+ (let ((result (cj/--transcription-script-path)))
+ (should (file-name-absolute-p result))
+ (should (string-prefix-p "/" result))))
+ (test-transcription-script-path-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-transcription-config--transcription-script-path-boundary-path-format-consistent ()
+ "Should return paths in consistent format across backends."
+ (test-transcription-script-path-setup)
+ (unwind-protect
+ (let (paths)
+ (dolist (backend '(openai-api assemblyai local-whisper))
+ (setq cj/transcribe-backend backend)
+ (push (cj/--transcription-script-path) paths))
+ ;; All paths should have same structure: <emacs-dir>/scripts/<name>
+ (should (= (length paths) 3))
+ (should (seq-every-p (lambda (p) (string-match-p "/scripts/[^/]+$" p)) paths)))
+ (test-transcription-script-path-teardown)))
+
+(provide 'test-transcription-config--transcription-script-path)
+;;; test-transcription-config--transcription-script-path.el ends here
diff --git a/tests/test-transcription-counter.el b/tests/test-transcription-counter.el
new file mode 100644
index 00000000..fae353ba
--- /dev/null
+++ b/tests/test-transcription-counter.el
@@ -0,0 +1,98 @@
+;;; test-transcription-counter.el --- Tests for active transcription counting -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--count-active-transcriptions and modeline integration
+;; Categories: Normal cases, Boundary cases
+
+;;; Code:
+
+(require 'ert)
+(require 'transcription-config)
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-cj/--count-active-transcriptions-empty ()
+ "Test count when no transcriptions are active."
+ (let ((cj/transcriptions-list '()))
+ (should (= 0 (cj/--count-active-transcriptions)))))
+
+(ert-deftest test-cj/--count-active-transcriptions-one-running ()
+ "Test count with one running transcription."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running))))
+ (should (= 1 (cj/--count-active-transcriptions)))))
+
+(ert-deftest test-cj/--count-active-transcriptions-multiple-running ()
+ "Test count with multiple running transcriptions."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running)
+ (proc2 "file2.m4a" nil running)
+ (proc3 "file3.m4a" nil running))))
+ (should (= 3 (cj/--count-active-transcriptions)))))
+
+(ert-deftest test-cj/--count-active-transcriptions-mixed-status ()
+ "Test count excludes completed/errored transcriptions."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running)
+ (proc2 "file2.m4a" nil complete)
+ (proc3 "file3.m4a" nil running)
+ (proc4 "file4.m4a" nil error))))
+ (should (= 2 (cj/--count-active-transcriptions)))))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-cj/--count-active-transcriptions-only-complete ()
+ "Test count when all transcriptions are complete."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil complete)
+ (proc2 "file2.m4a" nil complete))))
+ (should (= 0 (cj/--count-active-transcriptions)))))
+
+(ert-deftest test-cj/--count-active-transcriptions-only-error ()
+ "Test count when all transcriptions errored."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil error)
+ (proc2 "file2.m4a" nil error))))
+ (should (= 0 (cj/--count-active-transcriptions)))))
+
+;; ----------------------------- Modeline Tests --------------------------------
+
+(ert-deftest test-cj/--transcription-modeline-string-none-active ()
+ "Test modeline string when no transcriptions active."
+ (let ((cj/transcriptions-list '()))
+ (should-not (cj/--transcription-modeline-string))))
+
+(ert-deftest test-cj/--transcription-modeline-string-one-active ()
+ "Test modeline string with one active transcription."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running))))
+ (let ((result (cj/--transcription-modeline-string)))
+ (should result)
+ (should (string-match-p "⏺1" result)))))
+
+(ert-deftest test-cj/--transcription-modeline-string-multiple-active ()
+ "Test modeline string with multiple active transcriptions."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running)
+ (proc2 "file2.m4a" nil running)
+ (proc3 "file3.m4a" nil running))))
+ (let ((result (cj/--transcription-modeline-string)))
+ (should result)
+ (should (string-match-p "⏺3" result)))))
+
+(ert-deftest test-cj/--transcription-modeline-string-has-help-echo ()
+ "Test that modeline string has help-echo property."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running))))
+ (let ((result (cj/--transcription-modeline-string)))
+ (should (get-text-property 0 'help-echo result)))))
+
+(ert-deftest test-cj/--transcription-modeline-string-has-face ()
+ "Test that modeline string has warning face."
+ (let ((cj/transcriptions-list
+ '((proc1 "file1.m4a" nil running))))
+ (let ((result (cj/--transcription-modeline-string)))
+ (should (eq 'warning (get-text-property 0 'face result))))))
+
+(provide 'test-transcription-counter)
+;;; test-transcription-counter.el ends here
diff --git a/tests/test-transcription-duration.el b/tests/test-transcription-duration.el
new file mode 100644
index 00000000..370c439b
--- /dev/null
+++ b/tests/test-transcription-duration.el
@@ -0,0 +1,58 @@
+;;; test-transcription-duration.el --- Tests for duration calculation -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--transcription-duration function
+;; Categories: Normal cases, Boundary cases
+
+;;; Code:
+
+(require 'ert)
+(require 'transcription-config)
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-cj/--transcription-duration-zero-seconds ()
+ "Test duration calculation for current time (should be 00:00)."
+ (let ((now (current-time)))
+ (should (string= (cj/--transcription-duration now) "00:00"))))
+
+(ert-deftest test-cj/--transcription-duration-30-seconds ()
+ "Test duration calculation for 30 seconds ago."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 30))))
+ (should (string= (cj/--transcription-duration start-time) "00:30"))))
+
+(ert-deftest test-cj/--transcription-duration-1-minute ()
+ "Test duration calculation for 1 minute ago."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 60))))
+ (should (string= (cj/--transcription-duration start-time) "01:00"))))
+
+(ert-deftest test-cj/--transcription-duration-2-minutes-30-seconds ()
+ "Test duration calculation for 2:30 ago."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 150))))
+ (should (string= (cj/--transcription-duration start-time) "02:30"))))
+
+(ert-deftest test-cj/--transcription-duration-10-minutes ()
+ "Test duration calculation for 10 minutes ago."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 600))))
+ (should (string= (cj/--transcription-duration start-time) "10:00"))))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-cj/--transcription-duration-59-seconds ()
+ "Test duration just before 1 minute."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 59))))
+ (should (string= (cj/--transcription-duration start-time) "00:59"))))
+
+(ert-deftest test-cj/--transcription-duration-1-hour ()
+ "Test duration for 1 hour (60 minutes)."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 3600))))
+ (should (string= (cj/--transcription-duration start-time) "60:00"))))
+
+(ert-deftest test-cj/--transcription-duration-format ()
+ "Test that duration is always in MM:SS format with zero-padding."
+ (let ((start-time (time-subtract (current-time) (seconds-to-time 65))))
+ (let ((result (cj/--transcription-duration start-time)))
+ (should (string-match-p "^[0-9][0-9]:[0-9][0-9]$" result)))))
+
+(provide 'test-transcription-duration)
+;;; test-transcription-duration.el ends here
diff --git a/tests/test-transcription-log-cleanup.el b/tests/test-transcription-log-cleanup.el
new file mode 100644
index 00000000..82c902d8
--- /dev/null
+++ b/tests/test-transcription-log-cleanup.el
@@ -0,0 +1,44 @@
+;;; test-transcription-log-cleanup.el --- Tests for log cleanup logic -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--should-keep-log function
+;; Categories: Normal cases, Boundary cases
+
+;;; Code:
+
+(require 'ert)
+(require 'transcription-config)
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-cj/--should-keep-log-success-keep-disabled ()
+ "Test that logs are deleted on success when keep-log is nil."
+ (let ((cj/transcription-keep-log-when-done nil))
+ (should-not (cj/--should-keep-log t))))
+
+(ert-deftest test-cj/--should-keep-log-success-keep-enabled ()
+ "Test that logs are kept on success when keep-log is t."
+ (let ((cj/transcription-keep-log-when-done t))
+ (should (cj/--should-keep-log t))))
+
+(ert-deftest test-cj/--should-keep-log-error-keep-disabled ()
+ "Test that logs are always kept on error, even if keep-log is nil."
+ (let ((cj/transcription-keep-log-when-done nil))
+ (should (cj/--should-keep-log nil))))
+
+(ert-deftest test-cj/--should-keep-log-error-keep-enabled ()
+ "Test that logs are kept on error when keep-log is t."
+ (let ((cj/transcription-keep-log-when-done t))
+ (should (cj/--should-keep-log nil))))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-cj/--should-keep-log-default-behavior ()
+ "Test default behavior (should not keep on success)."
+ ;; Default is nil based on defcustom
+ (let ((cj/transcription-keep-log-when-done nil))
+ (should-not (cj/--should-keep-log t))
+ (should (cj/--should-keep-log nil))))
+
+(provide 'test-transcription-log-cleanup)
+;;; test-transcription-log-cleanup.el ends here
diff --git a/tests/test-transcription-paths.el b/tests/test-transcription-paths.el
new file mode 100644
index 00000000..5ee80e67
--- /dev/null
+++ b/tests/test-transcription-paths.el
@@ -0,0 +1,80 @@
+;;; test-transcription-paths.el --- Tests for transcription file path logic -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Tests for cj/--transcription-output-files and cj/--transcription-script-path
+;; Categories: Normal cases, Boundary cases, Error cases
+
+;;; Code:
+
+(require 'ert)
+(require 'transcription-config)
+
+;; ----------------------------- Normal Cases ----------------------------------
+
+(ert-deftest test-cj/--transcription-output-files-simple ()
+ "Test output file paths for simple filename."
+ (let ((result (cj/--transcription-output-files "meeting.m4a")))
+ (should (string= (car result) "meeting.txt"))
+ (should (string= (cdr result) "meeting.log"))))
+
+(ert-deftest test-cj/--transcription-output-files-with-path ()
+ "Test output file paths with full path."
+ (let ((result (cj/--transcription-output-files "/home/user/audio/podcast.mp3")))
+ (should (string= (car result) "/home/user/audio/podcast.txt"))
+ (should (string= (cdr result) "/home/user/audio/podcast.log"))))
+
+(ert-deftest test-cj/--transcription-output-files-different-extensions ()
+ "Test output files for various audio extensions."
+ (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg"))
+ (let* ((input (format "audio.%s" ext))
+ (result (cj/--transcription-output-files input)))
+ (should (string= (car result) "audio.txt"))
+ (should (string= (cdr result) "audio.log")))))
+
+;; ----------------------------- Boundary Cases --------------------------------
+
+(ert-deftest test-cj/--transcription-output-files-multiple-dots ()
+ "Test output files for filename with multiple dots."
+ (let ((result (cj/--transcription-output-files "meeting.2025-11-04.final.m4a")))
+ (should (string= (car result) "meeting.2025-11-04.final.txt"))
+ (should (string= (cdr result) "meeting.2025-11-04.final.log"))))
+
+(ert-deftest test-cj/--transcription-output-files-no-extension ()
+ "Test output files for filename without extension."
+ (let ((result (cj/--transcription-output-files "meeting")))
+ (should (string= (car result) "meeting.txt"))
+ (should (string= (cdr result) "meeting.log"))))
+
+(ert-deftest test-cj/--transcription-output-files-spaces-in-name ()
+ "Test output files for filename with spaces."
+ (let ((result (cj/--transcription-output-files "team meeting 2025.m4a")))
+ (should (string= (car result) "team meeting 2025.txt"))
+ (should (string= (cdr result) "team meeting 2025.log"))))
+
+(ert-deftest test-cj/--transcription-output-files-special-chars ()
+ "Test output files for filename with special characters."
+ (let ((result (cj/--transcription-output-files "meeting_(final).m4a")))
+ (should (string= (car result) "meeting_(final).txt"))
+ (should (string= (cdr result) "meeting_(final).log"))))
+
+;; ----------------------------- Script Path Tests -----------------------------
+
+(ert-deftest test-cj/--transcription-script-path-local-whisper ()
+ "Test script path for local-whisper backend."
+ (let ((cj/transcribe-backend 'local-whisper))
+ (should (string-suffix-p "scripts/local-whisper"
+ (cj/--transcription-script-path)))))
+
+(ert-deftest test-cj/--transcription-script-path-openai-api ()
+ "Test script path for openai-api backend."
+ (let ((cj/transcribe-backend 'openai-api))
+ (should (string-suffix-p "scripts/oai-transcribe"
+ (cj/--transcription-script-path)))))
+
+(ert-deftest test-cj/--transcription-script-path-absolute ()
+ "Test that script path is absolute."
+ (let ((path (cj/--transcription-script-path)))
+ (should (file-name-absolute-p path))))
+
+(provide 'test-transcription-paths)
+;;; test-transcription-paths.el ends here
diff --git a/tests/test-video-audio-recording-check-ffmpeg.el b/tests/test-video-audio-recording-check-ffmpeg.el
new file mode 100644
index 00000000..5c264b64
--- /dev/null
+++ b/tests/test-video-audio-recording-check-ffmpeg.el
@@ -0,0 +1,46 @@
+;;; test-video-audio-recording-check-ffmpeg.el --- Tests for cj/recording-check-ffmpeg -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-check-ffmpeg function.
+;; Tests detection of ffmpeg availability.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-check-ffmpeg-normal-ffmpeg-found-returns-t ()
+ "Test that function returns t when ffmpeg is found."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (cmd)
+ (when (equal cmd "ffmpeg") "/usr/bin/ffmpeg"))))
+ (let ((result (cj/recording-check-ffmpeg)))
+ (should (eq t result)))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-check-ffmpeg-error-ffmpeg-not-found-signals-error ()
+ "Test that function signals user-error when ffmpeg is not found."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (_cmd) nil)))
+ (should-error (cj/recording-check-ffmpeg) :type 'user-error)))
+
+(ert-deftest test-video-audio-recording-check-ffmpeg-error-message-mentions-pacman ()
+ "Test that error message includes installation command."
+ (cl-letf (((symbol-function 'executable-find)
+ (lambda (_cmd) nil)))
+ (condition-case err
+ (cj/recording-check-ffmpeg)
+ (user-error
+ (should (string-match-p "pacman -S ffmpeg" (error-message-string err)))))))
+
+(provide 'test-video-audio-recording-check-ffmpeg)
+;;; test-video-audio-recording-check-ffmpeg.el ends here
diff --git a/tests/test-video-audio-recording-detect-mic-device.el b/tests/test-video-audio-recording-detect-mic-device.el
new file mode 100644
index 00000000..e95889e3
--- /dev/null
+++ b/tests/test-video-audio-recording-detect-mic-device.el
@@ -0,0 +1,152 @@
+;;; test-video-audio-recording-detect-mic-device.el --- Tests for cj/recording-detect-mic-device -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-detect-mic-device function.
+;; Tests auto-detection of microphone input device from pactl output.
+;; Mocks shell-command-to-string to test regex matching logic.
+;;
+;; IMPORTANT: These tests document actual behavior, which appears to have a bug.
+;; The function currently returns the pactl ID number (e.g., "50") instead of
+;; the device name (e.g., "alsa_input.pci-0000_00_1f.3.analog-stereo").
+;; This is because the regex captures group 1 is \\([^\t\n]+\\) which stops
+;; at the first tab, capturing only the ID.
+;;
+;; This function may not be actively used (parse-sources is preferred).
+;; Tests document current behavior to catch regressions if function is fixed.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-detect-mic-device-normal-built-in-analog-stereo-found ()
+ "Test detection of built-in analog stereo microphone.
+Note: Returns first match which is the monitor (ID 49), not the input."
+ (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (stringp result))
+ ;; BUG: Returns first match "49" (monitor), not input "50"
+ (should (equal "49" result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-normal-usb-analog-stereo-found ()
+ "Test detection of USB analog stereo microphone.
+Note: Returns ID '100', not device name."
+ (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (stringp result))
+ ;; Current behavior: returns ID "100"
+ (should (equal "100" result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-normal-first-match-returned ()
+ "Test that first matching device is returned when multiple exist.
+Note: Returns first ID, not device name."
+ (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "100\talsa_input.usb-device.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ ;; Current behavior: returns first ID "50"
+ (should (equal "50" result))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-detect-mic-device-boundary-empty-output-returns-nil ()
+ "Test that empty output returns nil."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) "")))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result)))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-boundary-only-monitors-returns-nil ()
+ "Test that output with only monitor devices still matches (documents bug).
+Current regex doesn't exclude monitors, so this returns ID '49'."
+ (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ ;; BUG: Should return nil for monitors, but regex doesn't exclude them
+ (should (equal "49" result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-boundary-mono-fallback-no-match ()
+ "Test that mono-fallback device doesn't match (not stereo)."
+ (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-boundary-bluetooth-no-match ()
+ "Test that Bluetooth devices without 'analog stereo' don't match."
+ (let ((output "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-boundary-whitespace-only-returns-nil ()
+ "Test that whitespace-only output returns nil."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) " \n\t\n ")))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result)))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-boundary-case-insensitive-analog ()
+ "Test that 'ANALOG' (uppercase) matches (case-insensitive regex).
+Documents that regex is actually case-insensitive."
+ (let ((output "50\talsa_input.pci-0000_00_1f.3.ANALOG-STEREO\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ ;; Regex is case-insensitive, matches uppercase
+ (should (equal "50" result))))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-detect-mic-device-error-malformed-output-returns-nil ()
+ "Test that malformed output returns nil."
+ (let ((output "This is not valid pactl output\nRandom text here\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-analog-only ()
+ "Test that 'analog' without 'stereo' doesn't match."
+ (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-mono\tPipeWire\ts32le 1ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-stereo-only ()
+ "Test that 'stereo' without 'analog' doesn't match."
+ (let ((output "50\talsa_input.pci-0000_00_1f.3.digital-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-mic-device-error-monitor-with-analog-stereo-matches-bug ()
+ "Test that monitor device with 'analog stereo' incorrectly matches (documents bug).
+Should return nil for monitors, but current regex doesn't filter them."
+ (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-mic-device)))
+ ;; BUG: Returns ID "49" even though this is a monitor (output device)
+ (should (equal "49" result))))))
+
+(provide 'test-video-audio-recording-detect-mic-device)
+;;; test-video-audio-recording-detect-mic-device.el ends here
diff --git a/tests/test-video-audio-recording-detect-system-device.el b/tests/test-video-audio-recording-detect-system-device.el
new file mode 100644
index 00000000..bea20e8a
--- /dev/null
+++ b/tests/test-video-audio-recording-detect-system-device.el
@@ -0,0 +1,151 @@
+;;; test-video-audio-recording-detect-system-device.el --- Tests for cj/recording-detect-system-device -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-detect-system-device function.
+;; Tests auto-detection of system audio monitor device from pactl output.
+;; Mocks shell-command-to-string to test regex matching logic.
+;;
+;; NOTE: This function works correctly - returns the full device name ending in .monitor.
+;; The regex \\([^\t\n]+\\.monitor\\) matches any non-tab/newline chars ending with .monitor,
+;; which correctly captures the device name field from pactl output.
+;;
+;; This function may not be actively used (parse-sources is preferred).
+;; Tests document current behavior to catch regressions.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-detect-system-device-normal-built-in-monitor-found ()
+ "Test detection of built-in system audio monitor.
+Returns full device name."
+ (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (stringp result))
+ (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-normal-usb-monitor-found ()
+ "Test detection of USB system audio monitor."
+ (let ((output "99\talsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (stringp result))
+ (should (equal "alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-normal-bluetooth-monitor-found ()
+ "Test detection of Bluetooth monitor device."
+ (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (stringp result))
+ (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-normal-first-match-returned ()
+ "Test that first matching monitor is returned when multiple exist."
+ (let ((output (concat "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"
+ "99\talsa_output.usb-device.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ ;; Returns first monitor device name
+ (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-detect-system-device-boundary-empty-output-returns-nil ()
+ "Test that empty output returns nil."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) "")))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (null result)))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-boundary-only-inputs-returns-nil ()
+ "Test that output with only input devices (no monitors) returns nil."
+ (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-boundary-whitespace-only-returns-nil ()
+ "Test that whitespace-only output returns nil."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) " \n\t\n ")))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (null result)))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-boundary-monitor-different-states ()
+ "Test that monitors in different states are all matched."
+ (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ ;; Should match regardless of state (RUNNING, SUSPENDED, IDLE)
+ (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-boundary-case-insensitive-monitor ()
+ "Test that regex is case-insensitive for '.monitor' suffix.
+Documents that .MONITOR (uppercase) also matches."
+ (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ ;; Case-insensitive: .MONITOR matches
+ (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR" result))))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-detect-system-device-error-malformed-output-returns-nil ()
+ "Test that malformed output returns nil."
+ (let ((output "This is not valid pactl output\nRandom text here\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-error-partial-monitor-matches ()
+ "Test that device with .monitor in middle partially matches (documents quirk).
+The regex matches up to first .monitor occurrence, even if not at end of device name."
+ (let ((output "50\talsa_input.monitor-device.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ ;; QUIRK: Matches partial string "alsa_input.monitor"
+ (should (equal "alsa_input.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-error-incomplete-line ()
+ "Test that incomplete lines with .monitor are still matched."
+ (let ((output "49\tincomplete-line.monitor\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ ;; Should match device name ending in .monitor
+ (should (equal "incomplete-line.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-detect-system-device-error-mixed-valid-invalid ()
+ "Test that mix of valid and invalid lines returns first valid monitor."
+ (let ((output (concat "invalid line without tabs\n"
+ "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "another invalid line\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-detect-system-device)))
+ (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result))))))
+
+(provide 'test-video-audio-recording-detect-system-device)
+;;; test-video-audio-recording-detect-system-device.el ends here
diff --git a/tests/test-video-audio-recording-friendly-state.el b/tests/test-video-audio-recording-friendly-state.el
new file mode 100644
index 00000000..91b47998
--- /dev/null
+++ b/tests/test-video-audio-recording-friendly-state.el
@@ -0,0 +1,65 @@
+;;; test-video-audio-recording-friendly-state.el --- Tests for cj/recording-friendly-state -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-friendly-state function.
+;; Tests conversion of technical pactl state names to user-friendly labels.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-friendly-state-normal-suspended-returns-ready ()
+ "Test that SUSPENDED state converts to Ready."
+ (should (string= "Ready" (cj/recording-friendly-state "SUSPENDED"))))
+
+(ert-deftest test-video-audio-recording-friendly-state-normal-running-returns-active ()
+ "Test that RUNNING state converts to Active."
+ (should (string= "Active" (cj/recording-friendly-state "RUNNING"))))
+
+(ert-deftest test-video-audio-recording-friendly-state-normal-idle-returns-ready ()
+ "Test that IDLE state converts to Ready."
+ (should (string= "Ready" (cj/recording-friendly-state "IDLE"))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-friendly-state-boundary-empty-string-returns-empty ()
+ "Test that empty string passes through unchanged."
+ (should (string= "" (cj/recording-friendly-state ""))))
+
+(ert-deftest test-video-audio-recording-friendly-state-boundary-lowercase-suspended-returns-unchanged ()
+ "Test that lowercase 'suspended' is not converted (case-sensitive)."
+ (should (string= "suspended" (cj/recording-friendly-state "suspended"))))
+
+(ert-deftest test-video-audio-recording-friendly-state-boundary-mixed-case-returns-unchanged ()
+ "Test that mixed case 'Running' passes through unchanged."
+ (should (string= "Running" (cj/recording-friendly-state "Running"))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-friendly-state-error-unknown-state-returns-unchanged ()
+ "Test that unknown state passes through unchanged."
+ (should (string= "UNKNOWN" (cj/recording-friendly-state "UNKNOWN"))))
+
+(ert-deftest test-video-audio-recording-friendly-state-error-random-string-returns-unchanged ()
+ "Test that random string passes through unchanged."
+ (should (string= "foobar" (cj/recording-friendly-state "foobar"))))
+
+(ert-deftest test-video-audio-recording-friendly-state-error-numeric-string-returns-unchanged ()
+ "Test that numeric string passes through unchanged."
+ (should (string= "12345" (cj/recording-friendly-state "12345"))))
+
+(ert-deftest test-video-audio-recording-friendly-state-error-special-chars-returns-unchanged ()
+ "Test that string with special characters passes through unchanged."
+ (should (string= "!@#$%" (cj/recording-friendly-state "!@#$%"))))
+
+(provide 'test-video-audio-recording-friendly-state)
+;;; test-video-audio-recording-friendly-state.el ends here
diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el
new file mode 100644
index 00000000..b1b8470b
--- /dev/null
+++ b/tests/test-video-audio-recording-get-devices.el
@@ -0,0 +1,142 @@
+;;; test-video-audio-recording-get-devices.el --- Tests for cj/recording-get-devices -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-get-devices function.
+;; Tests device auto-detection fallback logic.
+;;
+;; Note: This function has interactive prompts, but we test the core logic paths
+;; without mocking y-or-n-p. We focus on testing:
+;; - Already-set devices (no auto-detection needed)
+;; - Successful auto-detection
+;; - Failed auto-detection → error
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-get-devices-setup ()
+ "Reset device variables before each test."
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+(defun test-get-devices-teardown ()
+ "Clean up device variables after each test."
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-get-devices-normal-already-set-returns-devices ()
+ "Test that already-set devices are returned without auto-detection."
+ (test-get-devices-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor")
+ (let ((result (cj/recording-get-devices)))
+ (should (consp result))
+ (should (equal "test-mic" (car result)))
+ (should (equal "test-monitor" (cdr result)))))
+ (test-get-devices-teardown)))
+
+(ert-deftest test-video-audio-recording-get-devices-normal-auto-detect-success ()
+ "Test that auto-detection succeeds and returns devices."
+ (test-get-devices-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-detect-mic-device)
+ (lambda () "auto-detected-mic"))
+ ((symbol-function 'cj/recording-detect-system-device)
+ (lambda () "auto-detected-monitor")))
+ (let ((result (cj/recording-get-devices)))
+ (should (consp result))
+ (should (equal "auto-detected-mic" (car result)))
+ (should (equal "auto-detected-monitor" (cdr result)))
+ ;; Verify variables were set
+ (should (equal "auto-detected-mic" cj/recording-mic-device))
+ (should (equal "auto-detected-monitor" cj/recording-system-device))))
+ (test-get-devices-teardown)))
+
+(ert-deftest test-video-audio-recording-get-devices-normal-partial-auto-detect ()
+ "Test when only one device is already set, only the other is auto-detected."
+ (test-get-devices-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "preset-mic")
+ (cl-letf (((symbol-function 'cj/recording-detect-system-device)
+ (lambda () "auto-detected-monitor")))
+ (let ((result (cj/recording-get-devices)))
+ (should (consp result))
+ (should (equal "preset-mic" (car result)))
+ (should (equal "auto-detected-monitor" (cdr result))))))
+ (test-get-devices-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-get-devices-error-auto-detect-fails-signals-error ()
+ "Test that failed auto-detection signals user-error.
+When auto-detection fails and user doesn't manually select, function errors."
+ (test-get-devices-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-detect-mic-device)
+ (lambda () nil))
+ ((symbol-function 'cj/recording-detect-system-device)
+ (lambda () nil))
+ ;; Mock y-or-n-p to say no to manual selection
+ ((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil)))
+ (should-error (cj/recording-get-devices) :type 'user-error))
+ (test-get-devices-teardown)))
+
+(ert-deftest test-video-audio-recording-get-devices-error-only-mic-detected-signals-error ()
+ "Test that detecting only mic (no monitor) signals error."
+ (test-get-devices-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-detect-mic-device)
+ (lambda () "detected-mic"))
+ ((symbol-function 'cj/recording-detect-system-device)
+ (lambda () nil))
+ ((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil)))
+ (should-error (cj/recording-get-devices) :type 'user-error))
+ (test-get-devices-teardown)))
+
+(ert-deftest test-video-audio-recording-get-devices-error-only-monitor-detected-signals-error ()
+ "Test that detecting only monitor (no mic) signals error."
+ (test-get-devices-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-detect-mic-device)
+ (lambda () nil))
+ ((symbol-function 'cj/recording-detect-system-device)
+ (lambda () "detected-monitor"))
+ ((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil)))
+ (should-error (cj/recording-get-devices) :type 'user-error))
+ (test-get-devices-teardown)))
+
+(ert-deftest test-video-audio-recording-get-devices-error-message-mentions-select-devices ()
+ "Test that error message guides user to manual selection command."
+ (test-get-devices-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-detect-mic-device)
+ (lambda () nil))
+ ((symbol-function 'cj/recording-detect-system-device)
+ (lambda () nil))
+ ((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil)))
+ (condition-case err
+ (cj/recording-get-devices)
+ (user-error
+ (should (string-match-p "cj/recording-select-devices" (error-message-string err))))))
+ (test-get-devices-teardown)))
+
+(provide 'test-video-audio-recording-get-devices)
+;;; test-video-audio-recording-get-devices.el ends here
diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el
new file mode 100644
index 00000000..0abe5f6c
--- /dev/null
+++ b/tests/test-video-audio-recording-group-devices-by-hardware.el
@@ -0,0 +1,194 @@
+;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-group-devices-by-hardware function.
+;; Tests grouping of audio sources by physical hardware device.
+;; Critical test: Bluetooth MAC address normalization (colons vs underscores).
+;;
+;; This function is used by the quick setup command to automatically pair
+;; microphone and monitor devices from the same hardware.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped ()
+ "Test grouping of all three device types (built-in, USB, Bluetooth).
+This is the key test validating the complete grouping logic."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (listp result))
+ (should (= 3 (length result)))
+ ;; Check that we have all three device types
+ (let ((names (mapcar #'car result)))
+ (should (member "Built-in Laptop Audio" names))
+ (should (member "Bluetooth Headset" names))
+ (should (member "Jabra SPEAK 510 USB" names)))
+ ;; Verify each device has both mic and monitor
+ (dolist (device result)
+ (should (stringp (car device))) ; friendly name
+ (should (stringp (cadr device))) ; mic device
+ (should (stringp (cddr device))) ; monitor device
+ (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor
+ (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired ()
+ "Test that built-in laptop audio devices are correctly paired."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let* ((result (cj/recording-group-devices-by-hardware))
+ (built-in (assoc "Built-in Laptop Audio" result)))
+ (should built-in)
+ (should (string-match-p "pci-0000_00_1f" (cadr built-in)))
+ (should (string-match-p "pci-0000_00_1f" (cddr built-in)))
+ (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in)))
+ (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in)))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired ()
+ "Test that USB devices (Jabra) are correctly paired."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let* ((result (cj/recording-group-devices-by-hardware))
+ (jabra (assoc "Jabra SPEAK 510 USB" result)))
+ (should jabra)
+ (should (string-match-p "Jabra" (cadr jabra)))
+ (should (string-match-p "Jabra" (cddr jabra)))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired ()
+ "Test that Bluetooth devices are correctly paired.
+CRITICAL: Tests MAC address normalization (colons in input, underscores in output)."
+ (let ((output (test-load-fixture "pactl-output-normal.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let* ((result (cj/recording-group-devices-by-hardware))
+ (bluetooth (assoc "Bluetooth Headset" result)))
+ (should bluetooth)
+ ;; Input has colons: bluez_input.00:1B:66:C0:91:6D
+ (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth)))
+ ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor
+ ;; But they should still be grouped together (MAC address normalized)
+ (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth)))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty ()
+ "Test that empty pactl output returns empty list."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) "")))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (listp result))
+ (should (null result)))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty ()
+ "Test that only input devices (no monitors) returns empty list.
+Devices must have BOTH mic and monitor to be included."
+ (let ((output (test-load-fixture "pactl-output-inputs-only.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (listp result))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty ()
+ "Test that only monitor devices (no inputs) returns empty list."
+ (let ((output (test-load-fixture "pactl-output-monitors-only.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (listp result))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device ()
+ "Test that single device with both mic and monitor is returned."
+ (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (= 1 (length result)))
+ (should (equal "Built-in Laptop Audio" (caar result)))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete ()
+ "Test that only devices with BOTH mic and monitor are included.
+Incomplete devices (only mic or only monitor) are filtered out."
+ (let ((output (concat
+ ;; Complete device (built-in)
+ "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ ;; Incomplete: USB mic with no monitor
+ "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n"
+ ;; Incomplete: Bluetooth monitor with no mic
+ "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ ;; Only the complete built-in device should be returned
+ (should (= 1 (length result)))
+ (should (equal "Built-in Laptop Audio" (caar result)))))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty ()
+ "Test that malformed pactl output returns empty list."
+ (let ((output (test-load-fixture "pactl-output-malformed.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (listp result))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type ()
+ "Test that unknown device types get generic 'USB Audio Device' name."
+ (let ((output (concat
+ "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"
+ "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (= 1 (length result)))
+ ;; Should get generic USB name (not matching Jabra pattern)
+ (should (equal "USB Audio Device" (caar result)))))))
+
+(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations ()
+ "Test that Bluetooth MAC addresses work with different formatting.
+Tests the normalization logic handles various MAC address formats."
+ (let ((output (concat
+ ;; Input with colons (typical)
+ "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
+ ;; Output with underscores (typical)
+ "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) output)))
+ (let ((result (cj/recording-group-devices-by-hardware)))
+ (should (= 1 (length result)))
+ (should (equal "Bluetooth Headset" (caar result)))
+ ;; Verify both devices paired despite different MAC formats
+ (let ((device (car result)))
+ (should (string-match-p "AA:BB:CC" (cadr device)))
+ (should (string-match-p "AA_BB_CC" (cddr device))))))))
+
+(provide 'test-video-audio-recording-group-devices-by-hardware)
+;;; test-video-audio-recording-group-devices-by-hardware.el ends here
diff --git a/tests/test-video-audio-recording-parse-pactl-output.el b/tests/test-video-audio-recording-parse-pactl-output.el
new file mode 100644
index 00000000..db49a897
--- /dev/null
+++ b/tests/test-video-audio-recording-parse-pactl-output.el
@@ -0,0 +1,157 @@
+;;; test-video-audio-recording-parse-pactl-output.el --- Tests for cj/recording--parse-pactl-output -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording--parse-pactl-output function.
+;; Tests parsing of pactl sources output into structured data.
+;; Uses fixture files with sample pactl output for reproducible testing.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-normal-all-devices-returns-list ()
+ "Test parsing normal pactl output with all device types."
+ (let* ((output (test-load-fixture "pactl-output-normal.txt"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (listp result))
+ (should (= 6 (length result)))
+ ;; Check first device (built-in monitor)
+ (should (equal '("alsa_output.pci-0000_00_1f.3.analog-stereo.monitor"
+ "PipeWire"
+ "SUSPENDED")
+ (nth 0 result)))
+ ;; Check Bluetooth input
+ (should (equal '("bluez_input.00:1B:66:C0:91:6D"
+ "PipeWire"
+ "SUSPENDED")
+ (nth 2 result)))
+ ;; Check USB device
+ (should (equal '("alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback"
+ "PipeWire"
+ "SUSPENDED")
+ (nth 5 result)))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-normal-single-device-returns-list ()
+ "Test parsing output with single device."
+ (let* ((output (test-load-fixture "pactl-output-single.txt"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (listp result))
+ (should (= 1 (length result)))
+ (should (equal '("alsa_input.pci-0000_00_1f.3.analog-stereo"
+ "PipeWire"
+ "SUSPENDED")
+ (car result)))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-normal-monitors-only-returns-list ()
+ "Test parsing output with only monitor devices."
+ (let* ((output (test-load-fixture "pactl-output-monitors-only.txt"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (listp result))
+ (should (= 3 (length result)))
+ ;; All should end with .monitor
+ (dolist (device result)
+ (should (string-suffix-p ".monitor" (car device))))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-normal-inputs-only-returns-list ()
+ "Test parsing output with only input devices."
+ (let* ((output (test-load-fixture "pactl-output-inputs-only.txt"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (listp result))
+ (should (= 3 (length result)))
+ ;; None should end with .monitor
+ (dolist (device result)
+ (should-not (string-suffix-p ".monitor" (car device))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-string-returns-empty-list ()
+ "Test parsing empty string returns empty list."
+ (let ((result (cj/recording--parse-pactl-output "")))
+ (should (listp result))
+ (should (null result))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-file-returns-empty-list ()
+ "Test parsing empty file returns empty list."
+ (let* ((output (test-load-fixture "pactl-output-empty.txt"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (listp result))
+ (should (null result))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-whitespace-only-returns-empty-list ()
+ "Test parsing whitespace-only string returns empty list."
+ (let ((result (cj/recording--parse-pactl-output " \n\t\n ")))
+ (should (listp result))
+ (should (null result))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-single-newline-returns-empty-list ()
+ "Test parsing single newline returns empty list."
+ (let ((result (cj/recording--parse-pactl-output "\n")))
+ (should (listp result))
+ (should (null result))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-running-state-parsed ()
+ "Test that RUNNING state (not just SUSPENDED) is parsed correctly."
+ (let* ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")
+ (result (cj/recording--parse-pactl-output output)))
+ (should (= 1 (length result)))
+ (should (equal "RUNNING" (nth 2 (car result))))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-idle-state-parsed ()
+ "Test that IDLE state is parsed correctly."
+ (let* ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tIDLE\n")
+ (result (cj/recording--parse-pactl-output output)))
+ (should (= 1 (length result)))
+ (should (equal "IDLE" (nth 2 (car result))))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-error-malformed-lines-ignored ()
+ "Test that malformed lines are silently ignored."
+ (let* ((output (test-load-fixture "pactl-output-malformed.txt"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (listp result))
+ (should (null result)))) ; All lines malformed, so empty list
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-error-mixed-valid-invalid-returns-valid ()
+ "Test that mix of valid and invalid lines returns only valid ones."
+ (let* ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "This is invalid\n"
+ "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"
+ "Also invalid\n"))
+ (result (cj/recording--parse-pactl-output output)))
+ (should (= 2 (length result)))
+ (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result))))
+ (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result))))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-error-missing-fields-ignored ()
+ "Test that lines with missing fields are ignored."
+ (let* ((output "50\tincomplete-line\tPipeWire\n") ; Missing state and format
+ (result (cj/recording--parse-pactl-output output)))
+ (should (null result))))
+
+(ert-deftest test-video-audio-recording-parse-pactl-output-error-nil-input-returns-error ()
+ "Test that nil input signals an error."
+ (should-error (cj/recording--parse-pactl-output nil)))
+
+(provide 'test-video-audio-recording-parse-pactl-output)
+;;; test-video-audio-recording-parse-pactl-output.el ends here
diff --git a/tests/test-video-audio-recording-parse-sources.el b/tests/test-video-audio-recording-parse-sources.el
new file mode 100644
index 00000000..d6d445b5
--- /dev/null
+++ b/tests/test-video-audio-recording-parse-sources.el
@@ -0,0 +1,98 @@
+;;; test-video-audio-recording-parse-sources.el --- Tests for cj/recording-parse-sources -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-parse-sources function.
+;; Tests the wrapper that calls pactl and delegates to internal parser.
+;; Mocks shell-command-to-string to avoid system dependencies.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Test Fixtures Helper
+
+(defun test-load-fixture (filename)
+ "Load fixture file FILENAME from tests/fixtures directory."
+ (let ((fixture-path (expand-file-name
+ (concat "tests/fixtures/" filename)
+ user-emacs-directory)))
+ (with-temp-buffer
+ (insert-file-contents fixture-path)
+ (buffer-string))))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-parse-sources-normal-calls-pactl-and-parses ()
+ "Test that parse-sources calls shell command and returns parsed list."
+ (let ((fixture-output (test-load-fixture "pactl-output-normal.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) fixture-output)))
+ (let ((result (cj/recording-parse-sources)))
+ (should (listp result))
+ (should (= 6 (length result)))
+ ;; Verify it returns structured data
+ (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor"
+ (car (nth 0 result))))
+ (should (equal "PipeWire" (nth 1 (nth 0 result))))
+ (should (equal "SUSPENDED" (nth 2 (nth 0 result))))))))
+
+(ert-deftest test-video-audio-recording-parse-sources-normal-single-device-returns-list ()
+ "Test parse-sources with single device."
+ (let ((fixture-output (test-load-fixture "pactl-output-single.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) fixture-output)))
+ (let ((result (cj/recording-parse-sources)))
+ (should (listp result))
+ (should (= 1 (length result)))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-parse-sources-boundary-empty-output-returns-empty-list ()
+ "Test that empty pactl output returns empty list."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) "")))
+ (let ((result (cj/recording-parse-sources)))
+ (should (listp result))
+ (should (null result)))))
+
+(ert-deftest test-video-audio-recording-parse-sources-boundary-whitespace-output-returns-empty-list ()
+ "Test that whitespace-only output returns empty list."
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) " \n\t\n ")))
+ (let ((result (cj/recording-parse-sources)))
+ (should (listp result))
+ (should (null result)))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-parse-sources-error-malformed-output-returns-empty-list ()
+ "Test that malformed output is handled gracefully."
+ (let ((fixture-output (test-load-fixture "pactl-output-malformed.txt")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) fixture-output)))
+ (let ((result (cj/recording-parse-sources)))
+ (should (listp result))
+ (should (null result))))))
+
+(ert-deftest test-video-audio-recording-parse-sources-error-mixed-valid-invalid-returns-valid-only ()
+ "Test that mix of valid and invalid lines returns only valid entries."
+ (let ((mixed-output (concat
+ "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n"
+ "invalid line\n"
+ "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tRUNNING\n")))
+ (cl-letf (((symbol-function 'shell-command-to-string)
+ (lambda (_cmd) mixed-output)))
+ (let ((result (cj/recording-parse-sources)))
+ (should (= 2 (length result)))
+ (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result))))
+ (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result))))))))
+
+(provide 'test-video-audio-recording-parse-sources)
+;;; test-video-audio-recording-parse-sources.el ends here