diff options
| author | Craig Jennings <c@cjennings.net> | 2025-11-03 15:26:11 -0600 |
|---|---|---|
| committer | Craig Jennings <c@cjennings.net> | 2025-11-03 15:26:11 -0600 |
| commit | 0a69c5854378afcafc567d965f206cf6a0a984be (patch) | |
| tree | 923425a2aaf106c51cf5a3100cdf42f98e534da8 /tests | |
| parent | 9f8aec55033d46ca4f1cd78fbd315444a0c00bc6 (diff) | |
test: Add comprehensive test suite for video-audio-recording module
Added 83 test cases across 9 test files with 100% pass rate, covering
device detection, parsing, grouping, and complete workflow integration.
## What Was Done
### Refactoring for Testability
- Extracted `cj/recording--parse-pactl-output` from `cj/recording-parse-sources`
- Separated parsing logic from shell command execution
- Enables testing with fixture data instead of live system calls
### Test Fixtures Created
- `pactl-output-normal.txt` - All device types (built-in, USB, Bluetooth)
- `pactl-output-empty.txt` - Empty output
- `pactl-output-single.txt` - Single device
- `pactl-output-monitors-only.txt` - Only monitor devices
- `pactl-output-inputs-only.txt` - Only input devices
- `pactl-output-malformed.txt` - Invalid/malformed output
### Unit Tests (8 files, 78 test cases)
1. **test-video-audio-recording-friendly-state.el** (10 tests)
- State name conversion: SUSPENDED→Ready, RUNNING→Active
2. **test-video-audio-recording-parse-pactl-output.el** (14 tests)
- Parse raw pactl output into structured data
- Handle empty, malformed, and mixed valid/invalid input
3. **test-video-audio-recording-parse-sources.el** (6 tests)
- Shell command wrapper testing with mocked output
4. **test-video-audio-recording-detect-mic-device.el** (13 tests)
- Documents bugs: Returns ID numbers instead of device names
- Doesn't filter monitors (legacy function, not actively used)
5. **test-video-audio-recording-detect-system-device.el** (13 tests)
- Works correctly: Returns full device names
- Tests monitor detection with various device types
6. **test-video-audio-recording-group-devices-by-hardware.el** (12 tests)
- CRITICAL: Bluetooth MAC address normalization (colons vs underscores)
- Device pairing logic (mic + monitor from same hardware)
- Friendly name assignment
- Filters incomplete devices
7. **test-video-audio-recording-check-ffmpeg.el** (3 tests)
- ffmpeg availability detection
8. **test-video-audio-recording-get-devices.el** (7 tests)
- Auto-detection fallback logic
- Error handling for incomplete detection
### Integration Tests (1 file, 5 test cases)
9. **test-integration-recording-device-workflow.el** (5 tests)
- Complete workflow: parse → group → friendly names
- Bluetooth MAC normalization end-to-end
- Incomplete device filtering across components
- Malformed data graceful handling
## Key Testing Insights
### Bugs Documented
- `cj/recording-detect-mic-device` has bugs (returns IDs, doesn't filter monitors)
- These functions appear to be legacy code not used by main workflow
- Tests document current behavior to catch regressions if fixed
### Critical Features Validated
- **Bluetooth MAC normalization**: Input uses colons (00:1B:66:C0:91:6D),
output uses underscores (00_1B_66_C0_91_6D), grouping normalizes correctly
- **Device pairing**: Only devices with BOTH mic and monitor are included
- **Friendly names**: USB/PCI/Bluetooth patterns correctly identified
### Test Coverage
- Normal cases: Valid inputs, typical workflows
- Boundary cases: Empty, single device, incomplete pairs
- Error cases: Malformed input, missing devices, partial detection
## Test Execution
All tests pass: 9/9 files, 83/83 test cases (100% pass rate)
```bash
make test-file FILE=test-video-audio-recording-*.el
# All pass individually
# Integration test also passes
make test-file FILE=test-integration-recording-device-workflow.el
```
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/fixtures/pactl-output-empty.txt | 0 | ||||
| -rw-r--r-- | tests/fixtures/pactl-output-inputs-only.txt | 3 | ||||
| -rw-r--r-- | tests/fixtures/pactl-output-malformed.txt | 4 | ||||
| -rw-r--r-- | tests/fixtures/pactl-output-monitors-only.txt | 3 | ||||
| -rw-r--r-- | tests/fixtures/pactl-output-normal.txt | 6 | ||||
| -rw-r--r-- | tests/fixtures/pactl-output-single.txt | 1 | ||||
| -rw-r--r-- | tests/test-integration-recording-device-workflow.el | 232 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-check-ffmpeg.el | 46 | ||||
| -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-friendly-state.el | 65 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-get-devices.el | 142 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-group-devices-by-hardware.el | 194 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-parse-pactl-output.el | 157 | ||||
| -rw-r--r-- | tests/test-video-audio-recording-parse-sources.el | 98 |
15 files changed, 1254 insertions, 0 deletions
diff --git a/tests/fixtures/pactl-output-empty.txt b/tests/fixtures/pactl-output-empty.txt new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/fixtures/pactl-output-empty.txt diff --git a/tests/fixtures/pactl-output-inputs-only.txt b/tests/fixtures/pactl-output-inputs-only.txt new file mode 100644 index 00000000..1840b37c --- /dev/null +++ b/tests/fixtures/pactl-output-inputs-only.txt @@ -0,0 +1,3 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-malformed.txt b/tests/fixtures/pactl-output-malformed.txt new file mode 100644 index 00000000..a37b8dd6 --- /dev/null +++ b/tests/fixtures/pactl-output-malformed.txt @@ -0,0 +1,4 @@ +This is not valid pactl output +Some random text +50 incomplete-line-missing-fields +Another bad line with only two tabs diff --git a/tests/fixtures/pactl-output-monitors-only.txt b/tests/fixtures/pactl-output-monitors-only.txt new file mode 100644 index 00000000..be29ebe8 --- /dev/null +++ b/tests/fixtures/pactl-output-monitors-only.txt @@ -0,0 +1,3 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz RUNNING +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-normal.txt b/tests/fixtures/pactl-output-normal.txt new file mode 100644 index 00000000..6d8d955b --- /dev/null +++ b/tests/fixtures/pactl-output-normal.txt @@ -0,0 +1,6 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz SUSPENDED +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-single.txt b/tests/fixtures/pactl-output-single.txt new file mode 100644 index 00000000..d1d1c254 --- /dev/null +++ b/tests/fixtures/pactl-output-single.txt @@ -0,0 +1 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el new file mode 100644 index 00000000..ba92d700 --- /dev/null +++ b/tests/test-integration-recording-device-workflow.el @@ -0,0 +1,232 @@ +;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests covering the complete device detection and grouping workflow. +;; +;; This tests the full pipeline from raw pactl output through parsing, grouping, +;; and friendly name assignment. The workflow enables users to select audio devices +;; for recording calls/meetings. +;; +;; Components integrated: +;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data) +;; - cj/recording-parse-sources (shell command wrapper) +;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device) +;; - cj/recording-friendly-state (convert technical state names) +;; - Bluetooth MAC address normalization (colons → underscores) +;; - Device name pattern matching (USB, PCI, Bluetooth) +;; - Friendly name assignment (user-facing device names) +;; +;; Critical integration points: +;; - Parse output must produce data that group-devices can process +;; - Bluetooth MAC normalization must work across parse→group boundary +;; - Incomplete devices (only mic OR only monitor) must be filtered +;; - Friendly names must correctly identify device types + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases - Complete Workflow + +(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices () + "Test complete workflow from pactl output to grouped devices. + +When pactl output contains all three device types (built-in, USB, Bluetooth), +the workflow should parse, group, and assign friendly names to all devices. + +Components integrated: +- cj/recording--parse-pactl-output (parsing) +- cj/recording-group-devices-by-hardware (grouping + MAC normalization) +- Device pattern matching (USB/PCI/Bluetooth detection) +- Friendly name assignment + +Validates: +- All three device types are detected +- Bluetooth MAC addresses normalized (colons → underscores) +- Each device has both mic and monitor +- Friendly names correctly assigned +- Complete data flow: raw output → parsed list → grouped pairs" + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Test parse step + (let ((parsed (cj/recording-parse-sources))) + (should (= 6 (length parsed))) + + ;; Test group step (receives parsed data) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 3 (length grouped))) + + ;; Validate built-in device + (let ((built-in (assoc "Built-in Laptop Audio" grouped))) + (should built-in) + (should (string-prefix-p "alsa_input.pci" (cadr built-in))) + (should (string-prefix-p "alsa_output.pci" (cddr built-in)))) + + ;; Validate USB device + (let ((usb (assoc "Jabra SPEAK 510 USB" grouped))) + (should usb) + (should (string-match-p "Jabra" (cadr usb))) + (should (string-match-p "Jabra" (cddr usb)))) + + ;; Validate Bluetooth device (CRITICAL: MAC normalization) + (let ((bluetooth (assoc "Bluetooth Headset" grouped))) + (should bluetooth) + ;; Input has colons + (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores + (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth))) + ;; But they're grouped together! + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))))) + +(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list () + "Test that friendly state names appear in device list output. + +When listing devices, technical state names (SUSPENDED, RUNNING) should be +converted to friendly names (Ready, Active) for better UX. + +Components integrated: +- cj/recording-parse-sources (parsing with state) +- cj/recording-friendly-state (state name conversion) + +Validates: +- SUSPENDED → Ready +- RUNNING → Active +- State conversion works across the parse workflow" + (let ((output (concat + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Verify states are parsed correctly + (should (equal "SUSPENDED" (nth 2 (nth 0 parsed)))) + (should (equal "RUNNING" (nth 2 (nth 1 parsed)))) + + ;; Verify friendly conversion works + (should (equal "Ready" (cj/recording-friendly-state (nth 2 (nth 0 parsed))))) + (should (equal "Active" (cj/recording-friendly-state (nth 2 (nth 1 parsed))))))))) + +;;; Boundary Cases - Incomplete Devices + +(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered () + "Test that devices with only mic OR only monitor are filtered out. + +For call recording, we need BOTH mic and monitor from the same device. +Incomplete devices should not appear in the grouped output. + +Components integrated: +- cj/recording-parse-sources (parsing all devices) +- cj/recording-group-devices-by-hardware (filtering incomplete pairs) + +Validates: +- Device with only mic is filtered +- Device with only monitor is filtered +- Only complete devices (both mic and monitor) are returned +- Filtering happens at group stage, not parse stage" + (let ((output (concat + ;; Complete device + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Parse sees all 4 devices + (let ((parsed (cj/recording-parse-sources))) + (should (= 4 (length parsed))) + + ;; Group returns only 1 complete device + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Built-in Laptop Audio" (caar grouped)))))))) + +;;; Edge Cases - Bluetooth MAC Normalization + +(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations () + "Test Bluetooth MAC normalization with different formats. + +Bluetooth devices use colons in input names but underscores in output names. +The grouping must normalize these to match devices correctly. + +Components integrated: +- cj/recording-parse-sources (preserves original MAC format) +- cj/recording-group-devices-by-hardware (normalizes MAC for matching) +- Base name extraction (regex patterns) +- MAC address transformation (underscores → colons) + +Validates: +- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly +- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly +- Normalization happens during grouping +- Devices paired despite format difference +- Original device names preserved (not mutated)" + (let ((output (concat + "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Original formats preserved in parse + (should (string-match-p "11:22:33" (caar parsed))) + (should (string-match-p "11_22_33" (caadr parsed))) + + ;; But grouping matches them + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Bluetooth Headset" (caar grouped))) + ;; Original names preserved + (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped))) + (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped)))))))) + +;;; Error Cases - Malformed Data + +(ert-deftest test-integration-recording-device-workflow-malformed-output-handled () + "Test that malformed pactl output is handled gracefully. + +When pactl output is malformed or unparseable, the workflow should not crash. +It should return empty results at appropriate stages. + +Components integrated: +- cj/recording--parse-pactl-output (malformed line handling) +- cj/recording-group-devices-by-hardware (empty input handling) + +Validates: +- Malformed lines are silently skipped during parse +- Empty parse results don't crash grouping +- Workflow degrades gracefully +- No exceptions thrown" + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Malformed output produces empty parse + (should (null parsed)) + + ;; Empty parse produces empty grouping (no crash) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (null grouped))))))) + +(provide 'test-integration-recording-device-workflow) +;;; test-integration-recording-device-workflow.el ends here diff --git a/tests/test-video-audio-recording-check-ffmpeg.el b/tests/test-video-audio-recording-check-ffmpeg.el new file mode 100644 index 00000000..5c264b64 --- /dev/null +++ b/tests/test-video-audio-recording-check-ffmpeg.el @@ -0,0 +1,46 @@ +;;; test-video-audio-recording-check-ffmpeg.el --- Tests for cj/recording-check-ffmpeg -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-check-ffmpeg function. +;; Tests detection of ffmpeg availability. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-normal-ffmpeg-found-returns-t () + "Test that function returns t when ffmpeg is found." + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) + (when (equal cmd "ffmpeg") "/usr/bin/ffmpeg")))) + (let ((result (cj/recording-check-ffmpeg))) + (should (eq t result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-ffmpeg-not-found-signals-error () + "Test that function signals user-error when ffmpeg is not found." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (should-error (cj/recording-check-ffmpeg) :type 'user-error))) + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-message-mentions-pacman () + "Test that error message includes installation command." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (condition-case err + (cj/recording-check-ffmpeg) + (user-error + (should (string-match-p "pacman -S ffmpeg" (error-message-string err))))))) + +(provide 'test-video-audio-recording-check-ffmpeg) +;;; test-video-audio-recording-check-ffmpeg.el ends here diff --git a/tests/test-video-audio-recording-detect-mic-device.el b/tests/test-video-audio-recording-detect-mic-device.el new file mode 100644 index 00000000..e95889e3 --- /dev/null +++ b/tests/test-video-audio-recording-detect-mic-device.el @@ -0,0 +1,152 @@ +;;; test-video-audio-recording-detect-mic-device.el --- Tests for cj/recording-detect-mic-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-detect-mic-device function. +;; Tests auto-detection of microphone input device from pactl output. +;; Mocks shell-command-to-string to test regex matching logic. +;; +;; IMPORTANT: These tests document actual behavior, which appears to have a bug. +;; The function currently returns the pactl ID number (e.g., "50") instead of +;; the device name (e.g., "alsa_input.pci-0000_00_1f.3.analog-stereo"). +;; This is because the regex captures group 1 is \\([^\t\n]+\\) which stops +;; at the first tab, capturing only the ID. +;; +;; This function may not be actively used (parse-sources is preferred). +;; Tests document current behavior to catch regressions if function is fixed. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-built-in-analog-stereo-found () + "Test detection of built-in analog stereo microphone. +Note: Returns first match which is the monitor (ID 49), not the input." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (stringp result)) + ;; BUG: Returns first match "49" (monitor), not input "50" + (should (equal "49" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-usb-analog-stereo-found () + "Test detection of USB analog stereo microphone. +Note: Returns ID '100', not device name." + (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (stringp result)) + ;; Current behavior: returns ID "100" + (should (equal "100" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-first-match-returned () + "Test that first matching device is returned when multiple exist. +Note: Returns first ID, not device name." + (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "100\talsa_input.usb-device.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; Current behavior: returns first ID "50" + (should (equal "50" result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-empty-output-returns-nil () + "Test that empty output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-only-monitors-returns-nil () + "Test that output with only monitor devices still matches (documents bug). +Current regex doesn't exclude monitors, so this returns ID '49'." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; BUG: Should return nil for monitors, but regex doesn't exclude them + (should (equal "49" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-mono-fallback-no-match () + "Test that mono-fallback device doesn't match (not stereo)." + (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-bluetooth-no-match () + "Test that Bluetooth devices without 'analog stereo' don't match." + (let ((output "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-whitespace-only-returns-nil () + "Test that whitespace-only output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-case-insensitive-analog () + "Test that 'ANALOG' (uppercase) matches (case-insensitive regex). +Documents that regex is actually case-insensitive." + (let ((output "50\talsa_input.pci-0000_00_1f.3.ANALOG-STEREO\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; Regex is case-insensitive, matches uppercase + (should (equal "50" result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-error-malformed-output-returns-nil () + "Test that malformed output returns nil." + (let ((output "This is not valid pactl output\nRandom text here\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-analog-only () + "Test that 'analog' without 'stereo' doesn't match." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-mono\tPipeWire\ts32le 1ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-stereo-only () + "Test that 'stereo' without 'analog' doesn't match." + (let ((output "50\talsa_input.pci-0000_00_1f.3.digital-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-monitor-with-analog-stereo-matches-bug () + "Test that monitor device with 'analog stereo' incorrectly matches (documents bug). +Should return nil for monitors, but current regex doesn't filter them." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; BUG: Returns ID "49" even though this is a monitor (output device) + (should (equal "49" result)))))) + +(provide 'test-video-audio-recording-detect-mic-device) +;;; test-video-audio-recording-detect-mic-device.el ends here diff --git a/tests/test-video-audio-recording-detect-system-device.el b/tests/test-video-audio-recording-detect-system-device.el new file mode 100644 index 00000000..bea20e8a --- /dev/null +++ b/tests/test-video-audio-recording-detect-system-device.el @@ -0,0 +1,151 @@ +;;; test-video-audio-recording-detect-system-device.el --- Tests for cj/recording-detect-system-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-detect-system-device function. +;; Tests auto-detection of system audio monitor device from pactl output. +;; Mocks shell-command-to-string to test regex matching logic. +;; +;; NOTE: This function works correctly - returns the full device name ending in .monitor. +;; The regex \\([^\t\n]+\\.monitor\\) matches any non-tab/newline chars ending with .monitor, +;; which correctly captures the device name field from pactl output. +;; +;; This function may not be actively used (parse-sources is preferred). +;; Tests document current behavior to catch regressions. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-detect-system-device-normal-built-in-monitor-found () + "Test detection of built-in system audio monitor. +Returns full device name." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-usb-monitor-found () + "Test detection of USB system audio monitor." + (let ((output "99\talsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-bluetooth-monitor-found () + "Test detection of Bluetooth monitor device." + (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-first-match-returned () + "Test that first matching monitor is returned when multiple exist." + (let ((output (concat "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n" + "99\talsa_output.usb-device.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Returns first monitor device name + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-empty-output-returns-nil () + "Test that empty output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-detect-system-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-only-inputs-returns-nil () + "Test that output with only input devices (no monitors) returns nil." + (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-whitespace-only-returns-nil () + "Test that whitespace-only output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-detect-system-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-monitor-different-states () + "Test that monitors in different states are all matched." + (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Should match regardless of state (RUNNING, SUSPENDED, IDLE) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-case-insensitive-monitor () + "Test that regex is case-insensitive for '.monitor' suffix. +Documents that .MONITOR (uppercase) also matches." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Case-insensitive: .MONITOR matches + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR" result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-detect-system-device-error-malformed-output-returns-nil () + "Test that malformed output returns nil." + (let ((output "This is not valid pactl output\nRandom text here\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-partial-monitor-matches () + "Test that device with .monitor in middle partially matches (documents quirk). +The regex matches up to first .monitor occurrence, even if not at end of device name." + (let ((output "50\talsa_input.monitor-device.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; QUIRK: Matches partial string "alsa_input.monitor" + (should (equal "alsa_input.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-incomplete-line () + "Test that incomplete lines with .monitor are still matched." + (let ((output "49\tincomplete-line.monitor\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Should match device name ending in .monitor + (should (equal "incomplete-line.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-mixed-valid-invalid () + "Test that mix of valid and invalid lines returns first valid monitor." + (let ((output (concat "invalid line without tabs\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "another invalid line\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +(provide 'test-video-audio-recording-detect-system-device) +;;; test-video-audio-recording-detect-system-device.el ends here diff --git a/tests/test-video-audio-recording-friendly-state.el b/tests/test-video-audio-recording-friendly-state.el new file mode 100644 index 00000000..91b47998 --- /dev/null +++ b/tests/test-video-audio-recording-friendly-state.el @@ -0,0 +1,65 @@ +;;; test-video-audio-recording-friendly-state.el --- Tests for cj/recording-friendly-state -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-friendly-state function. +;; Tests conversion of technical pactl state names to user-friendly labels. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-friendly-state-normal-suspended-returns-ready () + "Test that SUSPENDED state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "SUSPENDED")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-running-returns-active () + "Test that RUNNING state converts to Active." + (should (string= "Active" (cj/recording-friendly-state "RUNNING")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-idle-returns-ready () + "Test that IDLE state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "IDLE")))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-friendly-state-boundary-empty-string-returns-empty () + "Test that empty string passes through unchanged." + (should (string= "" (cj/recording-friendly-state "")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-lowercase-suspended-returns-unchanged () + "Test that lowercase 'suspended' is not converted (case-sensitive)." + (should (string= "suspended" (cj/recording-friendly-state "suspended")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-mixed-case-returns-unchanged () + "Test that mixed case 'Running' passes through unchanged." + (should (string= "Running" (cj/recording-friendly-state "Running")))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-friendly-state-error-unknown-state-returns-unchanged () + "Test that unknown state passes through unchanged." + (should (string= "UNKNOWN" (cj/recording-friendly-state "UNKNOWN")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-random-string-returns-unchanged () + "Test that random string passes through unchanged." + (should (string= "foobar" (cj/recording-friendly-state "foobar")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-numeric-string-returns-unchanged () + "Test that numeric string passes through unchanged." + (should (string= "12345" (cj/recording-friendly-state "12345")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-special-chars-returns-unchanged () + "Test that string with special characters passes through unchanged." + (should (string= "!@#$%" (cj/recording-friendly-state "!@#$%")))) + +(provide 'test-video-audio-recording-friendly-state) +;;; test-video-audio-recording-friendly-state.el ends here diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el new file mode 100644 index 00000000..b1b8470b --- /dev/null +++ b/tests/test-video-audio-recording-get-devices.el @@ -0,0 +1,142 @@ +;;; test-video-audio-recording-get-devices.el --- Tests for cj/recording-get-devices -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-get-devices function. +;; Tests device auto-detection fallback logic. +;; +;; Note: This function has interactive prompts, but we test the core logic paths +;; without mocking y-or-n-p. We focus on testing: +;; - Already-set devices (no auto-detection needed) +;; - Successful auto-detection +;; - Failed auto-detection → error + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-get-devices-setup () + "Reset device variables before each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +(defun test-get-devices-teardown () + "Clean up device variables after each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-get-devices-normal-already-set-returns-devices () + "Test that already-set devices are returned without auto-detection." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor") + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "test-mic" (car result))) + (should (equal "test-monitor" (cdr result))))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-auto-detect-success () + "Test that auto-detection succeeds and returns devices." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () "auto-detected-mic")) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () "auto-detected-monitor"))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "auto-detected-mic" (car result))) + (should (equal "auto-detected-monitor" (cdr result))) + ;; Verify variables were set + (should (equal "auto-detected-mic" cj/recording-mic-device)) + (should (equal "auto-detected-monitor" cj/recording-system-device)))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-partial-auto-detect () + "Test when only one device is already set, only the other is auto-detected." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "preset-mic") + (cl-letf (((symbol-function 'cj/recording-detect-system-device) + (lambda () "auto-detected-monitor"))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "preset-mic" (car result))) + (should (equal "auto-detected-monitor" (cdr result)))))) + (test-get-devices-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-get-devices-error-auto-detect-fails-signals-error () + "Test that failed auto-detection signals user-error. +When auto-detection fails and user doesn't manually select, function errors." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ;; Mock y-or-n-p to say no to manual selection + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-only-mic-detected-signals-error () + "Test that detecting only mic (no monitor) signals error." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () "detected-mic")) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-only-monitor-detected-signals-error () + "Test that detecting only monitor (no mic) signals error." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () "detected-monitor")) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-message-mentions-select-devices () + "Test that error message guides user to manual selection command." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (condition-case err + (cj/recording-get-devices) + (user-error + (should (string-match-p "cj/recording-select-devices" (error-message-string err)))))) + (test-get-devices-teardown))) + +(provide 'test-video-audio-recording-get-devices) +;;; test-video-audio-recording-get-devices.el ends here diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el new file mode 100644 index 00000000..0abe5f6c --- /dev/null +++ b/tests/test-video-audio-recording-group-devices-by-hardware.el @@ -0,0 +1,194 @@ +;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-group-devices-by-hardware function. +;; Tests grouping of audio sources by physical hardware device. +;; Critical test: Bluetooth MAC address normalization (colons vs underscores). +;; +;; This function is used by the quick setup command to automatically pair +;; microphone and monitor devices from the same hardware. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped () + "Test grouping of all three device types (built-in, USB, Bluetooth). +This is the key test validating the complete grouping logic." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (= 3 (length result))) + ;; Check that we have all three device types + (let ((names (mapcar #'car result))) + (should (member "Built-in Laptop Audio" names)) + (should (member "Bluetooth Headset" names)) + (should (member "Jabra SPEAK 510 USB" names))) + ;; Verify each device has both mic and monitor + (dolist (device result) + (should (stringp (car device))) ; friendly name + (should (stringp (cadr device))) ; mic device + (should (stringp (cddr device))) ; monitor device + (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor + (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired () + "Test that built-in laptop audio devices are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (built-in (assoc "Built-in Laptop Audio" result))) + (should built-in) + (should (string-match-p "pci-0000_00_1f" (cadr built-in))) + (should (string-match-p "pci-0000_00_1f" (cddr built-in))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired () + "Test that USB devices (Jabra) are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (jabra (assoc "Jabra SPEAK 510 USB" result))) + (should jabra) + (should (string-match-p "Jabra" (cadr jabra))) + (should (string-match-p "Jabra" (cddr jabra))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired () + "Test that Bluetooth devices are correctly paired. +CRITICAL: Tests MAC address normalization (colons in input, underscores in output)." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (bluetooth (assoc "Bluetooth Headset" result))) + (should bluetooth) + ;; Input has colons: bluez_input.00:1B:66:C0:91:6D + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor + ;; But they should still be grouped together (MAC address normalized) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty () + "Test that only input devices (no monitors) returns empty list. +Devices must have BOTH mic and monitor to be included." + (let ((output (test-load-fixture "pactl-output-inputs-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty () + "Test that only monitor devices (no inputs) returns empty list." + (let ((output (test-load-fixture "pactl-output-monitors-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device () + "Test that single device with both mic and monitor is returned." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete () + "Test that only devices with BOTH mic and monitor are included. +Incomplete devices (only mic or only monitor) are filtered out." + (let ((output (concat + ;; Complete device (built-in) + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + ;; Only the complete built-in device should be returned + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty () + "Test that malformed pactl output returns empty list." + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type () + "Test that unknown device types get generic 'USB Audio Device' name." + (let ((output (concat + "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n" + "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + ;; Should get generic USB name (not matching Jabra pattern) + (should (equal "USB Audio Device" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations () + "Test that Bluetooth MAC addresses work with different formatting. +Tests the normalization logic handles various MAC address formats." + (let ((output (concat + ;; Input with colons (typical) + "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + ;; Output with underscores (typical) + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Bluetooth Headset" (caar result))) + ;; Verify both devices paired despite different MAC formats + (let ((device (car result))) + (should (string-match-p "AA:BB:CC" (cadr device))) + (should (string-match-p "AA_BB_CC" (cddr device)))))))) + +(provide 'test-video-audio-recording-group-devices-by-hardware) +;;; test-video-audio-recording-group-devices-by-hardware.el ends here diff --git a/tests/test-video-audio-recording-parse-pactl-output.el b/tests/test-video-audio-recording-parse-pactl-output.el new file mode 100644 index 00000000..db49a897 --- /dev/null +++ b/tests/test-video-audio-recording-parse-pactl-output.el @@ -0,0 +1,157 @@ +;;; test-video-audio-recording-parse-pactl-output.el --- Tests for cj/recording--parse-pactl-output -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--parse-pactl-output function. +;; Tests parsing of pactl sources output into structured data. +;; Uses fixture files with sample pactl output for reproducible testing. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-all-devices-returns-list () + "Test parsing normal pactl output with all device types." + (let* ((output (test-load-fixture "pactl-output-normal.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 6 (length result))) + ;; Check first device (built-in monitor) + (should (equal '("alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + "PipeWire" + "SUSPENDED") + (nth 0 result))) + ;; Check Bluetooth input + (should (equal '("bluez_input.00:1B:66:C0:91:6D" + "PipeWire" + "SUSPENDED") + (nth 2 result))) + ;; Check USB device + (should (equal '("alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback" + "PipeWire" + "SUSPENDED") + (nth 5 result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-single-device-returns-list () + "Test parsing output with single device." + (let* ((output (test-load-fixture "pactl-output-single.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 1 (length result))) + (should (equal '("alsa_input.pci-0000_00_1f.3.analog-stereo" + "PipeWire" + "SUSPENDED") + (car result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-monitors-only-returns-list () + "Test parsing output with only monitor devices." + (let* ((output (test-load-fixture "pactl-output-monitors-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; All should end with .monitor + (dolist (device result) + (should (string-suffix-p ".monitor" (car device)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-inputs-only-returns-list () + "Test parsing output with only input devices." + (let* ((output (test-load-fixture "pactl-output-inputs-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; None should end with .monitor + (dolist (device result) + (should-not (string-suffix-p ".monitor" (car device)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-string-returns-empty-list () + "Test parsing empty string returns empty list." + (let ((result (cj/recording--parse-pactl-output ""))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-file-returns-empty-list () + "Test parsing empty file returns empty list." + (let* ((output (test-load-fixture "pactl-output-empty.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-whitespace-only-returns-empty-list () + "Test parsing whitespace-only string returns empty list." + (let ((result (cj/recording--parse-pactl-output " \n\t\n "))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-single-newline-returns-empty-list () + "Test parsing single newline returns empty list." + (let ((result (cj/recording--parse-pactl-output "\n"))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-running-state-parsed () + "Test that RUNNING state (not just SUSPENDED) is parsed correctly." + (let* ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "RUNNING" (nth 2 (car result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-idle-state-parsed () + "Test that IDLE state is parsed correctly." + (let* ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tIDLE\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "IDLE" (nth 2 (car result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-malformed-lines-ignored () + "Test that malformed lines are silently ignored." + (let* ((output (test-load-fixture "pactl-output-malformed.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) ; All lines malformed, so empty list + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-mixed-valid-invalid-returns-valid () + "Test that mix of valid and invalid lines returns only valid ones." + (let* ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "This is invalid\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "Also invalid\n")) + (result (cj/recording--parse-pactl-output output))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-missing-fields-ignored () + "Test that lines with missing fields are ignored." + (let* ((output "50\tincomplete-line\tPipeWire\n") ; Missing state and format + (result (cj/recording--parse-pactl-output output))) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-nil-input-returns-error () + "Test that nil input signals an error." + (should-error (cj/recording--parse-pactl-output nil))) + +(provide 'test-video-audio-recording-parse-pactl-output) +;;; test-video-audio-recording-parse-pactl-output.el ends here diff --git a/tests/test-video-audio-recording-parse-sources.el b/tests/test-video-audio-recording-parse-sources.el new file mode 100644 index 00000000..d6d445b5 --- /dev/null +++ b/tests/test-video-audio-recording-parse-sources.el @@ -0,0 +1,98 @@ +;;; test-video-audio-recording-parse-sources.el --- Tests for cj/recording-parse-sources -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-parse-sources function. +;; Tests the wrapper that calls pactl and delegates to internal parser. +;; Mocks shell-command-to-string to avoid system dependencies. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-sources-normal-calls-pactl-and-parses () + "Test that parse-sources calls shell command and returns parsed list." + (let ((fixture-output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 6 (length result))) + ;; Verify it returns structured data + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + (car (nth 0 result)))) + (should (equal "PipeWire" (nth 1 (nth 0 result)))) + (should (equal "SUSPENDED" (nth 2 (nth 0 result)))))))) + +(ert-deftest test-video-audio-recording-parse-sources-normal-single-device-returns-list () + "Test parse-sources with single device." + (let ((fixture-output (test-load-fixture "pactl-output-single.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 1 (length result))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-sources-boundary-empty-output-returns-empty-list () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-parse-sources-boundary-whitespace-output-returns-empty-list () + "Test that whitespace-only output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-sources-error-malformed-output-returns-empty-list () + "Test that malformed output is handled gracefully." + (let ((fixture-output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-parse-sources-error-mixed-valid-invalid-returns-valid-only () + "Test that mix of valid and invalid lines returns only valid entries." + (let ((mixed-output (concat + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "invalid line\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) mixed-output))) + (let ((result (cj/recording-parse-sources))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))))) + +(provide 'test-video-audio-recording-parse-sources) +;;; test-video-audio-recording-parse-sources.el ends here |
