summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorCraig Jennings <c@cjennings.net>2025-11-11 17:43:34 -0600
committerCraig Jennings <c@cjennings.net>2025-11-11 17:43:34 -0600
commitb07f8fe248db0c9916eccbc249f24d7a9107a3ce (patch)
treef6336d009b589f1840fadac901bf2758563af9aa
parent23b3df60eb619351fada7b83c9646c86e1addbd2 (diff)
a/v recording: fix setup, add test functionality and indicatorlkg
Integrates a modeline indicator to display active recording status in Emacs. The indicator shows "🔴Audio", "🔴Video", or "🔴A+V" based on the active recording processes. Includes functions for starting and stopping audio/video recordings, with sentinel processes ensuring timely updates to the modeline. Also adds extensive integration tests to validate modeline synchronization.
-rw-r--r--modules/modeline-config.el2
-rw-r--r--modules/video-audio-recording.el259
-rw-r--r--tests/test-integration-recording-modeline-sync.el384
-rw-r--r--tests/test-integration-recording-toggle-workflow.el347
-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-ffmpeg-functions.el361
-rw-r--r--tests/test-video-audio-recording-get-devices.el194
-rw-r--r--tests/test-video-audio-recording-modeline-indicator.el134
-rw-r--r--tests/test-video-audio-recording-process-sentinel.el190
-rw-r--r--tests/test-video-audio-recording-quick-setup-for-calls.el144
-rw-r--r--tests/test-video-audio-recording-select-device.el165
-rw-r--r--tests/test-video-audio-recording-test-mic.el147
-rw-r--r--tests/test-video-audio-recording-test-monitor.el148
-rw-r--r--tests/test-video-audio-recording-toggle-functions.el185
15 files changed, 2536 insertions, 427 deletions
diff --git a/modules/modeline-config.el b/modules/modeline-config.el
index a1c85caa..f2b80561 100644
--- a/modules/modeline-config.el
+++ b/modules/modeline-config.el
@@ -155,6 +155,8 @@ Shows only in active window.")
;; RIGHT SIDE (using Emacs 30 built-in right-align)
;; Order: leftmost to rightmost as they appear in the list
mode-line-format-right-align
+ (:eval (when (fboundp 'cj/recording-modeline-indicator)
+ (cj/recording-modeline-indicator)))
cj/modeline-vc-branch
" "
cj/modeline-misc-info
diff --git a/modules/video-audio-recording.el b/modules/video-audio-recording.el
index 45bab267..32399d95 100644
--- a/modules/video-audio-recording.el
+++ b/modules/video-audio-recording.el
@@ -3,12 +3,51 @@
;;
;;; Commentary:
;; Use ffmpeg to record desktop video or just audio.
-;; with audio from mic and audio from default audio sink
+;; Records audio from both microphone and system audio (for calls/meetings).
;; Audio recordings use M4A/AAC format for best compatibility.
;;
;; Note: video-recordings-dir and audio-recordings-dir are defined
;; (and directory created) in user-constants.el
;;
+;; Quick Start
+;; ===========
+;; 1. Press C-; r a (start/stop audio recording)
+;; 2. First time: you'll be prompted for device setup
+;; 3. Choose "Bluetooth Headset" (or your device)
+;; 4. Recording starts - you'll see 🔴Audio in your modeline
+;; 5. Press C-; r a again to stop (🔴 disappears)
+;;
+;; Device Setup (First Time Only)
+;; ===============================
+;; C-; r a automatically prompts for device selection on first use.
+;; Device selection persists across Emacs sessions.
+;;
+;; Manual device selection:
+;;
+;; C-; r c (cj/recording-quick-setup-for-calls) - RECOMMENDED
+;; Quick setup: picks one device for both mic and monitor.
+;; Perfect for calls, meetings, or when using headset.
+;;
+;; C-; r s (cj/recording-select-devices) - ADVANCED
+;; Manual selection: choose mic and monitor separately.
+;; Use when you need different devices for input/output.
+;;
+;; C-; r d (cj/recording-list-devices)
+;; List all available audio devices and current configuration.
+;;
+;; Testing Devices Before Important Recordings
+;; ============================================
+;; Always test devices before important meetings/calls:
+;;
+;; C-; r t b (cj/recording-test-both) - RECOMMENDED
+;; Guided test: mic only, monitor only, then both together.
+;; Catches hardware issues before they ruin recordings!
+;;
+;; C-; r t m (cj/recording-test-mic)
+;; Quick 5-second mic test with playback.
+;;
+;; C-; r t s (cj/recording-test-monitor)
+;; Quick 5-second system audio test with playback.
;;
;; To adjust volumes:
;; - Use =M-x cj/recording-adjust-volumes= (or your keybinding =r l=)
@@ -47,6 +86,36 @@ If nil, will auto-detect on first use.")
(defvar cj/audio-recording-ffmpeg-process nil
"Variable to store the process of the ffmpeg audio recording.")
+;; Modeline recording indicator
+(defun cj/recording-modeline-indicator ()
+ "Return modeline string showing active recordings.
+Shows 🔴 when recording (audio and/or video).
+Checks if process is actually alive, not just if variable is set."
+ (let ((audio-active (and cj/audio-recording-ffmpeg-process
+ (process-live-p cj/audio-recording-ffmpeg-process)))
+ (video-active (and cj/video-recording-ffmpeg-process
+ (process-live-p cj/video-recording-ffmpeg-process))))
+ (cond
+ ((and audio-active video-active) " 🔴A+V ")
+ (audio-active " 🔴Audio ")
+ (video-active " 🔴Video ")
+ (t ""))))
+
+(defun cj/recording-process-sentinel (process event)
+ "Sentinel for recording processes to clean up and update modeline.
+PROCESS is the ffmpeg process, EVENT describes what happened."
+ (when (memq (process-status process) '(exit signal))
+ ;; Process ended - clear the variable
+ (cond
+ ((eq process cj/audio-recording-ffmpeg-process)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (message "Audio recording stopped: %s" (string-trim event)))
+ ((eq process cj/video-recording-ffmpeg-process)
+ (setq cj/video-recording-ffmpeg-process nil)
+ (message "Video recording stopped: %s" (string-trim event))))
+ ;; Force modeline update
+ (force-mode-line-update t)))
+
(defun cj/recording-check-ffmpeg ()
"Check if ffmpeg is available.
Return t if found, nil otherwise."
@@ -55,19 +124,10 @@ Return t if found, nil otherwise."
nil)
t)
-(defun cj/recording-detect-mic-device ()
- "Auto-detect PulseAudio microphone input device.
-Returns device name or nil if not found."
- (let ((output (shell-command-to-string "pactl list sources short 2>/dev/null")))
- (when (string-match "\\([^\t\n]+\\).*analog.*stereo" output)
- (match-string 1 output))))
-
-(defun cj/recording-detect-system-device ()
- "Auto-detect PulseAudio system audio monitor device.
-Returns device name or nil if not found."
- (let ((output (shell-command-to-string "pactl list sources short 2>/dev/null")))
- (when (string-match "\\([^\t\n]+\\.monitor\\)" output)
- (match-string 1 output))))
+;; Auto-detection functions removed - they were unreliable and preferred built-in
+;; audio over Bluetooth/USB devices. Use explicit device selection instead:
+;; - C-; r c (cj/recording-quick-setup-for-calls) - recommended for most use cases
+;; - C-; r s (cj/recording-select-devices) - manual selection of mic + monitor
(defun cj/recording--parse-pactl-output (output)
"Internal parser for pactl sources output. Takes OUTPUT string.
@@ -223,51 +283,139 @@ Perfect for recording video calls, phone calls, or presentations."
(file-name-nondirectory mic)
(file-name-nondirectory monitor))))))
-(defun cj/recording-get-devices ()
- "Get or auto-detect audio devices.
-Returns (mic-device . system-device) or nil on error."
- ;; Auto-detect if not already set
+(defun cj/recording-test-mic ()
+ "Test microphone by recording 5 seconds and playing it back.
+Records from configured mic device, saves to temp file, plays back.
+Useful for verifying mic hardware works before important recordings."
+ (interactive)
(unless cj/recording-mic-device
- (setq cj/recording-mic-device (cj/recording-detect-mic-device)))
+ (user-error "No microphone configured. Run C-; r c first"))
+
+ (let* ((temp-file (make-temp-file "mic-test-" nil ".wav"))
+ (duration 5))
+ (message "Recording from mic for %d seconds... SPEAK NOW!" duration)
+ (shell-command
+ (format "ffmpeg -f pulse -i %s -t %d -y %s 2>/dev/null"
+ (shell-quote-argument cj/recording-mic-device)
+ duration
+ (shell-quote-argument temp-file)))
+ (message "Playing back recording...")
+ (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &"
+ (shell-quote-argument temp-file)))
+ (message "Mic test complete. Temp file: %s" temp-file)))
+
+(defun cj/recording-test-monitor ()
+ "Test system audio monitor by recording 5 seconds and playing it back.
+Records from configured monitor device (system audio output).
+Play some audio/video during test. Useful for verifying you can capture
+conference call audio, YouTube, etc."
+ (interactive)
(unless cj/recording-system-device
- (setq cj/recording-system-device (cj/recording-detect-system-device)))
+ (user-error "No system monitor configured. Run C-; r c first"))
+
+ (let* ((temp-file (make-temp-file "monitor-test-" nil ".wav"))
+ (duration 5))
+ (message "Recording system audio for %d seconds... PLAY SOMETHING NOW!" duration)
+ (shell-command
+ (format "ffmpeg -f pulse -i %s -t %d -y %s 2>/dev/null"
+ (shell-quote-argument cj/recording-system-device)
+ duration
+ (shell-quote-argument temp-file)))
+ (message "Playing back recording...")
+ (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &"
+ (shell-quote-argument temp-file)))
+ (message "Monitor test complete. Temp file: %s" temp-file)))
+
+(defun cj/recording-test-both ()
+ "Test both mic and monitor together with guided prompts.
+This simulates a real recording scenario:
+1. Tests mic only (speak into it)
+2. Tests monitor only (play audio/video)
+3. Tests both together (speak while audio plays)
+
+Run this before important recordings to verify everything works!"
+ (interactive)
+ (unless (and cj/recording-mic-device cj/recording-system-device)
+ (user-error "Devices not configured. Run C-; r c first"))
+
+ (when (y-or-n-p "Test 1: Record from MICROPHONE only (5 sec). Ready? ")
+ (cj/recording-test-mic)
+ (sit-for 6)) ; Wait for playback
+
+ (when (y-or-n-p "Test 2: Record from SYSTEM AUDIO only (5 sec). Start playing audio/video, then press y: ")
+ (cj/recording-test-monitor)
+ (sit-for 6)) ; Wait for playback
+
+ (when (y-or-n-p "Test 3: Record BOTH mic + system audio (5 sec). Speak while audio plays, then press y: ")
+ (let* ((temp-file (make-temp-file "both-test-" nil ".wav"))
+ (duration 5))
+ (message "Recording BOTH for %d seconds... SPEAK + PLAY AUDIO NOW!" duration)
+ (shell-command
+ (format "ffmpeg -f pulse -i %s -f pulse -i %s -filter_complex \"[0:a]volume=%.1f[mic];[1:a]volume=%.1f[sys];[mic][sys]amix=inputs=2:duration=longest\" -t %d -y %s 2>/dev/null"
+ (shell-quote-argument cj/recording-mic-device)
+ (shell-quote-argument cj/recording-system-device)
+ cj/recording-mic-boost
+ cj/recording-system-volume
+ duration
+ (shell-quote-argument temp-file)))
+ (message "Playing back recording...")
+ (shell-command (format "ffplay -autoexit -nodisp %s 2>/dev/null &"
+ (shell-quote-argument temp-file)))
+ (sit-for 6)
+ (message "All tests complete! Temp file: %s" temp-file)))
+
+ (message "Device testing complete. If you heard audio in all tests, recording will work!"))
- ;; If auto-detection failed, prompt user to select
+(defun cj/recording-get-devices ()
+ "Get audio devices, prompting user if not already configured.
+Returns (mic-device . system-device) or nil on error."
+ ;; If devices not set, prompt user to select them
(unless (and cj/recording-mic-device cj/recording-system-device)
- (when (y-or-n-p "Could not auto-detect audio devices. Select manually? ")
+ (if (y-or-n-p "Audio devices not configured. Use quick setup for calls? ")
+ (cj/recording-quick-setup-for-calls)
(cj/recording-select-devices)))
;; Final validation
(unless (and cj/recording-mic-device cj/recording-system-device)
- (user-error "Audio devices not configured. Run M-x cj/recording-select-devices"))
+ (user-error "Audio devices not configured. Run C-; r c (quick setup) or C-; r s (manual select)"))
(cons cj/recording-mic-device cj/recording-system-device))
-(defun cj/video-recording-start (arg)
- "Start the ffmpeg video recording.
+(defun cj/video-recording-toggle (arg)
+ "Toggle video recording: start if not recording, stop if recording.
+On first use (or when devices not configured), runs quick setup (C-; r c).
With prefix ARG, prompt for recording location.
Otherwise use the default location in `video-recordings-dir'."
(interactive "P")
- (let* ((location (if arg
- (read-directory-name "Enter recording location: ")
- video-recordings-dir))
- (directory (file-name-directory location)))
- (unless (file-directory-p directory)
- (make-directory directory t))
- (cj/ffmpeg-record-video location)))
-
-(defun cj/audio-recording-start (arg)
- "Start the ffmpeg audio recording.
+ (if cj/video-recording-ffmpeg-process
+ ;; Recording in progress - stop it
+ (cj/video-recording-stop)
+ ;; Not recording - start it
+ (let* ((location (if arg
+ (read-directory-name "Enter recording location: ")
+ video-recordings-dir))
+ (directory (file-name-directory location)))
+ (unless (file-directory-p directory)
+ (make-directory directory t))
+ (cj/ffmpeg-record-video location))))
+
+(defun cj/audio-recording-toggle (arg)
+ "Toggle audio recording: start if not recording, stop if recording.
+On first use (or when devices not configured), runs quick setup (C-; r c).
With prefix ARG, prompt for recording location.
Otherwise use the default location in `audio-recordings-dir'."
(interactive "P")
- (let* ((location (if arg
- (read-directory-name "Enter recording location: ")
- audio-recordings-dir))
- (directory (file-name-directory location)))
- (unless (file-directory-p directory)
- (make-directory directory t))
- (cj/ffmpeg-record-audio location)))
+ (if cj/audio-recording-ffmpeg-process
+ ;; Recording in progress - stop it
+ (cj/audio-recording-stop)
+ ;; Not recording - start it
+ (let* ((location (if arg
+ (read-directory-name "Enter recording location: ")
+ audio-recordings-dir))
+ (directory (file-name-directory location)))
+ (unless (file-directory-p directory)
+ (make-directory directory t))
+ (cj/ffmpeg-record-audio location))))
(defun cj/ffmpeg-record-video (directory)
"Start an ffmpeg video recording. Save output to DIRECTORY."
@@ -299,6 +447,8 @@ Otherwise use the default location in `audio-recordings-dir'."
"*ffmpeg-video-recording*"
ffmpeg-command))
(set-process-query-on-exit-flag cj/video-recording-ffmpeg-process nil)
+ (set-process-sentinel cj/video-recording-ffmpeg-process #'cj/recording-process-sentinel)
+ (force-mode-line-update t)
(message "Started video recording to %s (mic: %.1fx, system: %.1fx)."
filename cj/recording-mic-boost cj/recording-system-volume))))
@@ -333,6 +483,8 @@ Otherwise use the default location in `audio-recordings-dir'."
"*ffmpeg-audio-recording*"
ffmpeg-command))
(set-process-query-on-exit-flag cj/audio-recording-ffmpeg-process nil)
+ (set-process-sentinel cj/audio-recording-ffmpeg-process #'cj/recording-process-sentinel)
+ (force-mode-line-update t)
(message "Started audio recording to %s (mic: %.1fx, system: %.1fx)."
filename cj/recording-mic-boost cj/recording-system-volume))))
@@ -346,6 +498,7 @@ Otherwise use the default location in `audio-recordings-dir'."
;; Give ffmpeg a moment to finalize the file
(sit-for 0.2)
(setq cj/video-recording-ffmpeg-process nil)
+ (force-mode-line-update t)
(message "Stopped video recording."))
(message "No video recording in progress.")))
@@ -359,6 +512,7 @@ Otherwise use the default location in `audio-recordings-dir'."
;; Give ffmpeg a moment to finalize the file
(sit-for 0.2)
(setq cj/audio-recording-ffmpeg-process nil)
+ (force-mode-line-update t)
(message "Stopped audio recording."))
(message "No audio recording in progress.")))
@@ -376,14 +530,15 @@ Otherwise use the default location in `audio-recordings-dir'."
;; Recording operations prefix and keymap
(defvar cj/record-map
(let ((map (make-sparse-keymap)))
- (define-key map (kbd "V") #'cj/video-recording-stop)
- (define-key map (kbd "v") #'cj/video-recording-start)
- (define-key map (kbd "A") #'cj/audio-recording-stop)
- (define-key map (kbd "a") #'cj/audio-recording-start)
+ (define-key map (kbd "v") #'cj/video-recording-toggle)
+ (define-key map (kbd "a") #'cj/audio-recording-toggle)
(define-key map (kbd "l") #'cj/recording-adjust-volumes)
(define-key map (kbd "d") #'cj/recording-list-devices)
(define-key map (kbd "s") #'cj/recording-select-devices)
(define-key map (kbd "c") #'cj/recording-quick-setup-for-calls)
+ (define-key map (kbd "t m") #'cj/recording-test-mic)
+ (define-key map (kbd "t s") #'cj/recording-test-monitor)
+ (define-key map (kbd "t b") #'cj/recording-test-both)
map)
"Keymap for video/audio recording operations.")
@@ -392,14 +547,16 @@ Otherwise use the default location in `audio-recordings-dir'."
(with-eval-after-load 'which-key
(which-key-add-key-based-replacements
"C-; r" "recording menu"
- "C-; r v" "start video"
- "C-; r V" "stop video"
- "C-; r a" "start audio"
- "C-; r A" "stop audio"
+ "C-; r v" "toggle video recording"
+ "C-; r a" "toggle audio recording"
"C-; r l" "adjust levels"
"C-; r d" "list devices"
"C-; r s" "select devices"
- "C-; r c" "quick setup for calls"))
+ "C-; r c" "quick setup for calls"
+ "C-; r t" "test devices"
+ "C-; r t m" "test microphone"
+ "C-; r t s" "test system audio"
+ "C-; r t b" "test both (guided)"))
(provide 'video-audio-recording)
;;; video-audio-recording.el ends here.
diff --git a/tests/test-integration-recording-modeline-sync.el b/tests/test-integration-recording-modeline-sync.el
new file mode 100644
index 00000000..fab442bd
--- /dev/null
+++ b/tests/test-integration-recording-modeline-sync.el
@@ -0,0 +1,384 @@
+;;; test-integration-recording-modeline-sync.el --- Integration tests for modeline sync -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests validating that the modeline indicator NEVER desyncs
+;; from the actual recording state throughout the entire toggle lifecycle.
+;;
+;; This tests the critical requirement: modeline must always accurately
+;; reflect whether recording is happening, with NO desyncs.
+;;
+;; Components integrated:
+;; - cj/audio-recording-toggle (state changes)
+;; - cj/video-recording-toggle (state changes)
+;; - cj/recording-modeline-indicator (UI state display)
+;; - cj/ffmpeg-record-audio (process creation)
+;; - cj/ffmpeg-record-video (process creation)
+;; - cj/recording-process-sentinel (auto-updates modeline)
+;; - cj/audio-recording-stop (cleanup triggers update)
+;; - cj/video-recording-stop (cleanup triggers update)
+;; - force-mode-line-update (explicit refresh calls)
+;;
+;; Validates:
+;; - Modeline updates immediately on toggle start
+;; - Modeline updates immediately on toggle stop
+;; - Modeline updates when sentinel runs (process dies)
+;; - Modeline shows correct state for audio, video, or both
+;; - Modeline never shows stale state
+;; - process-live-p check prevents desync on dead processes
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub directory variables
+(defvar video-recordings-dir "/tmp/video-recordings/")
+(defvar audio-recordings-dir "/tmp/audio-recordings/")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-integration-modeline-setup ()
+ "Reset all variables before each test."
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor"))
+
+(defun test-integration-modeline-teardown ()
+ "Clean up after each test."
+ (when cj/video-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/video-recording-ffmpeg-process)))
+ (when cj/audio-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/audio-recording-ffmpeg-process)))
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+;;; Integration Tests - Modeline Sync on Toggle
+
+(ert-deftest test-integration-recording-modeline-sync-audio-start-updates-immediately ()
+ "Test that modeline updates immediately when audio recording starts.
+
+When user toggles audio recording on:
+1. Process is created
+2. Modeline indicator updates to show 🔴Audio
+3. State is in sync immediately (not delayed)
+
+Components integrated:
+- cj/audio-recording-toggle
+- cj/ffmpeg-record-audio (calls force-mode-line-update)
+- cj/recording-modeline-indicator
+
+Validates:
+- Modeline syncs on start
+- No delay or race condition"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; Before toggle: no recording
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; Toggle on
+ (cj/audio-recording-toggle nil)
+
+ ;; Immediately after toggle: modeline should show recording
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; Process should be alive
+ (should (process-live-p cj/audio-recording-ffmpeg-process)))
+ (test-integration-modeline-teardown)))
+
+(ert-deftest test-integration-recording-modeline-sync-audio-stop-updates-immediately ()
+ "Test that modeline updates immediately when audio recording stops.
+
+When user toggles audio recording off:
+1. Process is interrupted
+2. Variable is cleared
+3. Modeline indicator updates to show empty
+4. State is in sync immediately
+
+Components integrated:
+- cj/audio-recording-toggle (stop path)
+- cj/audio-recording-stop (calls force-mode-line-update)
+- cj/recording-modeline-indicator
+
+Validates:
+- Modeline syncs on stop
+- No stale indicator after stop"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; Start recording
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; Stop recording
+ (cj/audio-recording-toggle nil)
+
+ ;; Immediately after stop: modeline should be empty
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; Process should be nil
+ (should (null cj/audio-recording-ffmpeg-process)))
+ (test-integration-modeline-teardown)))
+
+(ert-deftest test-integration-recording-modeline-sync-video-lifecycle ()
+ "Test modeline sync through complete video recording lifecycle.
+
+Components integrated:
+- cj/video-recording-toggle (both start and stop)
+- cj/ffmpeg-record-video
+- cj/video-recording-stop
+- cj/recording-modeline-indicator
+
+Validates:
+- Video recording follows same sync pattern as audio
+- Modeline shows 🔴Video correctly"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; Initial state
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; Start video
+ (cj/video-recording-toggle nil)
+ (should (equal " 🔴Video " (cj/recording-modeline-indicator)))
+
+ ;; Stop video
+ (cj/video-recording-toggle nil)
+ (should (equal "" (cj/recording-modeline-indicator))))
+ (test-integration-modeline-teardown)))
+
+;;; Integration Tests - Modeline Sync with Both Recordings
+
+(ert-deftest test-integration-recording-modeline-sync-both-recordings-transitions ()
+ "Test modeline sync through all possible state transitions.
+
+Tests transitions:
+- none → audio → both → video → none
+- Validates modeline updates at every transition
+
+Components integrated:
+- cj/audio-recording-toggle
+- cj/video-recording-toggle
+- cj/recording-modeline-indicator (handles all states)
+
+Validates:
+- Modeline accurately reflects all combinations
+- Transitions are clean with no stale state"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; State 1: None
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; State 2: Audio only
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; State 3: Both
+ (cj/video-recording-toggle nil)
+ (should (equal " 🔴A+V " (cj/recording-modeline-indicator)))
+
+ ;; State 4: Video only (stop audio)
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴Video " (cj/recording-modeline-indicator)))
+
+ ;; State 5: None (stop video)
+ (cj/video-recording-toggle nil)
+ (should (equal "" (cj/recording-modeline-indicator))))
+ (test-integration-modeline-teardown)))
+
+;;; Integration Tests - Modeline Sync with Sentinel
+
+(ert-deftest test-integration-recording-modeline-sync-sentinel-updates-on-crash ()
+ "Test that modeline syncs when process dies and sentinel runs.
+
+When recording process crashes:
+1. Sentinel detects process death
+2. Sentinel clears variable
+3. Sentinel calls force-mode-line-update
+4. Modeline indicator shows no recording
+
+Components integrated:
+- cj/ffmpeg-record-audio (attaches sentinel)
+- cj/recording-process-sentinel (cleanup + modeline update)
+- cj/recording-modeline-indicator
+
+Validates:
+- Sentinel updates modeline on process death
+- Modeline syncs automatically without user action
+- Critical: prevents desync when process crashes"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ ;; Create process that exits immediately
+ (make-process :name name :command '("sh" "-c" "exit 1")))))
+
+ ;; Start recording
+ (cj/audio-recording-toggle nil)
+
+ ;; Immediately after start: should show recording
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; Wait for process to exit and sentinel to run
+ (sit-for 0.3)
+
+ ;; After sentinel runs: modeline should be clear
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; Variable should be nil
+ (should (null cj/audio-recording-ffmpeg-process)))
+ (test-integration-modeline-teardown)))
+
+(ert-deftest test-integration-recording-modeline-sync-dead-process-not-shown ()
+ "Test that modeline never shows dead process as recording.
+
+The modeline indicator uses process-live-p to check if process is ACTUALLY
+alive, not just if the variable is set. This prevents desync.
+
+Components integrated:
+- cj/recording-modeline-indicator (uses process-live-p)
+
+Validates:
+- Dead process doesn't show as recording
+- process-live-p check prevents desync
+- Critical: if variable is set but process is dead, shows empty"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (let ((dead-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0"))))
+ ;; Set variable to dead process (simulating race condition)
+ (setq cj/audio-recording-ffmpeg-process dead-process)
+
+ ;; Wait for process to die
+ (sit-for 0.1)
+
+ ;; Modeline should NOT show recording (process is dead)
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; Even though variable is set
+ (should (eq dead-process cj/audio-recording-ffmpeg-process))
+
+ ;; Process is dead
+ (should-not (process-live-p dead-process)))
+ (test-integration-modeline-teardown)))
+
+;;; Integration Tests - Modeline Sync Under Rapid Toggling
+
+(ert-deftest test-integration-recording-modeline-sync-rapid-toggle-stays-synced ()
+ "Test modeline stays synced under rapid start/stop toggling.
+
+When user rapidly toggles recording on and off:
+- Modeline should stay in sync at every step
+- No race conditions or stale state
+
+Components integrated:
+- cj/audio-recording-toggle (rapid calls)
+- cj/ffmpeg-record-audio
+- cj/audio-recording-stop
+- cj/recording-modeline-indicator
+
+Validates:
+- Modeline syncs even with rapid state changes
+- No race conditions in update logic"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; Rapid toggling
+ (dotimes (_i 5)
+ ;; Start
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+ (should cj/audio-recording-ffmpeg-process)
+
+ ;; Stop
+ (cj/audio-recording-toggle nil)
+ (should (equal "" (cj/recording-modeline-indicator)))
+ (should (null cj/audio-recording-ffmpeg-process))))
+ (test-integration-modeline-teardown)))
+
+(ert-deftest test-integration-recording-modeline-sync-both-recordings-independent ()
+ "Test that audio and video modeline updates are independent.
+
+When one recording stops, the other's indicator persists.
+When one recording starts, both indicators combine correctly.
+
+Components integrated:
+- cj/audio-recording-toggle
+- cj/video-recording-toggle
+- cj/recording-modeline-indicator (combines states)
+
+Validates:
+- Independent recordings don't interfere
+- Modeline correctly shows: audio-only, video-only, or both
+- Stopping one doesn't affect other's indicator"
+ (test-integration-modeline-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; Start audio
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; Add video - modeline should combine
+ (cj/video-recording-toggle nil)
+ (should (equal " 🔴A+V " (cj/recording-modeline-indicator)))
+
+ ;; Stop audio - video indicator should persist
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴Video " (cj/recording-modeline-indicator)))
+
+ ;; Start audio again - should recombine
+ (cj/audio-recording-toggle nil)
+ (should (equal " 🔴A+V " (cj/recording-modeline-indicator)))
+
+ ;; Stop video - audio indicator should persist
+ (cj/video-recording-toggle nil)
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; Stop audio - should be empty
+ (cj/audio-recording-toggle nil)
+ (should (equal "" (cj/recording-modeline-indicator))))
+ (test-integration-modeline-teardown)))
+
+(provide 'test-integration-recording-modeline-sync)
+;;; test-integration-recording-modeline-sync.el ends here
diff --git a/tests/test-integration-recording-toggle-workflow.el b/tests/test-integration-recording-toggle-workflow.el
new file mode 100644
index 00000000..c61698c5
--- /dev/null
+++ b/tests/test-integration-recording-toggle-workflow.el
@@ -0,0 +1,347 @@
+;;; test-integration-recording-toggle-workflow.el --- Integration tests for recording toggle workflow -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Integration tests covering the complete recording toggle workflow from
+;; user action through device setup, recording, and cleanup.
+;;
+;; This tests the ACTUAL user workflow: Press C-; r a → setup → record → stop → cleanup
+;;
+;; Components integrated:
+;; - cj/audio-recording-toggle (entry point)
+;; - cj/video-recording-toggle (entry point)
+;; - cj/recording-get-devices (device prompting and setup)
+;; - cj/recording-quick-setup-for-calls (device selection workflow)
+;; - cj/ffmpeg-record-audio (process creation and ffmpeg command)
+;; - cj/ffmpeg-record-video (process creation and ffmpeg command)
+;; - cj/recording-modeline-indicator (UI state display)
+;; - cj/audio-recording-stop (cleanup)
+;; - cj/video-recording-stop (cleanup)
+;; - cj/recording-process-sentinel (auto-cleanup on process death)
+;;
+;; Validates:
+;; - Complete workflow from toggle to cleanup
+;; - Device setup on first use
+;; - Process creation and management
+;; - Modeline updates at each step
+;; - Cleanup on user stop
+;; - Auto-cleanup when process dies
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub directory variables
+(defvar video-recordings-dir "/tmp/video-recordings/")
+(defvar audio-recordings-dir "/tmp/audio-recordings/")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-integration-toggle-setup ()
+ "Reset all variables before each test."
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+(defun test-integration-toggle-teardown ()
+ "Clean up after each test."
+ (when cj/video-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/video-recording-ffmpeg-process)))
+ (when cj/audio-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/audio-recording-ffmpeg-process)))
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+;;; Integration Tests - Audio Recording Workflow
+
+(ert-deftest test-integration-recording-toggle-workflow-audio-first-use-full-cycle ()
+ "Test complete audio recording workflow from first use through cleanup.
+
+When user presses C-; r a for the first time:
+1. Device setup prompt appears (no devices configured)
+2. User chooses quick setup
+3. Devices are selected and saved
+4. Recording starts with correct ffmpeg command
+5. Process is created and sentinel attached
+6. Modeline shows recording indicator
+7. User presses C-; r a again to stop
+8. Recording stops gracefully
+9. Modeline indicator clears
+
+Components integrated:
+- cj/audio-recording-toggle (toggles start/stop)
+- cj/recording-get-devices (prompts for setup on first use)
+- cj/recording-quick-setup-for-calls (device selection)
+- cj/ffmpeg-record-audio (creates recording process)
+- cj/recording-modeline-indicator (UI state)
+- cj/audio-recording-stop (cleanup)
+
+Validates:
+- Full user workflow from first use to stop
+- Device setup on first toggle
+- Recording starts after setup
+- Modeline updates correctly
+- Stop works after recording"
+ (test-integration-toggle-setup)
+ (unwind-protect
+ (let ((setup-called nil)
+ (ffmpeg-cmd nil)
+ (process-created nil))
+ ;; Mock the device setup to simulate user choosing quick setup
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) t)) ; User says yes to quick setup
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (setq setup-called t)
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor")))
+ ((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer cmd)
+ (setq process-created t)
+ (setq ffmpeg-cmd cmd)
+ (make-process :name "fake-audio" :command '("sleep" "1000")))))
+
+ ;; STEP 1: First toggle - should trigger device setup
+ (cj/audio-recording-toggle nil)
+
+ ;; Verify setup was called
+ (should setup-called)
+
+ ;; Verify devices were set
+ (should (equal "test-mic" cj/recording-mic-device))
+ (should (equal "test-monitor" cj/recording-system-device))
+
+ ;; Verify recording started
+ (should process-created)
+ (should cj/audio-recording-ffmpeg-process)
+ (should (string-match-p "ffmpeg" ffmpeg-cmd))
+ (should (string-match-p "test-mic" ffmpeg-cmd))
+ (should (string-match-p "test-monitor" ffmpeg-cmd))
+
+ ;; Verify modeline shows recording
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; STEP 2: Second toggle - should stop recording
+ (cj/audio-recording-toggle nil)
+
+ ;; Verify recording stopped
+ (should (null cj/audio-recording-ffmpeg-process))
+
+ ;; Verify modeline cleared
+ (should (equal "" (cj/recording-modeline-indicator)))))
+ (test-integration-toggle-teardown)))
+
+(ert-deftest test-integration-recording-toggle-workflow-audio-subsequent-use-no-setup ()
+ "Test that subsequent audio recordings skip device setup.
+
+After devices are configured, pressing C-; r a should:
+1. Skip device setup (already configured)
+2. Start recording immediately
+3. Use previously configured devices
+
+Components integrated:
+- cj/audio-recording-toggle
+- cj/recording-get-devices (returns cached devices)
+- cj/ffmpeg-record-audio (uses cached devices)
+
+Validates:
+- Device setup is cached across recordings
+- Second recording doesn't prompt
+- Same devices are used"
+ (test-integration-toggle-setup)
+ (unwind-protect
+ (progn
+ ;; Pre-configure devices (simulating previous setup)
+ (setq cj/recording-mic-device "cached-mic")
+ (setq cj/recording-system-device "cached-monitor")
+
+ (let ((setup-called nil)
+ (ffmpeg-cmd nil))
+ (cl-letf (((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda () (setq setup-called t)))
+ ((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer cmd)
+ (setq ffmpeg-cmd cmd)
+ (make-process :name "fake-audio" :command '("sleep" "1000")))))
+
+ ;; Toggle to start recording
+ (cj/audio-recording-toggle nil)
+
+ ;; Setup should NOT be called
+ (should-not setup-called)
+
+ ;; Should use cached devices
+ (should (string-match-p "cached-mic" ffmpeg-cmd))
+ (should (string-match-p "cached-monitor" ffmpeg-cmd)))))
+ (test-integration-toggle-teardown)))
+
+;;; Integration Tests - Video Recording Workflow
+
+(ert-deftest test-integration-recording-toggle-workflow-video-full-cycle ()
+ "Test complete video recording workflow.
+
+Components integrated:
+- cj/video-recording-toggle
+- cj/recording-get-devices
+- cj/ffmpeg-record-video
+- cj/recording-modeline-indicator
+- cj/video-recording-stop
+
+Validates:
+- Video recording follows same workflow as audio
+- Modeline shows video indicator
+- Toggle works for video"
+ (test-integration-toggle-setup)
+ (unwind-protect
+ (let ((setup-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (setq setup-called t)
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor")))
+ ((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _cmd)
+ (make-process :name "fake-video" :command '("sleep" "1000")))))
+
+ ;; Start video recording
+ (cj/video-recording-toggle nil)
+
+ ;; Verify setup and recording
+ (should setup-called)
+ (should cj/video-recording-ffmpeg-process)
+ (should (equal " 🔴Video " (cj/recording-modeline-indicator)))
+
+ ;; Stop recording
+ (cj/video-recording-toggle nil)
+
+ ;; Verify cleanup
+ (should (null cj/video-recording-ffmpeg-process))
+ (should (equal "" (cj/recording-modeline-indicator)))))
+ (test-integration-toggle-teardown)))
+
+;;; Integration Tests - Both Recordings Simultaneously
+
+(ert-deftest test-integration-recording-toggle-workflow-both-simultaneous ()
+ "Test that both audio and video can record simultaneously.
+
+Components integrated:
+- cj/audio-recording-toggle
+- cj/video-recording-toggle
+- cj/recording-modeline-indicator (shows both)
+- Both ffmpeg-record functions
+
+Validates:
+- Audio and video can run together
+- Modeline shows both indicators
+- Stopping one doesn't affect the other"
+ (test-integration-toggle-setup)
+ (unwind-protect
+ (progn
+ ;; Pre-configure devices
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor")
+
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (make-process :name name :command '("sleep" "1000")))))
+
+ ;; Start both recordings
+ (cj/audio-recording-toggle nil)
+ (cj/video-recording-toggle nil)
+
+ ;; Verify both are recording
+ (should cj/audio-recording-ffmpeg-process)
+ (should cj/video-recording-ffmpeg-process)
+ (should (equal " 🔴A+V " (cj/recording-modeline-indicator)))
+
+ ;; Stop audio only
+ (cj/audio-recording-toggle nil)
+
+ ;; Verify only video still recording
+ (should (null cj/audio-recording-ffmpeg-process))
+ (should cj/video-recording-ffmpeg-process)
+ (should (equal " 🔴Video " (cj/recording-modeline-indicator)))
+
+ ;; Stop video
+ (cj/video-recording-toggle nil)
+
+ ;; Verify all cleared
+ (should (null cj/video-recording-ffmpeg-process))
+ (should (equal "" (cj/recording-modeline-indicator)))))
+ (test-integration-toggle-teardown)))
+
+;;; Integration Tests - Sentinel Auto-Cleanup
+
+(ert-deftest test-integration-recording-toggle-workflow-sentinel-auto-cleanup ()
+ "Test that sentinel auto-cleans when recording process dies unexpectedly.
+
+When the ffmpeg process crashes or exits unexpectedly:
+1. Sentinel detects process death
+2. Variable is automatically cleared
+3. Modeline updates to show no recording
+4. User can start new recording
+
+Components integrated:
+- cj/audio-recording-toggle (process creation)
+- cj/ffmpeg-record-audio (attaches sentinel)
+- cj/recording-process-sentinel (cleanup on death)
+- cj/recording-modeline-indicator (updates on cleanup)
+
+Validates:
+- Sentinel cleans up on unexpected process death
+- Modeline syncs when sentinel runs
+- User can toggle again after crash"
+ (test-integration-toggle-setup)
+ (unwind-protect
+ (progn
+ ;; Pre-configure devices
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor")
+
+ (let ((process nil))
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) t))
+ ((symbol-function 'start-process-shell-command)
+ (lambda (name _buffer _cmd)
+ (setq process (make-process :name name :command '("sh" "-c" "exit 1"))))))
+
+ ;; Start recording
+ (cj/audio-recording-toggle nil)
+
+ ;; Verify recording started
+ (should cj/audio-recording-ffmpeg-process)
+ (should (equal " 🔴Audio " (cj/recording-modeline-indicator)))
+
+ ;; Wait for process to exit (sentinel should run)
+ (sit-for 0.3)
+
+ ;; Verify sentinel cleaned up
+ (should (null cj/audio-recording-ffmpeg-process))
+ (should (equal "" (cj/recording-modeline-indicator)))
+
+ ;; Verify user can start new recording after crash
+ (cj/audio-recording-toggle nil)
+ (should cj/audio-recording-ffmpeg-process))))
+ (test-integration-toggle-teardown)))
+
+(provide 'test-integration-recording-toggle-workflow)
+;;; test-integration-recording-toggle-workflow.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
deleted file mode 100644
index e95889e3..00000000
--- a/tests/test-video-audio-recording-detect-mic-device.el
+++ /dev/null
@@ -1,152 +0,0 @@
-;;; 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
deleted file mode 100644
index bea20e8a..00000000
--- a/tests/test-video-audio-recording-detect-system-device.el
+++ /dev/null
@@ -1,151 +0,0 @@
-;;; 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-ffmpeg-functions.el b/tests/test-video-audio-recording-ffmpeg-functions.el
new file mode 100644
index 00000000..e82614e2
--- /dev/null
+++ b/tests/test-video-audio-recording-ffmpeg-functions.el
@@ -0,0 +1,361 @@
+;;; test-video-audio-recording-ffmpeg-functions.el --- Tests for ffmpeg recording functions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/ffmpeg-record-video, cj/ffmpeg-record-audio,
+;; cj/video-recording-stop, and cj/audio-recording-stop functions.
+;; Tests process creation, sentinel attachment, and cleanup.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub directory variables
+(defvar video-recordings-dir "/tmp/video-recordings/")
+(defvar audio-recordings-dir "/tmp/audio-recordings/")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-ffmpeg-setup ()
+ "Reset all variables before each test."
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device "test-mic-device")
+ (setq cj/recording-system-device "test-monitor-device")
+ (setq cj/recording-mic-boost 2.0)
+ (setq cj/recording-system-volume 0.5))
+
+(defun test-ffmpeg-teardown ()
+ "Clean up after each test."
+ (when cj/video-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/video-recording-ffmpeg-process)))
+ (when cj/audio-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/audio-recording-ffmpeg-process)))
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+;;; Video Recording - Normal Cases
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-creates-process ()
+ "Test that video recording creates a process."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((process-created nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (setq process-created t)
+ (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (should process-created)
+ (should cj/video-recording-ffmpeg-process)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-attaches-sentinel ()
+ "Test that video recording attaches sentinel to process."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((sentinel-attached nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'set-process-sentinel)
+ (lambda (_proc sentinel)
+ (should (eq sentinel #'cj/recording-process-sentinel))
+ (setq sentinel-attached t))))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (should sentinel-attached)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-updates-modeline ()
+ "Test that video recording triggers modeline update."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((update-called nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (make-process :name "fake-video" :command '("sleep" "1000"))))
+ ((symbol-function 'force-mode-line-update)
+ (lambda (&optional _all) (setq update-called t))))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (should update-called)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-video-normal-uses-device-settings ()
+ "Test that video recording uses configured devices and volume settings."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((command nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer cmd)
+ (setq command cmd)
+ (make-process :name "fake-video" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ (should (string-match-p "test-mic-device" command))
+ (should (string-match-p "test-monitor-device" command))
+ (should (string-match-p "2\\.0" command)) ; mic boost
+ (should (string-match-p "0\\.5" command)))) ; system volume
+ (test-ffmpeg-teardown)))
+
+;;; Audio Recording - Normal Cases
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-creates-process ()
+ "Test that audio recording creates a process."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((process-created nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (setq process-created t)
+ (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-audio audio-recordings-dir)
+ (should process-created)
+ (should cj/audio-recording-ffmpeg-process)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-attaches-sentinel ()
+ "Test that audio recording attaches sentinel to process."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((sentinel-attached nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'set-process-sentinel)
+ (lambda (_proc sentinel)
+ (should (eq sentinel #'cj/recording-process-sentinel))
+ (setq sentinel-attached t))))
+ (cj/ffmpeg-record-audio audio-recordings-dir)
+ (should sentinel-attached)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-updates-modeline ()
+ "Test that audio recording triggers modeline update."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((update-called nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (make-process :name "fake-audio" :command '("sleep" "1000"))))
+ ((symbol-function 'force-mode-line-update)
+ (lambda (&optional _all) (setq update-called t))))
+ (cj/ffmpeg-record-audio audio-recordings-dir)
+ (should update-called)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-audio-normal-creates-m4a-file ()
+ "Test that audio recording creates .m4a file."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((command nil))
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer cmd)
+ (setq command cmd)
+ (make-process :name "fake-audio" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-audio audio-recordings-dir)
+ (should (string-match-p "\\.m4a" command))))
+ (test-ffmpeg-teardown)))
+
+;;; Stop Functions - Normal Cases
+
+(ert-deftest test-video-audio-recording-video-stop-normal-interrupts-process ()
+ "Test that stopping video recording interrupts the process."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))
+ (interrupt-called nil))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'interrupt-process)
+ (lambda (_proc) (setq interrupt-called t))))
+ (cj/video-recording-stop)
+ (should interrupt-called))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-video-stop-normal-clears-variable ()
+ "Test that stopping video recording clears the process variable."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (cj/video-recording-stop)
+ (should (null cj/video-recording-ffmpeg-process))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-video-stop-normal-updates-modeline ()
+ "Test that stopping video recording updates modeline."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))
+ (update-called nil))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'force-mode-line-update)
+ (lambda (&optional _all) (setq update-called t))))
+ (cj/video-recording-stop)
+ (should update-called))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-stop-normal-interrupts-process ()
+ "Test that stopping audio recording interrupts the process."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))
+ (interrupt-called nil))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'interrupt-process)
+ (lambda (_proc) (setq interrupt-called t))))
+ (cj/audio-recording-stop)
+ (should interrupt-called))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-stop-normal-clears-variable ()
+ "Test that stopping audio recording clears the process variable."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (cj/audio-recording-stop)
+ (should (null cj/audio-recording-ffmpeg-process))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-video-stop-boundary-no-process-displays-message ()
+ "Test that stopping when no video recording shows message."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((message-text nil))
+ (setq cj/video-recording-ffmpeg-process nil)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq message-text (apply #'format fmt args)))))
+ (cj/video-recording-stop)
+ (should (string-match-p "No video recording" message-text))))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-stop-boundary-no-process-displays-message ()
+ "Test that stopping when no audio recording shows message."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((message-text nil))
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq message-text (apply #'format fmt args)))))
+ (cj/audio-recording-stop)
+ (should (string-match-p "No audio recording" message-text))))
+ (test-ffmpeg-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-video-stop-error-interrupt-process-fails ()
+ "Test that video stop handles interrupt-process failure gracefully."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))
+ (error-raised nil))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'interrupt-process)
+ (lambda (_proc) (error "Interrupt failed"))))
+ ;; Should handle the error without crashing
+ (condition-case err
+ (cj/video-recording-stop)
+ (error (setq error-raised t)))
+ ;; Error should propagate (function doesn't catch it)
+ (should error-raised))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-stop-error-interrupt-process-fails ()
+ "Test that audio stop handles interrupt-process failure gracefully."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))
+ (error-raised nil))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'interrupt-process)
+ (lambda (_proc) (error "Interrupt failed"))))
+ ;; Should handle the error without crashing
+ (condition-case err
+ (cj/audio-recording-stop)
+ (error (setq error-raised t)))
+ ;; Error should propagate (function doesn't catch it)
+ (should error-raised))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-video-stop-error-dead-process-raises-error ()
+ "Test that video stop raises error if process is already dead.
+This documents current behavior - interrupt-process on dead process errors.
+The sentinel should clear the variable before this happens in practice."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ ;; Kill process before calling stop
+ (delete-process fake-process)
+ (sit-for 0.1)
+ ;; Calling stop on dead process raises error
+ (should-error (cj/video-recording-stop)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-stop-error-dead-process-raises-error ()
+ "Test that audio stop raises error if process is already dead.
+This documents current behavior - interrupt-process on dead process errors.
+The sentinel should clear the variable before this happens in practice."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Kill process before calling stop
+ (delete-process fake-process)
+ (sit-for 0.1)
+ ;; Calling stop on dead process raises error
+ (should-error (cj/audio-recording-stop)))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-video-boundary-skips-if-already-recording ()
+ "Test that video recording skips if already in progress."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000")))
+ (start-called nil))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (setq start-called t)
+ (make-process :name "fake-video2" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-video video-recordings-dir)
+ ;; Should NOT start a new process
+ (should-not start-called))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(ert-deftest test-video-audio-recording-ffmpeg-record-audio-boundary-skips-if-already-recording ()
+ "Test that audio recording skips if already in progress."
+ (test-ffmpeg-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000")))
+ (start-called nil))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'start-process-shell-command)
+ (lambda (_name _buffer _command)
+ (setq start-called t)
+ (make-process :name "fake-audio2" :command '("sleep" "1000")))))
+ (cj/ffmpeg-record-audio audio-recordings-dir)
+ ;; Should NOT start a new process
+ (should-not start-called))
+ (delete-process fake-process))
+ (test-ffmpeg-teardown)))
+
+(provide 'test-video-audio-recording-ffmpeg-functions)
+;;; test-video-audio-recording-ffmpeg-functions.el ends here
diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el
index b1b8470b..ba7d95b9 100644
--- a/tests/test-video-audio-recording-get-devices.el
+++ b/tests/test-video-audio-recording-get-devices.el
@@ -2,13 +2,11 @@
;;; Commentary:
;; Unit tests for cj/recording-get-devices function.
-;; Tests device auto-detection fallback logic.
+;; Tests device prompting and validation workflow.
;;
-;; 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
+;; NOTE: This function was refactored to use interactive prompts instead of
+;; auto-detection. It now prompts the user with y-or-n-p and calls either
+;; cj/recording-quick-setup-for-calls or cj/recording-select-devices.
;;; Code:
@@ -35,107 +33,157 @@
;;; 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."
+(ert-deftest test-video-audio-recording-get-devices-normal-returns-preset-devices ()
+ "Test that already-configured devices are returned without prompting."
(test-get-devices-setup)
(unwind-protect
(progn
- (setq cj/recording-mic-device "test-mic")
- (setq cj/recording-system-device "test-monitor")
+ (setq cj/recording-mic-device "preset-mic")
+ (setq cj/recording-system-device "preset-monitor")
(let ((result (cj/recording-get-devices)))
(should (consp result))
- (should (equal "test-mic" (car result)))
- (should (equal "test-monitor" (cdr result)))))
+ (should (equal "preset-mic" (car result)))
+ (should (equal "preset-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."
+(ert-deftest test-video-audio-recording-get-devices-normal-prompts-when-not-configured ()
+ "Test that function prompts user when devices not configured."
(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))))
+ (let ((prompt-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) (setq prompt-called t) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (setq cj/recording-mic-device "quick-mic")
+ (setq cj/recording-system-device "quick-monitor"))))
+ (cj/recording-get-devices)
+ (should prompt-called)))
(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."
+(ert-deftest test-video-audio-recording-get-devices-normal-calls-quick-setup-on-yes ()
+ "Test that function calls quick setup when user answers yes."
(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))))))
+ (let ((quick-setup-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (setq quick-setup-called t)
+ (setq cj/recording-mic-device "quick-mic")
+ (setq cj/recording-system-device "quick-monitor"))))
+ (cj/recording-get-devices)
+ (should quick-setup-called)))
(test-get-devices-teardown)))
-;;; Error Cases
+(ert-deftest test-video-audio-recording-get-devices-normal-calls-select-devices-on-no ()
+ "Test that function calls manual selection when user answers no."
+ (test-get-devices-setup)
+ (unwind-protect
+ (let ((select-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil))
+ ((symbol-function 'cj/recording-select-devices)
+ (lambda ()
+ (setq select-called t)
+ (setq cj/recording-mic-device "manual-mic")
+ (setq cj/recording-system-device "manual-monitor"))))
+ (cj/recording-get-devices)
+ (should select-called)))
+ (test-get-devices-teardown)))
-(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."
+(ert-deftest test-video-audio-recording-get-devices-normal-returns-cons-cell ()
+ "Test that function returns (mic . monitor) cons cell."
(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))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (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)))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-get-devices-boundary-only-mic-set-prompts ()
+ "Test that function prompts even when only mic is set."
+ (test-get-devices-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "preset-mic")
+ (setq cj/recording-system-device nil)
+ (let ((prompt-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) (setq prompt-called t) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (setq cj/recording-mic-device "new-mic")
+ (setq cj/recording-system-device "new-monitor"))))
+ (cj/recording-get-devices)
+ (should prompt-called))))
(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."
+(ert-deftest test-video-audio-recording-get-devices-boundary-only-monitor-set-prompts ()
+ "Test that function prompts even when only monitor is set."
(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))
+ (progn
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device "preset-monitor")
+ (let ((prompt-called nil))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) (setq prompt-called t) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda ()
+ (setq cj/recording-mic-device "new-mic")
+ (setq cj/recording-system-device "new-monitor"))))
+ (cj/recording-get-devices)
+ (should prompt-called))))
(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."
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-get-devices-error-setup-fails-signals-error ()
+ "Test that function signals error when setup fails to set devices."
(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)))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda () nil))) ;; Setup fails - doesn't set devices
(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."
+(ert-deftest test-video-audio-recording-get-devices-error-message-mentions-setup-commands ()
+ "Test that error message guides user to setup commands."
(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)))
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) t))
+ ((symbol-function 'cj/recording-quick-setup-for-calls)
+ (lambda () nil)))
(condition-case err
(cj/recording-get-devices)
(user-error
- (should (string-match-p "cj/recording-select-devices" (error-message-string err))))))
+ (should (string-match-p "C-; r c" (error-message-string err)))
+ (should (string-match-p "C-; r s" (error-message-string err))))))
+ (test-get-devices-teardown)))
+
+(ert-deftest test-video-audio-recording-get-devices-error-select-devices-fails ()
+ "Test that function signals error when manual selection fails."
+ (test-get-devices-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'y-or-n-p)
+ (lambda (_prompt) nil))
+ ((symbol-function 'cj/recording-select-devices)
+ (lambda () nil))) ;; Manual selection fails
+ (should-error (cj/recording-get-devices) :type 'user-error))
(test-get-devices-teardown)))
(provide 'test-video-audio-recording-get-devices)
diff --git a/tests/test-video-audio-recording-modeline-indicator.el b/tests/test-video-audio-recording-modeline-indicator.el
new file mode 100644
index 00000000..f7f3bbff
--- /dev/null
+++ b/tests/test-video-audio-recording-modeline-indicator.el
@@ -0,0 +1,134 @@
+;;; test-video-audio-recording-modeline-indicator.el --- Tests for cj/recording-modeline-indicator -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-modeline-indicator function.
+;; Tests modeline indicator display based on active recording processes.
+
+;;; 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-modeline-indicator-setup ()
+ "Reset process variables before each test."
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/video-recording-ffmpeg-process nil))
+
+(defun test-modeline-indicator-teardown ()
+ "Clean up process variables after each test."
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/video-recording-ffmpeg-process nil))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-modeline-indicator-normal-no-processes-returns-empty ()
+ "Test that indicator returns empty string when no processes are active."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (stringp result))
+ (should (equal "" result)))
+ (test-modeline-indicator-teardown)))
+
+(ert-deftest test-video-audio-recording-modeline-indicator-normal-audio-only-shows-audio ()
+ "Test that indicator shows audio when only audio process is active."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal " 🔴Audio " result)))
+ (delete-process fake-process))
+ (test-modeline-indicator-teardown)))
+
+(ert-deftest test-video-audio-recording-modeline-indicator-normal-video-only-shows-video ()
+ "Test that indicator shows video when only video process is active."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal " 🔴Video " result)))
+ (delete-process fake-process))
+ (test-modeline-indicator-teardown)))
+
+(ert-deftest test-video-audio-recording-modeline-indicator-normal-both-shows-combined ()
+ "Test that indicator shows A+V when both processes are active."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((audio-proc (make-process :name "test-audio" :command '("sleep" "1000")))
+ (video-proc (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process audio-proc)
+ (setq cj/video-recording-ffmpeg-process video-proc)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal " 🔴A+V " result)))
+ (delete-process audio-proc)
+ (delete-process video-proc))
+ (test-modeline-indicator-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-modeline-indicator-boundary-dead-audio-process-returns-empty ()
+ "Test that indicator returns empty string when audio process variable is set but process is dead."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Kill the process
+ (delete-process fake-process)
+ ;; Wait for process to be fully dead
+ (sit-for 0.1)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal "" result))))
+ (test-modeline-indicator-teardown)))
+
+(ert-deftest test-video-audio-recording-modeline-indicator-boundary-dead-video-process-returns-empty ()
+ "Test that indicator returns empty string when video process variable is set but process is dead."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ ;; Kill the process
+ (delete-process fake-process)
+ ;; Wait for process to be fully dead
+ (sit-for 0.1)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal "" result))))
+ (test-modeline-indicator-teardown)))
+
+(ert-deftest test-video-audio-recording-modeline-indicator-boundary-one-dead-one-alive-shows-alive ()
+ "Test that only the alive process shows when one is dead and one is alive."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (let ((dead-proc (make-process :name "test-dead" :command '("sleep" "1000")))
+ (alive-proc (make-process :name "test-alive" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process dead-proc)
+ (setq cj/video-recording-ffmpeg-process alive-proc)
+ (delete-process dead-proc)
+ (sit-for 0.1)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal " 🔴Video " result)))
+ (delete-process alive-proc))
+ (test-modeline-indicator-teardown)))
+
+(ert-deftest test-video-audio-recording-modeline-indicator-boundary-nil-process-variables ()
+ "Test that nil process variables are handled gracefully."
+ (test-modeline-indicator-setup)
+ (unwind-protect
+ (progn
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/video-recording-ffmpeg-process nil)
+ (let ((result (cj/recording-modeline-indicator)))
+ (should (equal "" result))))
+ (test-modeline-indicator-teardown)))
+
+(provide 'test-video-audio-recording-modeline-indicator)
+;;; test-video-audio-recording-modeline-indicator.el ends here
diff --git a/tests/test-video-audio-recording-process-sentinel.el b/tests/test-video-audio-recording-process-sentinel.el
new file mode 100644
index 00000000..37a7f94d
--- /dev/null
+++ b/tests/test-video-audio-recording-process-sentinel.el
@@ -0,0 +1,190 @@
+;;; test-video-audio-recording-process-sentinel.el --- Tests for cj/recording-process-sentinel -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-process-sentinel function.
+;; Tests process cleanup and modeline update when recording processes exit.
+
+;;; 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-sentinel-setup ()
+ "Reset process variables before each test."
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/video-recording-ffmpeg-process nil))
+
+(defun test-sentinel-teardown ()
+ "Clean up process variables after each test."
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/video-recording-ffmpeg-process nil))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-audio-exit-clears-variable ()
+ "Test that sentinel clears audio process variable when process exits."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Mock process-status to return 'exit
+ (cl-letf (((symbol-function 'process-status)
+ (lambda (_proc) 'exit)))
+ ;; Call sentinel with exit status
+ (cj/recording-process-sentinel fake-process "finished\n")
+ ;; Variable should be cleared
+ (should (null cj/audio-recording-ffmpeg-process))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-video-exit-clears-variable ()
+ "Test that sentinel clears video process variable when process exits."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-video" :command '("sh" "-c" "exit 0"))))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ ;; Mock process-status to return 'exit
+ (cl-letf (((symbol-function 'process-status)
+ (lambda (_proc) 'exit)))
+ ;; Call sentinel with exit status
+ (cj/recording-process-sentinel fake-process "finished\n")
+ ;; Variable should be cleared
+ (should (null cj/video-recording-ffmpeg-process))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-signal-status-clears-variable ()
+ "Test that sentinel clears variable on signal status (killed)."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (delete-process fake-process)
+ ;; Call sentinel with signal status
+ (cj/recording-process-sentinel fake-process "killed\n")
+ ;; Variable should be cleared
+ (should (null cj/audio-recording-ffmpeg-process)))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-normal-modeline-update-called ()
+ "Test that sentinel triggers modeline update."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0")))
+ (update-called nil))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Mock force-mode-line-update to track if it's called
+ (cl-letf (((symbol-function 'force-mode-line-update)
+ (lambda (&optional _all) (setq update-called t))))
+ (cj/recording-process-sentinel fake-process "finished\n")
+ (should update-called)))
+ (test-sentinel-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-run-status-ignored ()
+ "Test that sentinel ignores processes in 'run status (still running)."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Mock process-status to return 'run
+ (cl-letf (((symbol-function 'process-status)
+ (lambda (_proc) 'run)))
+ (cj/recording-process-sentinel fake-process "run")
+ ;; Variable should NOT be cleared
+ (should (eq fake-process cj/audio-recording-ffmpeg-process)))
+ (delete-process fake-process))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-open-status-ignored ()
+ "Test that sentinel ignores processes in 'open status."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'process-status)
+ (lambda (_proc) 'open)))
+ (cj/recording-process-sentinel fake-process "open")
+ ;; Variable should NOT be cleared
+ (should (eq fake-process cj/audio-recording-ffmpeg-process)))
+ (delete-process fake-process))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-boundary-event-trimmed ()
+ "Test that event string is trimmed in message."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0")))
+ (message-text nil))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Mock message to capture output
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (setq message-text (apply #'format fmt args)))))
+ (cj/recording-process-sentinel fake-process " finished \n")
+ ;; Message should contain trimmed event
+ (should (string-match-p "finished" message-text))
+ ;; Should not have extra whitespace
+ (should-not (string-match-p " finished " message-text))))
+ (test-sentinel-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-process-sentinel-error-unknown-process-ignored ()
+ "Test that sentinel handles unknown process (not audio or video) gracefully."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-unknown" :command '("sh" "-c" "exit 0")))
+ (audio-proc (make-process :name "test-audio" :command '("sleep" "1000")))
+ (video-proc (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process audio-proc)
+ (setq cj/video-recording-ffmpeg-process video-proc)
+ ;; Call sentinel with unknown process
+ (cj/recording-process-sentinel fake-process "finished\n")
+ ;; Audio and video variables should NOT be cleared
+ (should (eq audio-proc cj/audio-recording-ffmpeg-process))
+ (should (eq video-proc cj/video-recording-ffmpeg-process))
+ (delete-process audio-proc)
+ (delete-process video-proc))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-error-nil-event-handled ()
+ "Test that sentinel handles nil event string gracefully."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Mock process-status to return 'exit
+ (cl-letf (((symbol-function 'process-status)
+ (lambda (_proc) 'exit)))
+ ;; Should not crash with nil event (string-trim will error, but that's caught)
+ ;; The function uses string-trim without protection, so this will error
+ ;; Testing that it doesn't crash means we expect an error
+ (should-error
+ (cj/recording-process-sentinel fake-process nil))))
+ (test-sentinel-teardown)))
+
+(ert-deftest test-video-audio-recording-process-sentinel-error-empty-event-handled ()
+ "Test that sentinel handles empty event string gracefully."
+ (test-sentinel-setup)
+ (unwind-protect
+ (let ((fake-process (make-process :name "test-audio" :command '("sh" "-c" "exit 0"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ ;; Mock process-status to return 'exit
+ (cl-letf (((symbol-function 'process-status)
+ (lambda (_proc) 'exit)))
+ ;; Empty string is fine - string-trim handles it
+ ;; No error should be raised
+ (cj/recording-process-sentinel fake-process "")
+ ;; Variable should be cleared
+ (should (null cj/audio-recording-ffmpeg-process))))
+ (test-sentinel-teardown)))
+
+(provide 'test-video-audio-recording-process-sentinel)
+;;; test-video-audio-recording-process-sentinel.el ends here
diff --git a/tests/test-video-audio-recording-quick-setup-for-calls.el b/tests/test-video-audio-recording-quick-setup-for-calls.el
new file mode 100644
index 00000000..0d3fe53a
--- /dev/null
+++ b/tests/test-video-audio-recording-quick-setup-for-calls.el
@@ -0,0 +1,144 @@
+;;; test-video-audio-recording-quick-setup-for-calls.el --- Tests for cj/recording-quick-setup-for-calls -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-quick-setup-for-calls function.
+;; Tests quick device setup workflow for call recording.
+
+;;; 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-quick-setup-setup ()
+ "Reset device variables before each test."
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+(defun test-quick-setup-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-quick-setup-for-calls-normal-sets-both-devices ()
+ "Test that function sets both mic and system device variables."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((grouped-devices '(("Bluetooth Headset" . ("bluez_input.00:1B:66" . "bluez_output.00_1B_66.monitor")))))
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () grouped-devices))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _choices &rest _args) "Bluetooth Headset")))
+ (cj/recording-quick-setup-for-calls)
+ (should (equal "bluez_input.00:1B:66" cj/recording-mic-device))
+ (should (equal "bluez_output.00_1B_66.monitor" cj/recording-system-device))))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-normal-presents-friendly-names ()
+ "Test that function presents friendly device names to user."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((grouped-devices '(("Jabra SPEAK 510 USB" . ("usb-input" . "usb-monitor"))
+ ("Built-in Laptop Audio" . ("pci-input" . "pci-monitor"))))
+ (presented-choices nil))
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () grouped-devices))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (setq presented-choices choices)
+ (car choices))))
+ (cj/recording-quick-setup-for-calls)
+ (should (member "Jabra SPEAK 510 USB" presented-choices))
+ (should (member "Built-in Laptop Audio" presented-choices))))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-normal-displays-confirmation ()
+ "Test that function displays confirmation message with device details."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((grouped-devices '(("Bluetooth Headset" . ("bluez_input.00:1B:66" . "bluez_output.00_1B_66.monitor"))))
+ (message-text nil))
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () grouped-devices))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _choices &rest _args) "Bluetooth Headset"))
+ ((symbol-function 'message)
+ (lambda (fmt &rest args) (setq message-text (apply #'format fmt args)))))
+ (cj/recording-quick-setup-for-calls)
+ (should (string-match-p "Call recording ready" message-text))
+ (should (string-match-p "Bluetooth Headset" message-text))))
+ (test-quick-setup-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-boundary-single-device-no-prompt ()
+ "Test that with single device, selection still happens."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((grouped-devices '(("Built-in Laptop Audio" . ("pci-input" . "pci-monitor")))))
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () grouped-devices))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _choices &rest _args) "Built-in Laptop Audio")))
+ (cj/recording-quick-setup-for-calls)
+ (should (equal "pci-input" cj/recording-mic-device))
+ (should (equal "pci-monitor" cj/recording-system-device))))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-boundary-device-name-with-special-chars ()
+ "Test that device names with special characters are handled correctly."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (let ((grouped-devices '(("Device (USB-C)" . ("special-input" . "special-monitor")))))
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () grouped-devices))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt _choices &rest _args) "Device (USB-C)")))
+ (cj/recording-quick-setup-for-calls)
+ (should (equal "special-input" cj/recording-mic-device))
+ (should (equal "special-monitor" cj/recording-system-device))))
+ (test-quick-setup-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-error-no-devices-signals-error ()
+ "Test that function signals user-error when no complete devices are found."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () nil)))
+ (should-error (cj/recording-quick-setup-for-calls) :type 'user-error))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-error-message-mentions-both-devices ()
+ "Test that error message mentions need for both mic and monitor."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () nil)))
+ (condition-case err
+ (cj/recording-quick-setup-for-calls)
+ (user-error
+ (should (string-match-p "both mic and monitor" (error-message-string err))))))
+ (test-quick-setup-teardown)))
+
+(ert-deftest test-video-audio-recording-quick-setup-for-calls-error-empty-device-list ()
+ "Test that empty device list from grouping is handled gracefully."
+ (test-quick-setup-setup)
+ (unwind-protect
+ (cl-letf (((symbol-function 'cj/recording-group-devices-by-hardware)
+ (lambda () '())))
+ (should-error (cj/recording-quick-setup-for-calls) :type 'user-error))
+ (test-quick-setup-teardown)))
+
+(provide 'test-video-audio-recording-quick-setup-for-calls)
+;;; test-video-audio-recording-quick-setup-for-calls.el ends here
diff --git a/tests/test-video-audio-recording-select-device.el b/tests/test-video-audio-recording-select-device.el
new file mode 100644
index 00000000..53b1e665
--- /dev/null
+++ b/tests/test-video-audio-recording-select-device.el
@@ -0,0 +1,165 @@
+;;; test-video-audio-recording-select-device.el --- Tests for cj/recording-select-device -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-select-device function.
+;; Tests interactive device selection with filtering.
+
+;;; 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-select-device-normal-returns-selected-mic ()
+ "Test that function returns selected microphone device."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")
+ ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED"))))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ ;; Select the first choice
+ (caar choices))))
+ (let ((result (cj/recording-select-device "Select mic: " 'mic)))
+ (should (stringp result))
+ (should (equal "alsa_input.pci-device" result))))))
+
+(ert-deftest test-video-audio-recording-select-device-normal-returns-selected-monitor ()
+ "Test that function returns selected monitor device."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")
+ ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED"))))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (caar choices))))
+ (let ((result (cj/recording-select-device "Select monitor: " 'monitor)))
+ (should (stringp result))
+ (should (equal "alsa_output.pci-device.monitor" result))))))
+
+(ert-deftest test-video-audio-recording-select-device-normal-filters-monitors-for-mic ()
+ "Test that function filters out monitor devices when selecting mic."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")
+ ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED")
+ ("bluez_input.00:1B:66" "PipeWire" "RUNNING")))
+ (presented-choices nil))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (setq presented-choices choices)
+ (caar choices))))
+ (cj/recording-select-device "Select mic: " 'mic)
+ ;; Should have 2 mic devices (not the monitor)
+ (should (= 2 (length presented-choices)))
+ (should-not (cl-some (lambda (choice) (string-match-p "\\.monitor" (car choice)))
+ presented-choices)))))
+
+(ert-deftest test-video-audio-recording-select-device-normal-filters-non-monitors-for-monitor ()
+ "Test that function filters out non-monitor devices when selecting monitor."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")
+ ("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED")
+ ("bluez_output.00_1B_66.1.monitor" "PipeWire" "RUNNING")))
+ (presented-choices nil))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (setq presented-choices choices)
+ (caar choices))))
+ (cj/recording-select-device "Select monitor: " 'monitor)
+ ;; Should have 2 monitor devices (not the input)
+ (should (= 2 (length presented-choices)))
+ (should (cl-every (lambda (choice) (string-match-p "\\.monitor" (car choice)))
+ presented-choices)))))
+
+(ert-deftest test-video-audio-recording-select-device-normal-shows-friendly-state ()
+ "Test that function shows friendly state in choices."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED")))
+ (presented-choices nil))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (setq presented-choices choices)
+ (caar choices))))
+ (cj/recording-select-device "Select mic: " 'mic)
+ ;; Choice should contain "Ready" (friendly for SUSPENDED)
+ (should (string-match-p "Ready" (caar presented-choices))))))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-select-device-boundary-single-device ()
+ "Test that function works with single device."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED"))))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (caar choices))))
+ (let ((result (cj/recording-select-device "Select mic: " 'mic)))
+ (should (equal "alsa_input.pci-device" result))))))
+
+(ert-deftest test-video-audio-recording-select-device-boundary-multiple-states ()
+ "Test that function handles devices in different states."
+ (let ((sources '(("alsa_input.device1" "PipeWire" "SUSPENDED")
+ ("alsa_input.device2" "PipeWire" "RUNNING")
+ ("alsa_input.device3" "PipeWire" "IDLE")))
+ (presented-choices nil))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources))
+ ((symbol-function 'completing-read)
+ (lambda (_prompt choices &rest _args)
+ (setq presented-choices choices)
+ (caar choices))))
+ (cj/recording-select-device "Select mic: " 'mic)
+ ;; All three should be presented
+ (should (= 3 (length presented-choices)))
+ ;; Check that friendly states appear
+ (let ((choice-text (mapconcat #'car presented-choices " ")))
+ (should (string-match-p "Ready\\|Active" choice-text))))))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-select-device-error-no-mic-devices-signals-error ()
+ "Test that function signals user-error when no mic devices found."
+ (let ((sources '(("alsa_output.pci-device.monitor" "PipeWire" "SUSPENDED"))))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources)))
+ (should-error (cj/recording-select-device "Select mic: " 'mic) :type 'user-error))))
+
+(ert-deftest test-video-audio-recording-select-device-error-no-monitor-devices-signals-error ()
+ "Test that function signals user-error when no monitor devices found."
+ (let ((sources '(("alsa_input.pci-device" "PipeWire" "SUSPENDED"))))
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () sources)))
+ (should-error (cj/recording-select-device "Select monitor: " 'monitor) :type 'user-error))))
+
+(ert-deftest test-video-audio-recording-select-device-error-empty-source-list ()
+ "Test that function signals user-error when source list is empty."
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () nil)))
+ (should-error (cj/recording-select-device "Select mic: " 'mic) :type 'user-error)))
+
+(ert-deftest test-video-audio-recording-select-device-error-message-mentions-device-type ()
+ "Test that error message mentions the device type being searched for."
+ (cl-letf (((symbol-function 'cj/recording-parse-sources)
+ (lambda () nil)))
+ (condition-case err
+ (cj/recording-select-device "Select mic: " 'mic)
+ (user-error
+ (should (string-match-p "input" (error-message-string err)))))
+ (condition-case err
+ (cj/recording-select-device "Select monitor: " 'monitor)
+ (user-error
+ (should (string-match-p "monitor" (error-message-string err)))))))
+
+(provide 'test-video-audio-recording-select-device)
+;;; test-video-audio-recording-select-device.el ends here
diff --git a/tests/test-video-audio-recording-test-mic.el b/tests/test-video-audio-recording-test-mic.el
new file mode 100644
index 00000000..5aa794bb
--- /dev/null
+++ b/tests/test-video-audio-recording-test-mic.el
@@ -0,0 +1,147 @@
+;;; test-video-audio-recording-test-mic.el --- Tests for cj/recording-test-mic -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-test-mic function.
+;; Tests microphone testing functionality.
+
+;;; 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-mic-setup ()
+ "Reset device variables before each test."
+ (setq cj/recording-mic-device nil))
+
+(defun test-mic-teardown ()
+ "Clean up device variables after each test."
+ (setq cj/recording-mic-device nil))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-test-mic-normal-creates-temp-wav-file ()
+ "Test that function creates temp file with .wav extension."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "test-mic-device")
+ (let ((temp-file nil))
+ ;; Mock make-temp-file to capture filename
+ (cl-letf (((symbol-function 'make-temp-file)
+ (lambda (prefix _dir-flag suffix)
+ (setq temp-file (concat prefix "12345" suffix))
+ temp-file))
+ ((symbol-function 'shell-command)
+ (lambda (_cmd) 0)))
+ (cj/recording-test-mic)
+ (should (string-match-p "\\.wav$" temp-file)))))
+ (test-mic-teardown)))
+
+(ert-deftest test-video-audio-recording-test-mic-normal-runs-ffmpeg-command ()
+ "Test that function runs ffmpeg command with configured mic device."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "test-mic-device")
+ (let ((commands nil))
+ ;; Mock shell-command to capture all commands
+ (cl-letf (((symbol-function 'shell-command)
+ (lambda (cmd) (push cmd commands) 0)))
+ (cj/recording-test-mic)
+ (should (= 2 (length commands)))
+ ;; First command should be ffmpeg (stored last in list due to push)
+ (let ((ffmpeg-cmd (cadr commands)))
+ (should (stringp ffmpeg-cmd))
+ (should (string-match-p "ffmpeg" ffmpeg-cmd))
+ (should (string-match-p "test-mic-device" ffmpeg-cmd))
+ (should (string-match-p "-t 5" ffmpeg-cmd))))))
+ (test-mic-teardown)))
+
+(ert-deftest test-video-audio-recording-test-mic-normal-runs-ffplay-for-playback ()
+ "Test that function runs ffplay for playback."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "test-mic-device")
+ (let ((commands nil))
+ ;; Capture all shell commands
+ (cl-letf (((symbol-function 'shell-command)
+ (lambda (cmd) (push cmd commands) 0)))
+ (cj/recording-test-mic)
+ (should (= 2 (length commands)))
+ ;; Second command should be ffplay
+ (should (string-match-p "ffplay" (car commands)))
+ (should (string-match-p "-autoexit" (car commands))))))
+ (test-mic-teardown)))
+
+(ert-deftest test-video-audio-recording-test-mic-normal-displays-messages ()
+ "Test that function displays appropriate messages to user."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "test-mic-device")
+ (let ((messages nil))
+ ;; Capture messages
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))
+ ((symbol-function 'shell-command)
+ (lambda (_cmd) 0)))
+ (cj/recording-test-mic)
+ (should (>= (length messages) 3))
+ ;; Check for recording message
+ (should (cl-some (lambda (msg) (string-match-p "Recording.*SPEAK NOW" msg)) messages))
+ ;; Check for playback message
+ (should (cl-some (lambda (msg) (string-match-p "Playing back" msg)) messages))
+ ;; Check for complete message
+ (should (cl-some (lambda (msg) (string-match-p "complete" msg)) messages)))))
+ (test-mic-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-test-mic-error-no-mic-configured-signals-error ()
+ "Test that function signals user-error when mic device is not configured."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device nil)
+ (should-error (cj/recording-test-mic) :type 'user-error))
+ (test-mic-teardown)))
+
+(ert-deftest test-video-audio-recording-test-mic-error-message-mentions-setup ()
+ "Test that error message guides user to run setup."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device nil)
+ (condition-case err
+ (cj/recording-test-mic)
+ (user-error
+ (should (string-match-p "C-; r c" (error-message-string err))))))
+ (test-mic-teardown)))
+
+(ert-deftest test-video-audio-recording-test-mic-error-ffmpeg-failure-handled ()
+ "Test that ffmpeg command failure is handled gracefully."
+ (test-mic-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-mic-device "test-mic-device")
+ ;; Mock shell-command to fail
+ (cl-letf (((symbol-function 'shell-command)
+ (lambda (_cmd) 1))) ;; Non-zero exit code
+ ;; Should complete without crashing (ffmpeg errors are ignored)
+ ;; No error is raised - function just completes
+ (cj/recording-test-mic)
+ ;; Test passes if we get here
+ (should t)))
+ (test-mic-teardown)))
+
+(provide 'test-video-audio-recording-test-mic)
+;;; test-video-audio-recording-test-mic.el ends here
diff --git a/tests/test-video-audio-recording-test-monitor.el b/tests/test-video-audio-recording-test-monitor.el
new file mode 100644
index 00000000..f1476577
--- /dev/null
+++ b/tests/test-video-audio-recording-test-monitor.el
@@ -0,0 +1,148 @@
+;;; test-video-audio-recording-test-monitor.el --- Tests for cj/recording-test-monitor -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/recording-test-monitor function.
+;; Tests system audio monitor testing functionality.
+
+;;; 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-monitor-setup ()
+ "Reset device variables before each test."
+ (setq cj/recording-system-device nil))
+
+(defun test-monitor-teardown ()
+ "Clean up device variables after each test."
+ (setq cj/recording-system-device nil))
+
+;;; Normal Cases
+
+(ert-deftest test-video-audio-recording-test-monitor-normal-creates-temp-wav-file ()
+ "Test that function creates temp file with .wav extension."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device "test-monitor-device")
+ (let ((temp-file nil))
+ ;; Mock make-temp-file to capture filename
+ (cl-letf (((symbol-function 'make-temp-file)
+ (lambda (prefix _dir-flag suffix)
+ (setq temp-file (concat prefix "12345" suffix))
+ temp-file))
+ ((symbol-function 'shell-command)
+ (lambda (_cmd) 0)))
+ (cj/recording-test-monitor)
+ (should (string-match-p "monitor-test-" temp-file))
+ (should (string-match-p "\\.wav$" temp-file)))))
+ (test-monitor-teardown)))
+
+(ert-deftest test-video-audio-recording-test-monitor-normal-runs-ffmpeg-command ()
+ "Test that function runs ffmpeg command with configured monitor device."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device "test-monitor-device")
+ (let ((commands nil))
+ ;; Mock shell-command to capture all commands
+ (cl-letf (((symbol-function 'shell-command)
+ (lambda (cmd) (push cmd commands) 0)))
+ (cj/recording-test-monitor)
+ (should (= 2 (length commands)))
+ ;; First command should be ffmpeg (stored last in list due to push)
+ (let ((ffmpeg-cmd (cadr commands)))
+ (should (stringp ffmpeg-cmd))
+ (should (string-match-p "ffmpeg" ffmpeg-cmd))
+ (should (string-match-p "test-monitor-device" ffmpeg-cmd))
+ (should (string-match-p "-t 5" ffmpeg-cmd))))))
+ (test-monitor-teardown)))
+
+(ert-deftest test-video-audio-recording-test-monitor-normal-runs-ffplay-for-playback ()
+ "Test that function runs ffplay for playback."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device "test-monitor-device")
+ (let ((commands nil))
+ ;; Capture all shell commands
+ (cl-letf (((symbol-function 'shell-command)
+ (lambda (cmd) (push cmd commands) 0)))
+ (cj/recording-test-monitor)
+ (should (= 2 (length commands)))
+ ;; Second command should be ffplay
+ (should (string-match-p "ffplay" (car commands)))
+ (should (string-match-p "-autoexit" (car commands))))))
+ (test-monitor-teardown)))
+
+(ert-deftest test-video-audio-recording-test-monitor-normal-displays-messages ()
+ "Test that function displays appropriate messages to user."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device "test-monitor-device")
+ (let ((messages nil))
+ ;; Capture messages
+ (cl-letf (((symbol-function 'message)
+ (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))
+ ((symbol-function 'shell-command)
+ (lambda (_cmd) 0)))
+ (cj/recording-test-monitor)
+ (should (>= (length messages) 3))
+ ;; Check for recording message
+ (should (cl-some (lambda (msg) (string-match-p "Recording.*PLAY SOMETHING" msg)) messages))
+ ;; Check for playback message
+ (should (cl-some (lambda (msg) (string-match-p "Playing back" msg)) messages))
+ ;; Check for complete message
+ (should (cl-some (lambda (msg) (string-match-p "complete" msg)) messages)))))
+ (test-monitor-teardown)))
+
+;;; Error Cases
+
+(ert-deftest test-video-audio-recording-test-monitor-error-no-monitor-configured-signals-error ()
+ "Test that function signals user-error when monitor device is not configured."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device nil)
+ (should-error (cj/recording-test-monitor) :type 'user-error))
+ (test-monitor-teardown)))
+
+(ert-deftest test-video-audio-recording-test-monitor-error-message-mentions-setup ()
+ "Test that error message guides user to run setup."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device nil)
+ (condition-case err
+ (cj/recording-test-monitor)
+ (user-error
+ (should (string-match-p "C-; r c" (error-message-string err))))))
+ (test-monitor-teardown)))
+
+(ert-deftest test-video-audio-recording-test-monitor-error-ffmpeg-failure-handled ()
+ "Test that ffmpeg command failure is handled gracefully."
+ (test-monitor-setup)
+ (unwind-protect
+ (progn
+ (setq cj/recording-system-device "test-monitor-device")
+ ;; Mock shell-command to fail
+ (cl-letf (((symbol-function 'shell-command)
+ (lambda (_cmd) 1))) ;; Non-zero exit code
+ ;; Should complete without crashing (ffmpeg errors are ignored)
+ ;; No error is raised - function just completes
+ (cj/recording-test-monitor)
+ ;; Test passes if we get here
+ (should t)))
+ (test-monitor-teardown)))
+
+(provide 'test-video-audio-recording-test-monitor)
+;;; test-video-audio-recording-test-monitor.el ends here
diff --git a/tests/test-video-audio-recording-toggle-functions.el b/tests/test-video-audio-recording-toggle-functions.el
new file mode 100644
index 00000000..2355ab4f
--- /dev/null
+++ b/tests/test-video-audio-recording-toggle-functions.el
@@ -0,0 +1,185 @@
+;;; test-video-audio-recording-toggle-functions.el --- Tests for toggle functions -*- lexical-binding: t; -*-
+
+;;; Commentary:
+;; Unit tests for cj/video-recording-toggle and cj/audio-recording-toggle functions.
+;; Tests start/stop toggle behavior for recording processes.
+
+;;; Code:
+
+(require 'ert)
+
+;; Stub dependencies before loading the module
+(defvar cj/custom-keymap (make-sparse-keymap)
+ "Stub keymap for testing.")
+
+;; Stub directory variables
+(defvar video-recordings-dir "/tmp/video-recordings/")
+(defvar audio-recordings-dir "/tmp/audio-recordings/")
+
+;; Now load the actual production module
+(require 'video-audio-recording)
+
+;;; Setup and Teardown
+
+(defun test-toggle-setup ()
+ "Reset process variables before each test."
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device "test-mic")
+ (setq cj/recording-system-device "test-monitor"))
+
+(defun test-toggle-teardown ()
+ "Clean up process variables after each test."
+ (when cj/video-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/video-recording-ffmpeg-process)))
+ (when cj/audio-recording-ffmpeg-process
+ (ignore-errors (delete-process cj/audio-recording-ffmpeg-process)))
+ (setq cj/video-recording-ffmpeg-process nil)
+ (setq cj/audio-recording-ffmpeg-process nil)
+ (setq cj/recording-mic-device nil)
+ (setq cj/recording-system-device nil))
+
+;;; Video Toggle - Normal Cases
+
+(ert-deftest test-video-audio-recording-video-toggle-normal-starts-when-not-recording ()
+ "Test that video toggle starts recording when not currently recording."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((start-called nil))
+ (cl-letf (((symbol-function 'cj/ffmpeg-record-video)
+ (lambda (_dir) (setq start-called t))))
+ (cj/video-recording-toggle nil)
+ (should start-called)))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-video-toggle-normal-stops-when-recording ()
+ "Test that video toggle stops recording when currently recording."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((stop-called nil)
+ (fake-process (make-process :name "test-video" :command '("sleep" "1000"))))
+ (setq cj/video-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'cj/video-recording-stop)
+ (lambda () (setq stop-called t))))
+ (cj/video-recording-toggle nil)
+ (should stop-called))
+ (ignore-errors (delete-process fake-process)))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-video-toggle-normal-uses-default-directory ()
+ "Test that video toggle uses default directory when no prefix arg."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((recorded-dir nil))
+ (cl-letf (((symbol-function 'cj/ffmpeg-record-video)
+ (lambda (dir) (setq recorded-dir dir))))
+ (cj/video-recording-toggle nil)
+ (should (equal video-recordings-dir recorded-dir))))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-video-toggle-normal-prompts-for-location-with-prefix ()
+ "Test that video toggle prompts for location with prefix arg."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((prompt-called nil)
+ (recorded-dir nil))
+ (cl-letf (((symbol-function 'read-directory-name)
+ (lambda (_prompt) (setq prompt-called t) "/custom/path/"))
+ ((symbol-function 'file-directory-p)
+ (lambda (_dir) t)) ; Directory exists
+ ((symbol-function 'cj/ffmpeg-record-video)
+ (lambda (dir) (setq recorded-dir dir))))
+ (cj/video-recording-toggle t)
+ (should prompt-called)
+ (should (equal "/custom/path/" recorded-dir))))
+ (test-toggle-teardown)))
+
+;;; Audio Toggle - Normal Cases
+
+(ert-deftest test-video-audio-recording-audio-toggle-normal-starts-when-not-recording ()
+ "Test that audio toggle starts recording when not currently recording."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((start-called nil))
+ (cl-letf (((symbol-function 'cj/ffmpeg-record-audio)
+ (lambda (_dir) (setq start-called t))))
+ (cj/audio-recording-toggle nil)
+ (should start-called)))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-toggle-normal-stops-when-recording ()
+ "Test that audio toggle stops recording when currently recording."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((stop-called nil)
+ (fake-process (make-process :name "test-audio" :command '("sleep" "1000"))))
+ (setq cj/audio-recording-ffmpeg-process fake-process)
+ (cl-letf (((symbol-function 'cj/audio-recording-stop)
+ (lambda () (setq stop-called t))))
+ (cj/audio-recording-toggle nil)
+ (should stop-called))
+ (ignore-errors (delete-process fake-process)))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-toggle-normal-uses-default-directory ()
+ "Test that audio toggle uses default directory when no prefix arg."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((recorded-dir nil))
+ (cl-letf (((symbol-function 'cj/ffmpeg-record-audio)
+ (lambda (dir) (setq recorded-dir dir))))
+ (cj/audio-recording-toggle nil)
+ (should (equal audio-recordings-dir recorded-dir))))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-toggle-normal-prompts-for-location-with-prefix ()
+ "Test that audio toggle prompts for location with prefix arg."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((prompt-called nil)
+ (recorded-dir nil))
+ (cl-letf (((symbol-function 'read-directory-name)
+ (lambda (_prompt) (setq prompt-called t) "/custom/path/"))
+ ((symbol-function 'file-directory-p)
+ (lambda (_dir) t)) ; Directory exists
+ ((symbol-function 'cj/ffmpeg-record-audio)
+ (lambda (dir) (setq recorded-dir dir))))
+ (cj/audio-recording-toggle t)
+ (should prompt-called)
+ (should (equal "/custom/path/" recorded-dir))))
+ (test-toggle-teardown)))
+
+;;; Boundary Cases
+
+(ert-deftest test-video-audio-recording-video-toggle-boundary-creates-directory ()
+ "Test that video toggle creates directory if it doesn't exist."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((mkdir-called nil))
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) nil))
+ ((symbol-function 'make-directory)
+ (lambda (_dir _parents) (setq mkdir-called t)))
+ ((symbol-function 'cj/ffmpeg-record-video)
+ (lambda (_dir) nil)))
+ (cj/video-recording-toggle nil)
+ (should mkdir-called)))
+ (test-toggle-teardown)))
+
+(ert-deftest test-video-audio-recording-audio-toggle-boundary-creates-directory ()
+ "Test that audio toggle creates directory if it doesn't exist."
+ (test-toggle-setup)
+ (unwind-protect
+ (let ((mkdir-called nil))
+ (cl-letf (((symbol-function 'file-directory-p)
+ (lambda (_dir) nil))
+ ((symbol-function 'make-directory)
+ (lambda (_dir _parents) (setq mkdir-called t)))
+ ((symbol-function 'cj/ffmpeg-record-audio)
+ (lambda (_dir) nil)))
+ (cj/audio-recording-toggle nil)
+ (should mkdir-called)))
+ (test-toggle-teardown)))
+
+(provide 'test-video-audio-recording-toggle-functions)
+;;; test-video-audio-recording-toggle-functions.el ends here