diff options
| author | Craig Jennings <c@cjennings.net> | 2025-11-11 17:43:34 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-11-11 17:43:34 -0600 |
| commit | b07f8fe248db0c9916eccbc249f24d7a9107a3ce (patch) | |
| tree | f6336d009b589f1840fadac901bf2758563af9aa | |
| parent | 23b3df60eb619351fada7b83c9646c86e1addbd2 (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.el | 2 | ||||
| -rw-r--r-- | modules/video-audio-recording.el | 259 | ||||
| -rw-r--r-- | tests/test-integration-recording-modeline-sync.el | 384 | ||||
| -rw-r--r-- | tests/test-integration-recording-toggle-workflow.el | 347 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-detect-mic-device.el | 152 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-detect-system-device.el | 151 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-ffmpeg-functions.el | 361 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-get-devices.el | 194 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-modeline-indicator.el | 134 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-process-sentinel.el | 190 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-quick-setup-for-calls.el | 144 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-select-device.el | 165 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-test-mic.el | 147 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-test-monitor.el | 148 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-toggle-functions.el | 185 |
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 |
