diff options
Diffstat (limited to 'tests')
37 files changed, 3728 insertions, 0 deletions
diff --git a/tests/fixtures/grammar-correct.txt b/tests/fixtures/grammar-correct.txt new file mode 100644 index 00000000..bea335e8 --- /dev/null +++ b/tests/fixtures/grammar-correct.txt @@ -0,0 +1,5 @@ +This is a well-written sentence with no grammar errors. + +The quick brown fox jumps over the lazy dog. + +Everything here follows standard English grammar rules. diff --git a/tests/fixtures/grammar-errors-basic.txt b/tests/fixtures/grammar-errors-basic.txt new file mode 100644 index 00000000..c2f72c12 --- /dev/null +++ b/tests/fixtures/grammar-errors-basic.txt @@ -0,0 +1,7 @@ +This are a test of basic grammar errors. + +I could of done better with this sentence. + +Their going to the store to buy there groceries. + +The dog wagged it's tail happily. diff --git a/tests/fixtures/grammar-errors-punctuation.txt b/tests/fixtures/grammar-errors-punctuation.txt new file mode 100644 index 00000000..37de646a --- /dev/null +++ b/tests/fixtures/grammar-errors-punctuation.txt @@ -0,0 +1,5 @@ +This sentence is missing punctuation at the end + +Multiple spaces between words should be detected. + +A sentence with,incorrect comma,placement and usage. diff --git a/tests/fixtures/pactl-output-empty.txt b/tests/fixtures/pactl-output-empty.txt new file mode 100644 index 00000000..e69de29b --- /dev/null +++ b/tests/fixtures/pactl-output-empty.txt diff --git a/tests/fixtures/pactl-output-inputs-only.txt b/tests/fixtures/pactl-output-inputs-only.txt new file mode 100644 index 00000000..1840b37c --- /dev/null +++ b/tests/fixtures/pactl-output-inputs-only.txt @@ -0,0 +1,3 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-malformed.txt b/tests/fixtures/pactl-output-malformed.txt new file mode 100644 index 00000000..a37b8dd6 --- /dev/null +++ b/tests/fixtures/pactl-output-malformed.txt @@ -0,0 +1,4 @@ +This is not valid pactl output +Some random text +50 incomplete-line-missing-fields +Another bad line with only two tabs diff --git a/tests/fixtures/pactl-output-monitors-only.txt b/tests/fixtures/pactl-output-monitors-only.txt new file mode 100644 index 00000000..be29ebe8 --- /dev/null +++ b/tests/fixtures/pactl-output-monitors-only.txt @@ -0,0 +1,3 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz RUNNING +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-normal.txt b/tests/fixtures/pactl-output-normal.txt new file mode 100644 index 00000000..6d8d955b --- /dev/null +++ b/tests/fixtures/pactl-output-normal.txt @@ -0,0 +1,6 @@ +49 alsa_output.pci-0000_00_1f.3.analog-stereo.monitor PipeWire s32le 2ch 48000Hz SUSPENDED +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED +79 bluez_input.00:1B:66:C0:91:6D PipeWire float32le 1ch 48000Hz SUSPENDED +81 bluez_output.00_1B_66_C0_91_6D.1.monitor PipeWire s24le 2ch 48000Hz SUSPENDED +99 alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor PipeWire s16le 2ch 48000Hz SUSPENDED +100 alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback PipeWire s16le 1ch 16000Hz SUSPENDED diff --git a/tests/fixtures/pactl-output-single.txt b/tests/fixtures/pactl-output-single.txt new file mode 100644 index 00000000..d1d1c254 --- /dev/null +++ b/tests/fixtures/pactl-output-single.txt @@ -0,0 +1 @@ +50 alsa_input.pci-0000_00_1f.3.analog-stereo PipeWire s32le 2ch 48000Hz SUSPENDED diff --git a/tests/test-flycheck-languagetool-setup.el b/tests/test-flycheck-languagetool-setup.el new file mode 100644 index 00000000..a719e822 --- /dev/null +++ b/tests/test-flycheck-languagetool-setup.el @@ -0,0 +1,71 @@ +;;; test-flycheck-languagetool-setup.el --- Unit tests for LanguageTool setup -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests verifying LanguageTool installation and wrapper script setup. +;; Focus: Testing OUR code (wrapper script, file setup), not flycheck internals. +;; +;; We trust that flycheck works correctly (it's an external framework). +;; These tests verify: +;; - LanguageTool is installed and accessible +;; - Our wrapper script exists, is executable, and has correct structure +;; - Python 3 dependency is available +;; +;; Categories: Normal (installation checks), Boundary (script structure), Error (missing dependencies) + +;;; Code: + +(require 'ert) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-exists () + "Test that languagetool-flycheck wrapper script exists." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (should (file-exists-p wrapper-path)))) + +(ert-deftest test-flycheck-languagetool-setup-normal-wrapper-executable () + "Test that languagetool-flycheck wrapper script is executable." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (should (file-executable-p wrapper-path)))) + +(ert-deftest test-flycheck-languagetool-setup-normal-languagetool-installed () + "Test that languagetool command is available in PATH." + (should (executable-find "languagetool"))) + +(ert-deftest test-flycheck-languagetool-setup-normal-python3-available () + "Test that python3 is available for wrapper script." + (should (executable-find "python3"))) + + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-boundary-wrapper-script-format () + "Test that wrapper script has correct shebang and structure." + (let ((wrapper-path (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (with-temp-buffer + (insert-file-contents wrapper-path) + (goto-char (point-min)) + ;; Check shebang + (should (looking-at "#!/usr/bin/env python3")) + ;; Check it contains required imports + (should (search-forward "import json" nil t)) + (should (search-forward "import subprocess" nil t))))) + +;; ----------------------------- Error Cases ----------------------------------- + +(ert-deftest test-flycheck-languagetool-setup-error-missing-file-argument () + "Test that wrapper script requires file argument. +When called without arguments, wrapper should exit with error." + (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil)) + ;; Should exit with non-zero status when no file provided + (should-not (= 0 exit-code)) + ;; Should print usage message to stderr (captured in buffer) + (goto-char (point-min)) + (should (or (search-forward "Usage:" nil t) + (search-forward "FILE" nil t)))))) + +(provide 'test-flycheck-languagetool-setup) +;;; test-flycheck-languagetool-setup.el ends here diff --git a/tests/test-integration-grammar-checking.el b/tests/test-integration-grammar-checking.el new file mode 100644 index 00000000..8948c17a --- /dev/null +++ b/tests/test-integration-grammar-checking.el @@ -0,0 +1,190 @@ +;;; test-integration-grammar-checking.el --- Integration tests for grammar checking -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests for the LanguageTool wrapper script with real grammar checking. +;; Tests the integration: test fixture → wrapper script → LanguageTool → formatted output +;; +;; Components integrated: +;; - scripts/languagetool-flycheck (our wrapper script) +;; - languagetool command (external grammar checker) +;; - Test fixtures with known grammar errors +;; - Output formatting (JSON → flycheck format) +;; +;; Focus: Testing OUR integration code (wrapper), not flycheck framework. +;; We trust that flycheck works; we test that our wrapper produces correct output. +;; +;; Categories: Normal workflow, Boundary cases, Error handling + +;;; Code: + +(require 'ert) + +;; ----------------------------- Test Helpers ---------------------------------- + +(defun test-integration-grammar--fixture-path (filename) + "Return absolute path to test fixture FILENAME." + (expand-file-name (concat "tests/fixtures/" filename) + user-emacs-directory)) + +(defun test-integration-grammar--wrapper-output (file-path) + "Run languagetool-flycheck wrapper directly on FILE-PATH. +Returns output as string." + (let ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck"))) + (with-temp-buffer + (call-process wrapper nil t nil file-path) + (buffer-string)))) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-integration-grammar-checking-normal-wrapper-detects-errors () + "Test that wrapper script detects grammar errors in fixture. + +Components integrated: +- scripts/languagetool-flycheck (wrapper script) +- languagetool command (external checker) +- Test fixture with known errors" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture))) + ;; Should detect "This are" error + (should (string-match-p "PLURAL_VERB_AFTER_THIS\\|This are" output)) + ;; Should detect "could of" error + (should (string-match-p "COULD_OF\\|could of" output)) + ;; Output should be in flycheck format (filename:line:column:) + (should (string-match-p "grammar-errors-basic\\.txt:[0-9]+:[0-9]+:" output)))) + +(ert-deftest test-integration-grammar-checking-normal-wrapper-format () + "Test that wrapper outputs flycheck-compatible format. + +Components integrated: +- scripts/languagetool-flycheck (output formatting) +- languagetool command (JSON parsing)" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + (dolist (line lines) + ;; Each line should match: filename:line:column: message + (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line))))) + +(ert-deftest test-integration-grammar-checking-normal-correct-text-no-errors () + "Test that grammatically correct text produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (wrapper script) +- languagetool command (validation) +- Test fixture with correct grammar" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-correct.txt")) + (output (test-integration-grammar--wrapper-output fixture))) + ;; Correct grammar should produce no output (or only whitespace) + (should (or (string-empty-p (string-trim output)) + (= 0 (length (string-trim output))))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-integration-grammar-checking-boundary-empty-file () + "Test that empty file produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (empty input handling) +- languagetool command" + (let ((temp-file (make-temp-file "grammar-test-" nil ".txt"))) + (unwind-protect + (let ((output (test-integration-grammar--wrapper-output temp-file))) + (should (or (string-empty-p (string-trim output)) + (= 0 (length (string-trim output)))))) + (delete-file temp-file)))) + +(ert-deftest test-integration-grammar-checking-boundary-single-word () + "Test that single word file produces no errors. + +Components integrated: +- scripts/languagetool-flycheck (minimal input) +- languagetool command" + (let ((temp-file (make-temp-file "grammar-test-" nil ".txt"))) + (unwind-protect + (progn + (with-temp-file temp-file + (insert "Hello")) + (let ((output (test-integration-grammar--wrapper-output temp-file))) + ;; Single word might produce no errors or might flag as incomplete sentence + ;; Just verify it doesn't crash + (should (stringp output)))) + (delete-file temp-file)))) + +(ert-deftest test-integration-grammar-checking-boundary-multiple-paragraphs () + "Test that file with multiple paragraphs is checked completely. + +Components integrated: +- scripts/languagetool-flycheck (multi-paragraph handling) +- languagetool command (full file processing)" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + ;; Should detect errors in multiple lines + ;; Check that we have multiple error reports with different line numbers + (let ((line-numbers '())) + (dolist (line lines) + (when (string-match ":[0-9]+:" line) + (let ((line-num (string-to-number + (nth 1 (split-string line ":"))))) + (push line-num line-numbers)))) + ;; Should have errors from multiple lines + (should (> (length (delete-dups line-numbers)) 1))))) + +;; ----------------------------- Error Cases ----------------------------------- + +(ert-deftest test-integration-grammar-checking-error-nonexistent-file () + "Test that wrapper handles nonexistent file with error. + +Components integrated: +- scripts/languagetool-flycheck (error handling) +- File system (missing file) +- Python exception handling" + (let* ((nonexistent "/tmp/this-file-does-not-exist-12345.txt") + (wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil) + (output nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil nonexistent)) + (setq output (buffer-string))) + ;; LanguageTool/Python should handle the error + ;; Check that we get output (error message or error in flycheck format) + (should (stringp output)) + ;; Output should contain some indication of the error (filename or error marker) + (should (or (string-match-p nonexistent output) + (string-match-p "error" output) + (string-match-p "Error" output) + ;; Or it might report no errors for a nonexistent file + (string-empty-p (string-trim output)))))) + +(ert-deftest test-integration-grammar-checking-error-no-file-argument () + "Test that wrapper requires file argument. + +Components integrated: +- scripts/languagetool-flycheck (argument validation)" + (let* ((wrapper (expand-file-name "~/.emacs.d/scripts/languagetool-flycheck")) + (exit-code nil)) + (with-temp-buffer + (setq exit-code (call-process wrapper nil t nil)) + ;; Should exit with non-zero status when no file provided + (should-not (= 0 exit-code))))) + +;; ----------------------------- Integration with Real Files ------------------- + +(ert-deftest test-integration-grammar-checking-integration-comprehensive-errors () + "Test that wrapper catches multiple types of grammar errors in one file. + +Components integrated: +- scripts/languagetool-flycheck (our wrapper) +- languagetool command (comprehensive checking) +- Test fixture with various error types" + (let* ((fixture (test-integration-grammar--fixture-path "grammar-errors-basic.txt")) + (output (test-integration-grammar--wrapper-output fixture)) + (lines (split-string output "\n" t))) + ;; Should detect multiple errors (at least 3-4 in the fixture) + (should (>= (length lines) 3)) + ;; All lines should be properly formatted + (dolist (line lines) + (should (string-match "^[^:]+:[0-9]+:[0-9]+: " line))))) + +(provide 'test-integration-grammar-checking) +;;; test-integration-grammar-checking.el ends here diff --git a/tests/test-integration-recording-device-workflow.el b/tests/test-integration-recording-device-workflow.el new file mode 100644 index 00000000..ba92d700 --- /dev/null +++ b/tests/test-integration-recording-device-workflow.el @@ -0,0 +1,232 @@ +;;; test-integration-recording-device-workflow.el --- Integration tests for recording device workflow -*- lexical-binding: t; -*- + +;;; Commentary: +;; Integration tests covering the complete device detection and grouping workflow. +;; +;; This tests the full pipeline from raw pactl output through parsing, grouping, +;; and friendly name assignment. The workflow enables users to select audio devices +;; for recording calls/meetings. +;; +;; Components integrated: +;; - cj/recording--parse-pactl-output (parse raw pactl output into structured data) +;; - cj/recording-parse-sources (shell command wrapper) +;; - cj/recording-group-devices-by-hardware (group inputs/monitors by device) +;; - cj/recording-friendly-state (convert technical state names) +;; - Bluetooth MAC address normalization (colons → underscores) +;; - Device name pattern matching (USB, PCI, Bluetooth) +;; - Friendly name assignment (user-facing device names) +;; +;; Critical integration points: +;; - Parse output must produce data that group-devices can process +;; - Bluetooth MAC normalization must work across parse→group boundary +;; - Incomplete devices (only mic OR only monitor) must be filtered +;; - Friendly names must correctly identify device types + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases - Complete Workflow + +(ert-deftest test-integration-recording-device-workflow-parse-to-group-all-devices () + "Test complete workflow from pactl output to grouped devices. + +When pactl output contains all three device types (built-in, USB, Bluetooth), +the workflow should parse, group, and assign friendly names to all devices. + +Components integrated: +- cj/recording--parse-pactl-output (parsing) +- cj/recording-group-devices-by-hardware (grouping + MAC normalization) +- Device pattern matching (USB/PCI/Bluetooth detection) +- Friendly name assignment + +Validates: +- All three device types are detected +- Bluetooth MAC addresses normalized (colons → underscores) +- Each device has both mic and monitor +- Friendly names correctly assigned +- Complete data flow: raw output → parsed list → grouped pairs" + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Test parse step + (let ((parsed (cj/recording-parse-sources))) + (should (= 6 (length parsed))) + + ;; Test group step (receives parsed data) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 3 (length grouped))) + + ;; Validate built-in device + (let ((built-in (assoc "Built-in Laptop Audio" grouped))) + (should built-in) + (should (string-prefix-p "alsa_input.pci" (cadr built-in))) + (should (string-prefix-p "alsa_output.pci" (cddr built-in)))) + + ;; Validate USB device + (let ((usb (assoc "Jabra SPEAK 510 USB" grouped))) + (should usb) + (should (string-match-p "Jabra" (cadr usb))) + (should (string-match-p "Jabra" (cddr usb)))) + + ;; Validate Bluetooth device (CRITICAL: MAC normalization) + (let ((bluetooth (assoc "Bluetooth Headset" grouped))) + (should bluetooth) + ;; Input has colons + (should (string-match-p "00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores + (should (string-match-p "00_1B_66_C0_91_6D" (cddr bluetooth))) + ;; But they're grouped together! + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))))) + +(ert-deftest test-integration-recording-device-workflow-friendly-states-in-list () + "Test that friendly state names appear in device list output. + +When listing devices, technical state names (SUSPENDED, RUNNING) should be +converted to friendly names (Ready, Active) for better UX. + +Components integrated: +- cj/recording-parse-sources (parsing with state) +- cj/recording-friendly-state (state name conversion) + +Validates: +- SUSPENDED → Ready +- RUNNING → Active +- State conversion works across the parse workflow" + (let ((output (concat + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Verify states are parsed correctly + (should (equal "SUSPENDED" (nth 2 (nth 0 parsed)))) + (should (equal "RUNNING" (nth 2 (nth 1 parsed)))) + + ;; Verify friendly conversion works + (should (equal "Ready" (cj/recording-friendly-state (nth 2 (nth 0 parsed))))) + (should (equal "Active" (cj/recording-friendly-state (nth 2 (nth 1 parsed))))))))) + +;;; Boundary Cases - Incomplete Devices + +(ert-deftest test-integration-recording-device-workflow-incomplete-devices-filtered () + "Test that devices with only mic OR only monitor are filtered out. + +For call recording, we need BOTH mic and monitor from the same device. +Incomplete devices should not appear in the grouped output. + +Components integrated: +- cj/recording-parse-sources (parsing all devices) +- cj/recording-group-devices-by-hardware (filtering incomplete pairs) + +Validates: +- Device with only mic is filtered +- Device with only monitor is filtered +- Only complete devices (both mic and monitor) are returned +- Filtering happens at group stage, not parse stage" + (let ((output (concat + ;; Complete device + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Parse sees all 4 devices + (let ((parsed (cj/recording-parse-sources))) + (should (= 4 (length parsed))) + + ;; Group returns only 1 complete device + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Built-in Laptop Audio" (caar grouped)))))))) + +;;; Edge Cases - Bluetooth MAC Normalization + +(ert-deftest test-integration-recording-device-workflow-bluetooth-mac-variations () + "Test Bluetooth MAC normalization with different formats. + +Bluetooth devices use colons in input names but underscores in output names. +The grouping must normalize these to match devices correctly. + +Components integrated: +- cj/recording-parse-sources (preserves original MAC format) +- cj/recording-group-devices-by-hardware (normalizes MAC for matching) +- Base name extraction (regex patterns) +- MAC address transformation (underscores → colons) + +Validates: +- Input with colons (bluez_input.AA:BB:CC:DD:EE:FF) parsed correctly +- Output with underscores (bluez_output.AA_BB_CC_DD_EE_FF) parsed correctly +- Normalization happens during grouping +- Devices paired despite format difference +- Original device names preserved (not mutated)" + (let ((output (concat + "79\tbluez_input.11:22:33:44:55:66\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.11_22_33_44_55_66.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Original formats preserved in parse + (should (string-match-p "11:22:33" (caar parsed))) + (should (string-match-p "11_22_33" (caadr parsed))) + + ;; But grouping matches them + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (= 1 (length grouped))) + (should (equal "Bluetooth Headset" (caar grouped))) + ;; Original names preserved + (should (equal "bluez_input.11:22:33:44:55:66" (cadar grouped))) + (should (equal "bluez_output.11_22_33_44_55_66.1.monitor" (cddar grouped)))))))) + +;;; Error Cases - Malformed Data + +(ert-deftest test-integration-recording-device-workflow-malformed-output-handled () + "Test that malformed pactl output is handled gracefully. + +When pactl output is malformed or unparseable, the workflow should not crash. +It should return empty results at appropriate stages. + +Components integrated: +- cj/recording--parse-pactl-output (malformed line handling) +- cj/recording-group-devices-by-hardware (empty input handling) + +Validates: +- Malformed lines are silently skipped during parse +- Empty parse results don't crash grouping +- Workflow degrades gracefully +- No exceptions thrown" + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((parsed (cj/recording-parse-sources))) + ;; Malformed output produces empty parse + (should (null parsed)) + + ;; Empty parse produces empty grouping (no crash) + (let ((grouped (cj/recording-group-devices-by-hardware))) + (should (null grouped))))))) + +(provide 'test-integration-recording-device-workflow) +;;; test-integration-recording-device-workflow.el ends here diff --git a/tests/test-integration-transcription.el b/tests/test-integration-transcription.el new file mode 100644 index 00000000..96b617bc --- /dev/null +++ b/tests/test-integration-transcription.el @@ -0,0 +1,145 @@ +;;; test-integration-transcription.el --- Integration tests for transcription -*- lexical-binding: t; -*- + +;;; Commentary: +;; End-to-end integration tests for transcription workflow +;; Tests complete workflow with temporary files and mocked processes +;; Categories: Normal workflow, Error handling, Cleanup + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Test Helpers ---------------------------------- + +(defun test-transcription--make-mock-audio-file () + "Create a temporary mock audio file for testing. +Returns the absolute path to the file." + (let ((file (make-temp-file "test-audio-" nil ".m4a"))) + (with-temp-file file + (insert "Mock audio data")) + file)) + +(defun test-transcription--cleanup-output-files (audio-file) + "Delete transcript and log files associated with AUDIO-FILE." + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (when (file-exists-p txt-file) + (delete-file txt-file)) + (when (file-exists-p log-file) + (delete-file log-file)))) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-integration-transcription-output-files-created () + "Test that .txt and .log files are created for audio file." + (let* ((audio-file (test-transcription--make-mock-audio-file)) + (outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (unwind-protect + (progn + ;; Verify output file paths are correct + (should (string-suffix-p ".txt" txt-file)) + (should (string-suffix-p ".log" log-file)) + (should (string= (file-name-sans-extension txt-file) + (file-name-sans-extension audio-file))) + (should (string= (file-name-sans-extension log-file) + (file-name-sans-extension audio-file)))) + ;; Cleanup + (delete-file audio-file) + (test-transcription--cleanup-output-files audio-file)))) + +(ert-deftest test-integration-transcription-validates-file-exists () + "Test that transcription fails for non-existent file." + (should-error + (cj/--start-transcription-process "/nonexistent/audio.m4a") + :type 'user-error)) + +(ert-deftest test-integration-transcription-validates-audio-extension () + "Test that transcription fails for non-audio file." + (let ((non-audio (make-temp-file "test-" nil ".txt"))) + (unwind-protect + (should-error + (cj/--start-transcription-process non-audio) + :type 'user-error) + (delete-file non-audio)))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-integration-transcription-audio-file-detection () + "Test various audio file extensions are accepted." + (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg" "opus")) + (let ((audio-file (make-temp-file "test-audio-" nil (concat "." ext)))) + (unwind-protect + (progn + (should (cj/--audio-file-p audio-file)) + ;; Would start transcription if script existed + ) + (delete-file audio-file))))) + +(ert-deftest test-integration-transcription-filename-with-spaces () + "Test transcription with audio file containing spaces." + (let ((audio-file (make-temp-file "test audio file" nil ".m4a"))) + (unwind-protect + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs)) + (log-file (cdr outputs))) + (should (file-name-absolute-p txt-file)) + (should (file-name-absolute-p log-file))) + (delete-file audio-file)))) + +(ert-deftest test-integration-transcription-filename-with-special-chars () + "Test transcription with special characters in filename." + (let ((audio-file (make-temp-file "test_(final)" nil ".m4a"))) + (unwind-protect + (let* ((outputs (cj/--transcription-output-files audio-file)) + (txt-file (car outputs))) + ;; make-temp-file adds random suffix, so just check it ends with .txt + ;; and contains the special chars + (should (string-suffix-p ".txt" txt-file)) + (should (string-match-p "test_(final)" txt-file))) + (delete-file audio-file)))) + +;; ----------------------------- Cleanup Tests --------------------------------- + +(ert-deftest test-integration-transcription-cleanup-completed () + "Test that completed transcriptions are removed from tracking." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil complete) + (proc3 "file3.m4a" nil error)))) + (cj/--cleanup-completed-transcriptions) + (should (= 1 (length cj/transcriptions-list))) + (should (eq 'running (nth 3 (car cj/transcriptions-list)))))) + +(ert-deftest test-integration-transcription-cleanup-all-complete () + "Test cleanup when all transcriptions are complete." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil complete) + (proc2 "file2.m4a" nil error)))) + (cj/--cleanup-completed-transcriptions) + (should (null cj/transcriptions-list)))) + +(ert-deftest test-integration-transcription-cleanup-preserves-running () + "Test that running transcriptions are not cleaned up." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running)))) + (cj/--cleanup-completed-transcriptions) + (should (= 2 (length cj/transcriptions-list))))) + +;; ----------------------------- Backend Tests --------------------------------- + +(ert-deftest test-integration-transcription-script-path-exists () + "Test that transcription scripts exist in expected location." + (dolist (backend '(local-whisper openai-api)) + (let ((cj/transcribe-backend backend)) + (let ((script (cj/--transcription-script-path))) + (should (file-name-absolute-p script)) + ;; Note: Script may not exist in test environment, just check path format + (should (string-match-p "scripts/" script)))))) + +(provide 'test-integration-transcription) +;;; test-integration-transcription.el ends here diff --git a/tests/test-music-config--append-track-to-m3u-file.el b/tests/test-music-config--append-track-to-m3u-file.el new file mode 100644 index 00000000..2bf3e87d --- /dev/null +++ b/tests/test-music-config--append-track-to-m3u-file.el @@ -0,0 +1,187 @@ +;;; test-music-config--append-track-to-m3u-file.el --- Tests for appending tracks to M3U files -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--append-track-to-m3u-file function. +;; Tests the pure, deterministic helper that appends track paths to M3U files. +;; +;; Test organization: +;; - Normal Cases: Standard append operations +;; - Boundary Cases: Edge conditions (unicode, long paths, special chars) +;; - Error Cases: File errors (missing, read-only, directory instead of file) +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--append-track-to-m3u-file-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--append-track-to-m3u-file-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-empty-file-appends-track () + "Append to brand new empty M3U file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/artist/song.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-with-newline-appends-track () + "Append to file with existing content ending with newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "/home/user/music/first.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-existing-without-newline-appends-track () + "Append to file without trailing newline adds leading newline." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "/home/user/music/first.mp3") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/second.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content "\n" track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-normal-multiple-appends-all-succeed () + "Multiple appends to same file all succeed (allows duplicates)." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track1 "/home/user/music/track1.mp3") + (track2 "/home/user/music/track2.mp3") + (track1-duplicate "/home/user/music/track1.mp3")) + (cj/music--append-track-to-m3u-file track1 m3u-file) + (cj/music--append-track-to-m3u-file track2 m3u-file) + (cj/music--append-track-to-m3u-file track1-duplicate m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (let ((content (buffer-string))) + (should (string= content + (concat track1 "\n" track2 "\n" track1-duplicate "\n")))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-very-long-path-appends-successfully () + "Append very long track path without truncation." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + ;; Create a path that's ~500 chars long + (track-path (concat "/home/user/music/" + (make-string 450 ?a) + "/song.mp3"))) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))) + (should (= (length (buffer-string)) (1+ (length track-path)))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-unicode-appends-successfully () + "Append path with unicode characters preserves UTF-8 encoding." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/中文/artist-名前/song🎵.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-path-with-spaces-appends-successfully () + "Append path with spaces and special characters." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/Artist Name/Album (2024)/01 - Song's Title [Remix].mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) (concat track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-boundary-m3u-with-comments-appends-after () + "Append to M3U file containing comments and metadata." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((existing-content "#EXTM3U\n#EXTINF:-1,Radio Station\nhttp://stream.url/radio\n") + (m3u-file (cj/create-temp-test-file-with-content existing-content "test-playlist-")) + (track-path "/home/user/music/local-track.mp3")) + (cj/music--append-track-to-m3u-file track-path m3u-file) + (with-temp-buffer + (insert-file-contents m3u-file) + (should (string= (buffer-string) + (concat existing-content track-path "\n"))))) + (test-music-config--append-track-to-m3u-file-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--append-track-to-m3u-file-error-nonexistent-file-signals-error () + "Signal error when M3U file doesn't exist." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file "/nonexistent/path/to/playlist.m3u") + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-readonly-file-signals-error () + "Signal error when M3U file is read-only." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-file (cj/create-temp-test-file "test-playlist-")) + (track-path "/home/user/music/song.mp3")) + ;; Make file read-only + (set-file-modes m3u-file #o444) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-file) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(ert-deftest test-music-config--append-track-to-m3u-file-error-directory-not-file-signals-error () + "Signal error when path points to directory instead of file." + (test-music-config--append-track-to-m3u-file-setup) + (unwind-protect + (let* ((m3u-dir (cj/create-test-subdirectory "test-playlist-dir")) + (track-path "/home/user/music/song.mp3")) + (should-error (cj/music--append-track-to-m3u-file track-path m3u-dir) + :type 'error)) + (test-music-config--append-track-to-m3u-file-teardown))) + +(provide 'test-music-config--append-track-to-m3u-file) +;;; test-music-config--append-track-to-m3u-file.el ends here diff --git a/tests/test-music-config--collect-entries-recursive.el b/tests/test-music-config--collect-entries-recursive.el new file mode 100644 index 00000000..d71ceab6 --- /dev/null +++ b/tests/test-music-config--collect-entries-recursive.el @@ -0,0 +1,245 @@ +;;; test-music-config--collect-entries-recursive.el --- Tests for recursive music collection -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--collect-entries-recursive function. +;; Tests the recursive helper that collects music files and directories. +;; +;; Test organization: +;; - Normal Cases: Single level, nested directories, mixed files +;; - Boundary Cases: Hidden files/dirs, non-music files, empty dirs, sorting +;; - Error Cases: Empty root, nonexistent root +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--collect-entries-recursive-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--collect-entries-recursive-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--collect-entries-recursive-normal-single-level-files-and-dirs () + "Collect music files and subdirectories at single level." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files at root + (cj/create-directory-or-file-ensuring-parents "music/song1.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/song2.flac" "") + ;; Create subdirectories + (cj/create-directory-or-file-ensuring-parents "music/artist1/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist2/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist1/" result)) + (should (member "artist2/" result)) + (should (member "song1.mp3" result)) + (should (member "song2.flac" result)) + (should (= (length result) 4)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-nested-directories () + "Collect nested directories multiple levels deep." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create nested structure + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/disc1/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "artist/" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/disc1/" result)) + (should (= (length result) 3)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-normal-mixed-files-at-multiple-levels () + "Collect music files at root, subdirs, and nested subdirs." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Root level file + (cj/create-directory-or-file-ensuring-parents "music/root-track.mp3" "") + ;; Subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/track1.mp3" "") + ;; Nested subdir with file + (cj/create-directory-or-file-ensuring-parents "music/artist/album/" "") + (cj/create-directory-or-file-ensuring-parents "music/artist/album/track2.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "root-track.mp3" result)) + (should (member "artist/" result)) + (should (member "artist/track1.mp3" result)) + (should (member "artist/album/" result)) + (should (member "artist/album/track2.mp3" result)) + (should (= (length result) 5)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-directories-skipped () + "Hidden directories and their contents are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden directory with music file + (cj/create-directory-or-file-ensuring-parents "music/.hidden/" "") + (cj/create-directory-or-file-ensuring-parents "music/.hidden/secret.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden/" result)) + (should-not (member ".hidden/secret.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-hidden-files-skipped () + "Hidden files at root are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Visible file + (cj/create-directory-or-file-ensuring-parents "music/visible.mp3" "") + ;; Hidden file (note: directory-files regex "^[^.].*" should skip it) + (cj/create-directory-or-file-ensuring-parents "music/.hidden-track.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "visible.mp3" result)) + (should-not (member ".hidden-track.mp3" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-non-music-files-excluded () + "Non-music files are excluded." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Music file + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + ;; Non-music files + (cj/create-directory-or-file-ensuring-parents "music/readme.txt" "") + (cj/create-directory-or-file-ensuring-parents "music/cover.jpg" "") + (cj/create-directory-or-file-ensuring-parents "music/info.pdf" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "song.mp3" result)) + (should-not (member "readme.txt" result)) + (should-not (member "cover.jpg" result)) + (should-not (member "info.pdf" result)) + (should (= (length result) 1)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-empty-directories-included () + "Empty subdirectories are still listed with trailing slash." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Empty subdirectories + (cj/create-directory-or-file-ensuring-parents "music/empty-artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/another-empty/" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (member "empty-artist/" result)) + (should (member "another-empty/" result)) + (should (= (length result) 2)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-sorted-output () + "Output is sorted alphabetically (case-insensitive)." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create files in non-alphabetical order + (cj/create-directory-or-file-ensuring-parents "music/zebra.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/Alpha.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/beta.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Should be sorted alphabetically (case-insensitive) + (should (equal result '("Alpha.mp3" "beta.mp3" "zebra.mp3"))))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-directories-have-trailing-slash () + "Directories have trailing slash, files don't." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + (cj/create-directory-or-file-ensuring-parents "music/artist/" "") + (cj/create-directory-or-file-ensuring-parents "music/song.mp3" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + ;; Directory has trailing slash + (should (cl-some (lambda (entry) (string-suffix-p "/" entry)) result)) + ;; File doesn't have trailing slash + (should (cl-some (lambda (entry) (not (string-suffix-p "/" entry))) result)) + ;; Specifically check + (should (member "artist/" result)) + (should (member "song.mp3" result)) + (should-not (member "song.mp3/" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-boundary-all-music-extensions () + "All configured music extensions are collected." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "music"))) + ;; Create file for each extension: aac, flac, m4a, mp3, ogg, opus, wav + (cj/create-directory-or-file-ensuring-parents "music/track.aac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.flac" "") + (cj/create-directory-or-file-ensuring-parents "music/track.m4a" "") + (cj/create-directory-or-file-ensuring-parents "music/track.mp3" "") + (cj/create-directory-or-file-ensuring-parents "music/track.ogg" "") + (cj/create-directory-or-file-ensuring-parents "music/track.opus" "") + (cj/create-directory-or-file-ensuring-parents "music/track.wav" "") + + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (= (length result) 7)) + (should (member "track.aac" result)) + (should (member "track.flac" result)) + (should (member "track.m4a" result)) + (should (member "track.mp3" result)) + (should (member "track.ogg" result)) + (should (member "track.opus" result)) + (should (member "track.wav" result)))) + (test-music-config--collect-entries-recursive-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--collect-entries-recursive-error-empty-root-returns-empty () + "Empty root directory returns empty list." + (test-music-config--collect-entries-recursive-setup) + (unwind-protect + (let* ((root-dir (cj/create-test-subdirectory "empty-music"))) + (let ((result (cj/music--collect-entries-recursive root-dir))) + (should (null result)))) + (test-music-config--collect-entries-recursive-teardown))) + +(ert-deftest test-music-config--collect-entries-recursive-error-nonexistent-root-returns-empty () + "Nonexistent directory returns empty list." + (let ((result (cj/music--collect-entries-recursive "/nonexistent/path/to/music"))) + (should (null result)))) + +(provide 'test-music-config--collect-entries-recursive) +;;; test-music-config--collect-entries-recursive.el ends here diff --git a/tests/test-music-config--completion-table.el b/tests/test-music-config--completion-table.el new file mode 100644 index 00000000..5be0479d --- /dev/null +++ b/tests/test-music-config--completion-table.el @@ -0,0 +1,134 @@ +;;; test-music-config--completion-table.el --- Tests for completion table generation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--completion-table function. +;; Tests the completion table generator that creates custom completion tables. +;; +;; Test organization: +;; - Normal Cases: Metadata, completions, case-insensitive matching +;; - Boundary Cases: Empty candidates, partial matching, exact matches +;; - Error Cases: Nil candidates +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--completion-table-normal-metadata-action-returns-metadata () + "Completion table returns metadata when action is 'metadata." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil 'metadata))) + (should (eq (car result) 'metadata)) + ;; Check metadata contains expected properties + (should (equal (alist-get 'display-sort-function (cdr result)) 'identity)) + (should (equal (alist-get 'cycle-sort-function (cdr result)) 'identity)) + (should (eq (alist-get 'completion-ignore-case (cdr result)) t)))) + +(ert-deftest test-music-config--completion-table-normal-t-action-returns-all-completions () + "Completion table returns all matching completions when action is t." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "" nil t))) + ;; Empty string should match all candidates + (should (equal (sort result #'string<) '("Classical" "Jazz" "Rock"))))) + +(ert-deftest test-music-config--completion-table-normal-nil-action-tries-completion () + "Completion table tries completion when action is nil." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Roc" nil nil))) + ;; Should return completion attempt for "Roc" -> "Rock" + (should (stringp result)) + (should (string-prefix-p "Roc" result)))) + +(ert-deftest test-music-config--completion-table-normal-case-insensitive-metadata () + "Completion table metadata indicates case-insensitive completion." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (metadata (funcall table "" nil 'metadata))) + ;; Metadata should indicate case-insensitive + (should (eq (alist-get 'completion-ignore-case (cdr metadata)) t)))) + +;;; Boundary Cases + +(ert-deftest test-music-config--completion-table-boundary-empty-candidates () + "Completion table with empty candidate list returns no completions." + (let* ((candidates '()) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-single-candidate () + "Completion table with single candidate returns it on match." + (let* ((candidates '("OnlyOne")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Only" nil t))) + (should (equal result '("OnlyOne"))))) + +(ert-deftest test-music-config--completion-table-boundary-partial-matching () + "Completion table matches multiple candidates with common prefix." + (let* ((candidates '("playlist1" "playlist2" "jazz")) + (table (cj/music--completion-table candidates)) + (result (funcall table "play" nil t))) + (should (= (length result) 2)) + (should (member "playlist1" result)) + (should (member "playlist2" result)) + (should-not (member "jazz" result)))) + +(ert-deftest test-music-config--completion-table-boundary-no-matches () + "Completion table returns empty when no candidates match." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Metal" nil t))) + (should (null result)))) + +(ert-deftest test-music-config--completion-table-boundary-exact-match () + "Completion table returns t for exact match with nil action." + (let* ((candidates '("Rock" "Jazz" "Classical")) + (table (cj/music--completion-table candidates)) + (result (funcall table "Jazz" nil nil))) + ;; Exact match with nil action returns t + (should (eq result t)))) + +(ert-deftest test-music-config--completion-table-boundary-mixed-case-candidates () + "Completion table with mixed-case duplicate candidates." + (let* ((candidates '("Rock" "ROCK" "rock")) + (table (cj/music--completion-table candidates)) + (result (funcall table "R" nil t))) + ;; All start with "R", but exact case matters for complete-with-action + ;; Only exact case match "R" prefix + (should (member "Rock" result)) + (should (member "ROCK" result)) + ;; "rock" doesn't match "R" prefix (lowercase) + (should-not (member "rock" result)))) + +(ert-deftest test-music-config--completion-table-boundary-unicode-candidates () + "Completion table handles unicode characters in candidates." + (let* ((candidates '("中文" "日本語" "한국어")) + (table (cj/music--completion-table candidates)) + (result (funcall table "中" nil t))) + (should (member "中文" result)))) + +;;; Error Cases + +(ert-deftest test-music-config--completion-table-error-nil-candidates-handles-gracefully () + "Completion table with nil candidates handles gracefully." + (let* ((candidates nil) + (table (cj/music--completion-table candidates)) + (result (funcall table "anything" nil t))) + ;; Should not crash, returns empty + (should (null result)))) + +(provide 'test-music-config--completion-table) +;;; test-music-config--completion-table.el ends here diff --git a/tests/test-music-config--get-m3u-basenames.el b/tests/test-music-config--get-m3u-basenames.el new file mode 100644 index 00000000..91c8af70 --- /dev/null +++ b/tests/test-music-config--get-m3u-basenames.el @@ -0,0 +1,121 @@ +;;; test-music-config--get-m3u-basenames.el --- Tests for M3U basename extraction -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-basenames function. +;; Tests the helper that extracts M3U basenames (without .m3u extension). +;; +;; Test organization: +;; - Normal Cases: Multiple files, single file +;; - Boundary Cases: Empty directory, extension removal +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-basenames-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-basenames-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-basenames-normal-multiple-files-returns-basenames () + "Extract basenames from multiple M3U files without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "rock.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "jazz.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "classical.m3u"))) + (rename-file file1 (expand-file-name "rock.m3u" test-dir)) + (rename-file file2 (expand-file-name "jazz.m3u" test-dir)) + (rename-file file3 (expand-file-name "classical.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 3)) + ;; Sort for consistent comparison + (let ((sorted-result (sort result #'string<))) + (should (equal sorted-result '("classical" "jazz" "rock"))))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-normal-single-file-returns-basename () + "Extract basename from single M3U file without .m3u extension." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "favorites.m3u"))) + (rename-file file1 (expand-file-name "favorites.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (= (length result) 1)) + (should (equal (car result) "favorites"))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-basenames-boundary-empty-directory-returns-empty () + "Extract basenames from empty directory returns empty list." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (null result))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-extension-removed () + "Basenames have .m3u extension removed." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "playlist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("playlist"))) + ;; Verify no .m3u extension present + (should-not (string-match-p "\\.m3u" (car result)))))) + (test-music-config--get-m3u-basenames-teardown))) + +(ert-deftest test-music-config--get-m3u-basenames-boundary-spaces-in-filename-preserved () + "Basenames with spaces preserve the spaces." + (test-music-config--get-m3u-basenames-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "test.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-basenames))) + (should (equal result '("My Favorite Songs")))))) + (test-music-config--get-m3u-basenames-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-basenames-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-basenames) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-basenames) +;;; test-music-config--get-m3u-basenames.el ends here diff --git a/tests/test-music-config--get-m3u-files.el b/tests/test-music-config--get-m3u-files.el new file mode 100644 index 00000000..2d31d554 --- /dev/null +++ b/tests/test-music-config--get-m3u-files.el @@ -0,0 +1,150 @@ +;;; test-music-config--get-m3u-files.el --- Tests for M3U file discovery -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--get-m3u-files function. +;; Tests the helper that discovers M3U files in the music directory. +;; +;; Test organization: +;; - Normal Cases: Multiple M3U files, single file +;; - Boundary Cases: Empty directory, non-M3U files, various filenames +;; - Error Cases: Nonexistent directory +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--get-m3u-files-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--get-m3u-files-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--get-m3u-files-normal-multiple-files-returns-list () + "Discover multiple M3U files returns list of (basename . fullpath) conses." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "playlist1.m3u")) + (file2 (cj/create-temp-test-file-with-content "" "playlist2.m3u")) + (file3 (cj/create-temp-test-file-with-content "" "playlist3.m3u"))) + ;; Move files to test-dir + (rename-file file1 (expand-file-name "playlist1.m3u" test-dir)) + (rename-file file2 (expand-file-name "playlist2.m3u" test-dir)) + (rename-file file3 (expand-file-name "playlist3.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 3)) + ;; Check structure: list of (basename . fullpath) conses + ;; Sort for consistent comparison (directory-files order is filesystem-dependent) + (let ((basenames (sort (mapcar #'car result) #'string<)) + (fullpaths (sort (mapcar #'cdr result) #'string<))) + (should (equal basenames '("playlist1.m3u" "playlist2.m3u" "playlist3.m3u"))) + (should (equal fullpaths + (list (expand-file-name "playlist1.m3u" test-dir) + (expand-file-name "playlist2.m3u" test-dir) + (expand-file-name "playlist3.m3u" test-dir)))))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-normal-single-file-returns-list () + "Discover single M3U file returns single-item list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "myplaylist.m3u"))) + (rename-file file1 (expand-file-name "myplaylist.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "myplaylist.m3u")) + (should (equal (cdar result) (expand-file-name "myplaylist.m3u" test-dir)))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--get-m3u-files-boundary-empty-directory-returns-empty () + "Discover M3U files in empty directory returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "empty-playlists"))) + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-non-m3u-files-ignored () + "Directory with non-M3U files returns empty list." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed-files")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3")) + (json-file (cj/create-temp-test-file-with-content "" "data.json"))) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + (rename-file json-file (expand-file-name "data.json" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (null result))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-m3u-with-spaces-included () + "M3U files with spaces in name are discovered." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "playlists")) + (file1 (cj/create-temp-test-file-with-content "" "my-playlist.m3u"))) + (rename-file file1 (expand-file-name "My Favorite Songs.m3u" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "My Favorite Songs.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +(ert-deftest test-music-config--get-m3u-files-boundary-mixed-m3u-and-other-files () + "Directory with both M3U and non-M3U files returns only M3U files." + (test-music-config--get-m3u-files-setup) + (unwind-protect + (let* ((test-dir (cj/create-test-subdirectory "mixed")) + (m3u-file (cj/create-temp-test-file-with-content "" "playlist.m3u")) + (txt-file (cj/create-temp-test-file-with-content "" "readme.txt")) + (mp3-file (cj/create-temp-test-file-with-content "" "song.mp3"))) + (rename-file m3u-file (expand-file-name "playlist.m3u" test-dir)) + (rename-file txt-file (expand-file-name "readme.txt" test-dir)) + (rename-file mp3-file (expand-file-name "song.mp3" test-dir)) + + (let ((cj/music-m3u-root test-dir)) + (let ((result (cj/music--get-m3u-files))) + (should (= (length result) 1)) + (should (equal (caar result) "playlist.m3u"))))) + (test-music-config--get-m3u-files-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--get-m3u-files-error-nonexistent-directory-signals-error () + "Nonexistent directory signals error." + (let ((cj/music-m3u-root "/nonexistent/directory/path")) + (should-error (cj/music--get-m3u-files) + :type 'file-error))) + +(provide 'test-music-config--get-m3u-files) +;;; test-music-config--get-m3u-files.el ends here diff --git a/tests/test-music-config--m3u-file-tracks.el b/tests/test-music-config--m3u-file-tracks.el new file mode 100644 index 00000000..badc9817 --- /dev/null +++ b/tests/test-music-config--m3u-file-tracks.el @@ -0,0 +1,193 @@ +;;; test-music-config--m3u-file-tracks.el --- Tests for M3U file parsing -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--m3u-file-tracks function. +;; Tests the M3U parser that extracts track paths from playlist files. +;; +;; Test organization: +;; - Normal Cases: Absolute paths, relative paths, URLs (http/https/mms) +;; - Boundary Cases: Empty lines, whitespace, comments, order preservation +;; - Error Cases: Nonexistent files, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--m3u-file-tracks-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--m3u-file-tracks-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--m3u-file-tracks-normal-absolute-paths-returns-list () + "Parse M3U with absolute paths returns list in order." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n/home/user/music/track2.mp3\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-relative-paths-expanded () + "Parse M3U with relative paths expands them relative to M3U directory." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "artist/track1.mp3\nartist/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list (expand-file-name "artist/track1.mp3" m3u-dir) + (expand-file-name "artist/track2.mp3" m3u-dir))))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-http-urls-preserved () + "Parse M3U with http:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "http://example.com/stream1.mp3\nhttp://example.com/stream2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("http://example.com/stream1.mp3" + "http://example.com/stream2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-https-urls-preserved () + "Parse M3U with https:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "https://secure.example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("https://secure.example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mms-urls-preserved () + "Parse M3U with mms:// URLs preserves them as-is." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "mms://radio.example.com/stream\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("mms://radio.example.com/stream")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-normal-mixed-paths-and-urls () + "Parse M3U with mix of absolute, relative, and URLs handles all correctly." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/local.mp3\nartist/relative.mp3\nhttp://example.com/stream.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (m3u-dir (file-name-directory m3u-file)) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks (list "/home/user/music/local.mp3" + (expand-file-name "artist/relative.mp3" m3u-dir) + "http://example.com/stream.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-lines-ignored () + "Parse M3U with empty lines ignores them and returns tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n\n/home/user/music/track2.mp3\n\n\n/home/user/music/track3.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3" + "/home/user/music/track3.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-whitespace-only-lines-ignored () + "Parse M3U with whitespace-only lines ignores them." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/home/user/music/track1.mp3\n \n\t\t\n/home/user/music/track2.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-comments-ignored () + "Parse M3U with comment lines ignores them, returns only tracks." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Track Title\n/home/user/music/track.mp3\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-leading-trailing-whitespace-trimmed () + "Parse M3U with whitespace around paths trims it." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content " /home/user/music/track1.mp3 \n\t/home/user/music/track2.mp3\t\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/home/user/music/track1.mp3" + "/home/user/music/track2.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-empty-file-returns-nil () + "Parse empty M3U file returns nil." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-only-comments-returns-empty () + "Parse M3U with only comments returns empty list." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "#EXTM3U\n#EXTINF:-1,Title\n#Another comment\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (null tracks))) + (test-music-config--m3u-file-tracks-teardown))) + +(ert-deftest test-music-config--m3u-file-tracks-boundary-preserves-order () + "Parse M3U preserves track order (tests nreverse)." + (test-music-config--m3u-file-tracks-setup) + (unwind-protect + (let* ((content "/track1.mp3\n/track2.mp3\n/track3.mp3\n/track4.mp3\n/track5.mp3\n") + (m3u-file (cj/create-temp-test-file-with-content content "test.m3u")) + (tracks (cj/music--m3u-file-tracks m3u-file))) + (should (equal tracks '("/track1.mp3" "/track2.mp3" "/track3.mp3" "/track4.mp3" "/track5.mp3")))) + (test-music-config--m3u-file-tracks-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--m3u-file-tracks-error-nonexistent-file-returns-nil () + "Parse nonexistent file returns nil." + (should (null (cj/music--m3u-file-tracks "/nonexistent/path/playlist.m3u")))) + +(ert-deftest test-music-config--m3u-file-tracks-error-nil-input-returns-nil () + "Parse nil input returns nil gracefully." + (should (null (cj/music--m3u-file-tracks nil)))) + +(provide 'test-music-config--m3u-file-tracks) +;;; test-music-config--m3u-file-tracks.el ends here diff --git a/tests/test-music-config--safe-filename.el b/tests/test-music-config--safe-filename.el new file mode 100644 index 00000000..8105ee15 --- /dev/null +++ b/tests/test-music-config--safe-filename.el @@ -0,0 +1,97 @@ +;;; test-music-config--safe-filename.el --- Tests for filename sanitization -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--safe-filename function. +;; Tests the pure helper that sanitizes filenames by replacing invalid chars. +;; +;; Test organization: +;; - Normal Cases: Valid filenames unchanged, spaces replaced +;; - Boundary Cases: Special chars, unicode, slashes, consecutive invalid chars +;; - Error Cases: Nil input +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--safe-filename-normal-alphanumeric-unchanged () + "Validate alphanumeric filename remains unchanged." + (should (string= (cj/music--safe-filename "MyPlaylist123") + "MyPlaylist123"))) + +(ert-deftest test-music-config--safe-filename-normal-with-hyphens-unchanged () + "Validate filename with hyphens remains unchanged." + (should (string= (cj/music--safe-filename "my-playlist-name") + "my-playlist-name"))) + +(ert-deftest test-music-config--safe-filename-normal-with-underscores-unchanged () + "Validate filename with underscores remains unchanged." + (should (string= (cj/music--safe-filename "my_playlist_name") + "my_playlist_name"))) + +(ert-deftest test-music-config--safe-filename-normal-spaces-replaced () + "Validate spaces are replaced with underscores." + (should (string= (cj/music--safe-filename "My Favorite Songs") + "My_Favorite_Songs"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--safe-filename-boundary-special-chars-replaced () + "Validate special characters are replaced with underscores." + (should (string= (cj/music--safe-filename "playlist@#$%^&*()") + "playlist_________"))) + +(ert-deftest test-music-config--safe-filename-boundary-unicode-replaced () + "Validate unicode characters are replaced with underscores." + (should (string= (cj/music--safe-filename "中文歌曲") + "____"))) + +(ert-deftest test-music-config--safe-filename-boundary-mixed-valid-invalid () + "Validate mixed valid and invalid characters." + (should (string= (cj/music--safe-filename "Rock & Roll") + "Rock___Roll"))) + +(ert-deftest test-music-config--safe-filename-boundary-dots-replaced () + "Validate dots are replaced with underscores." + (should (string= (cj/music--safe-filename "my.playlist.name") + "my_playlist_name"))) + +(ert-deftest test-music-config--safe-filename-boundary-slashes-replaced () + "Validate slashes are replaced with underscores." + (should (string= (cj/music--safe-filename "folder/file") + "folder_file"))) + +(ert-deftest test-music-config--safe-filename-boundary-consecutive-invalid-chars () + "Validate consecutive invalid characters each become underscores." + (should (string= (cj/music--safe-filename "test!!!name") + "test___name"))) + +(ert-deftest test-music-config--safe-filename-boundary-empty-string-unchanged () + "Validate empty string remains unchanged." + (should (string= (cj/music--safe-filename "") + ""))) + +(ert-deftest test-music-config--safe-filename-boundary-only-invalid-chars () + "Validate string with only invalid characters becomes all underscores." + (should (string= (cj/music--safe-filename "!@#$%") + "_____"))) + +;;; Error Cases + +(ert-deftest test-music-config--safe-filename-error-nil-input-signals-error () + "Validate nil input signals error." + (should-error (cj/music--safe-filename nil) + :type 'wrong-type-argument)) + +(provide 'test-music-config--safe-filename) +;;; test-music-config--safe-filename.el ends here diff --git a/tests/test-music-config--valid-directory-p.el b/tests/test-music-config--valid-directory-p.el new file mode 100644 index 00000000..21c2b240 --- /dev/null +++ b/tests/test-music-config--valid-directory-p.el @@ -0,0 +1,139 @@ +;;; test-music-config--valid-directory-p.el --- Tests for directory validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-directory-p function. +;; Tests the pure helper that validates non-hidden directories. +;; +;; Test organization: +;; - Normal Cases: Valid visible directories +;; - Boundary Cases: Trailing slashes, dots in names, hidden directories +;; - Error Cases: Files (not dirs), nonexistent paths, nil input +;; +;;; Code: + +(require 'ert) +(require 'testutil-general) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Setup & Teardown + +(defun test-music-config--valid-directory-p-setup () + "Setup test environment." + (cj/create-test-base-dir)) + +(defun test-music-config--valid-directory-p-teardown () + "Clean up test environment." + (cj/delete-test-base-dir)) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-directory-p-normal-visible-directory-returns-true () + "Validate visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-normal-nested-directory-returns-true () + "Validate nested visible directory returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir/nested"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-directory-p-boundary-trailing-slash-returns-true () + "Validate directory with trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (file-name-as-directory test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-no-trailing-slash-returns-true () + "Validate directory without trailing slash returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + (should (cj/music--valid-directory-p (directory-file-name test-dir)))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-dot-in-middle-returns-true () + "Validate directory with dot in middle of name returns non-nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "my.music.dir"))) + (should (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-directory-returns-nil () + "Validate hidden directory (starting with dot) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory ".hidden"))) + (should-not (cj/music--valid-directory-p test-dir))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-current-dir-dot-returns-nil () + "Validate current directory '.' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir"))) + ;; Change to test dir and check "." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p ".")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-parent-dir-dotdot-returns-nil () + "Validate parent directory '..' returns nil (hidden)." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-dir (cj/create-test-subdirectory "testdir/subdir"))) + ;; Change to subdir and check ".." + (let ((default-directory test-dir)) + (should-not (cj/music--valid-directory-p "..")))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-boundary-hidden-subdir-basename-check () + "Validate hidden subdirectory returns nil based on basename." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((hidden-dir (cj/create-test-subdirectory "visible/.hidden"))) + (should-not (cj/music--valid-directory-p hidden-dir))) + (test-music-config--valid-directory-p-teardown))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-directory-p-error-regular-file-returns-nil () + "Validate regular file (not directory) returns nil." + (test-music-config--valid-directory-p-setup) + (unwind-protect + (let ((test-file (cj/create-temp-test-file "testfile-"))) + (should-not (cj/music--valid-directory-p test-file))) + (test-music-config--valid-directory-p-teardown))) + +(ert-deftest test-music-config--valid-directory-p-error-nonexistent-path-returns-nil () + "Validate nonexistent path returns nil." + (should-not (cj/music--valid-directory-p "/nonexistent/path/to/directory"))) + +(ert-deftest test-music-config--valid-directory-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-directory-p nil))) + +(ert-deftest test-music-config--valid-directory-p-error-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-directory-p ""))) + +(provide 'test-music-config--valid-directory-p) +;;; test-music-config--valid-directory-p.el ends here diff --git a/tests/test-music-config--valid-file-p.el b/tests/test-music-config--valid-file-p.el new file mode 100644 index 00000000..8099c50c --- /dev/null +++ b/tests/test-music-config--valid-file-p.el @@ -0,0 +1,99 @@ +;;; test-music-config--valid-file-p.el --- Tests for music file validation -*- coding: utf-8; lexical-binding: t; -*- +;; +;; Author: Craig Jennings <c@cjennings.net> +;; +;;; Commentary: +;; Unit tests for cj/music--valid-file-p function. +;; Tests the pure, deterministic helper that validates music file extensions. +;; +;; Test organization: +;; - Normal Cases: Valid music extensions (case-insensitive) +;; - Boundary Cases: Edge conditions (no extension, dots in path, empty strings) +;; - Error Cases: Invalid extensions, nil input +;; +;;; Code: + +(require 'ert) + +;; Stub missing dependencies before loading music-config +(defvar-keymap cj/custom-keymap + :doc "Stub keymap for testing") + +;; Load production code +(require 'music-config) + +;;; Normal Cases + +(ert-deftest test-music-config--valid-file-p-normal-mp3-extension-returns-true () + "Validate mp3 file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-normal-flac-extension-returns-true () + "Validate flac file extension returns non-nil." + (should (cj/music--valid-file-p "/path/to/song.flac"))) + +(ert-deftest test-music-config--valid-file-p-normal-all-extensions-return-true () + "Validate all configured music extensions return non-nil." + ;; Test each extension from cj/music-file-extensions + (dolist (ext '("aac" "flac" "m4a" "mp3" "ogg" "opus" "wav")) + (should (cj/music--valid-file-p (format "/path/to/song.%s" ext))))) + +(ert-deftest test-music-config--valid-file-p-normal-uppercase-extension-returns-true () + "Validate uppercase extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.MP3"))) + +(ert-deftest test-music-config--valid-file-p-normal-mixed-case-extension-returns-true () + "Validate mixed-case extension returns non-nil (case-insensitive)." + (should (cj/music--valid-file-p "/path/to/song.Mp3")) + (should (cj/music--valid-file-p "/path/to/song.FLaC"))) + +;;; Boundary Cases + +(ert-deftest test-music-config--valid-file-p-boundary-dots-in-path-returns-true () + "Validate file with dots in directory path uses only last extension." + (should (cj/music--valid-file-p "/path/with.dots/in.directory/song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-multiple-extensions-uses-last () + "Validate file with multiple extensions uses rightmost extension." + (should (cj/music--valid-file-p "/path/to/song.backup.mp3")) + (should (cj/music--valid-file-p "/path/to/song.old.flac"))) + +(ert-deftest test-music-config--valid-file-p-boundary-just-filename-with-extension-returns-true () + "Validate bare filename without path returns non-nil." + (should (cj/music--valid-file-p "song.mp3"))) + +(ert-deftest test-music-config--valid-file-p-boundary-no-extension-returns-nil () + "Validate file without extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/song"))) + +(ert-deftest test-music-config--valid-file-p-boundary-dot-at-end-returns-nil () + "Validate file ending with dot (empty extension) returns nil." + (should-not (cj/music--valid-file-p "/path/to/song."))) + +(ert-deftest test-music-config--valid-file-p-boundary-empty-string-returns-nil () + "Validate empty string returns nil." + (should-not (cj/music--valid-file-p ""))) + +;;; Error Cases + +(ert-deftest test-music-config--valid-file-p-error-nil-input-returns-nil () + "Validate nil input returns nil gracefully." + (should-not (cj/music--valid-file-p nil))) + +(ert-deftest test-music-config--valid-file-p-error-non-music-extension-returns-nil () + "Validate non-music file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/document.txt")) + (should-not (cj/music--valid-file-p "/path/to/readme.md"))) + +(ert-deftest test-music-config--valid-file-p-error-image-extension-returns-nil () + "Validate image file extension returns nil." + (should-not (cj/music--valid-file-p "/path/to/cover.jpg")) + (should-not (cj/music--valid-file-p "/path/to/artwork.png"))) + +(ert-deftest test-music-config--valid-file-p-error-video-extension-returns-nil () + "Validate video file extension returns nil (mp4 not in list, only m4a)." + (should-not (cj/music--valid-file-p "/path/to/video.mp4")) + (should-not (cj/music--valid-file-p "/path/to/clip.mkv"))) + +(provide 'test-music-config--valid-file-p) +;;; test-music-config--valid-file-p.el ends here diff --git a/tests/test-org-contacts-capture-finalize.el b/tests/test-org-contacts-capture-finalize.el new file mode 100644 index 00000000..d379a912 --- /dev/null +++ b/tests/test-org-contacts-capture-finalize.el @@ -0,0 +1,217 @@ +;;; test-org-contacts-capture-finalize.el --- Tests for org-contacts capture template finalization -*- lexical-binding: t; -*- + +;; Copyright (C) 2025 Craig Jennings + +;; Author: Craig Jennings <c@cjennings.net> + +;; This program is free software: you can redistribute it and/or modify +;; it under the terms of the GNU General Public License as published by +;; the Free Software Foundation, either version 3 of the License, or +;; (at your option) any later version. + +;;; Commentary: + +;; Unit tests for the org-contacts capture template finalization function +;; that automatically inserts birthday timestamps. + +;;; Code: + +;; Initialize package system for batch mode +(when noninteractive + (package-initialize)) + +(require 'ert) +(require 'org) + +;; Define the function to test (copied from org-contacts-config.el) +(defun cj/org-contacts-finalize-birthday-timestamp () + "Add yearly repeating timestamp after properties drawer if BIRTHDAY is set. +This function is called during `org-capture' finalization to automatically +insert a plain timestamp for birthdays, enabling them to appear in org-agenda +without requiring org-contacts to be loaded in the async subprocess." + (when (string= (plist-get org-capture-plist :key) "C") + (save-excursion + (goto-char (point-min)) + ;; Find the properties drawer + (when (re-search-forward "^:PROPERTIES:" nil t) + (let ((drawer-start (point)) + (drawer-end (save-excursion + (when (re-search-forward "^:END:" nil t) + (point))))) + (when drawer-end + ;; Get BIRTHDAY property value + (goto-char drawer-start) + (when (re-search-forward "^:BIRTHDAY:[ \t]*\\(.+\\)$" drawer-end t) + (let ((birthday-value (string-trim (match-string 1)))) + ;; Only process non-empty birthdays + (when (and birthday-value + (not (string-blank-p birthday-value))) + ;; Parse birthday and create timestamp + (let* ((parsed (cond + ;; Format: YYYY-MM-DD + ((string-match "^\\([0-9]\\{4\\}\\)-\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-value) + (list (string-to-number (match-string 1 birthday-value)) + (string-to-number (match-string 2 birthday-value)) + (string-to-number (match-string 3 birthday-value)))) + ;; Format: MM-DD + ((string-match "^\\([0-9]\\{2\\}\\)-\\([0-9]\\{2\\}\\)$" birthday-value) + (list nil + (string-to-number (match-string 1 birthday-value)) + (string-to-number (match-string 2 birthday-value)))) + (t nil))) + (year (when parsed (or (nth 0 parsed) (nth 5 (decode-time))))) + (month (when parsed (nth 1 parsed))) + (day (when parsed (nth 2 parsed)))) + (when (and year month day) + ;; Create timestamp + (let* ((time (encode-time 0 0 0 day month year)) + (dow (format-time-string "%a" time)) + (date-str (format "%04d-%02d-%02d" year month day)) + (timestamp (format "<%s %s +1y>" date-str dow))) + ;; Insert after :END: if not already present + (goto-char drawer-end) + (let ((heading-end (save-excursion (outline-next-heading) (point)))) + (unless (re-search-forward "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}[^>]*\\+1y>" heading-end t) + (goto-char drawer-end) + (end-of-line) + (insert "\n" timestamp))))))))))))) + +;;; Tests for birthday timestamp finalization + +(ert-deftest test-contacts-capture-finalize-with-full-birthday () + "Test that finalize adds timestamp for YYYY-MM-DD birthday." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + ;; Simulate capture context + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)) + ;; Timestamp should be after :END: + (should (string-match-p ":END:\n<1985-03-15" content)))))) + +(ert-deftest test-contacts-capture-finalize-with-partial-birthday () + "Test that finalize adds timestamp for MM-DD birthday with current year." + (let ((current-year (nth 5 (decode-time)))) + (with-temp-buffer + (org-mode) + (insert "* Bob Baker\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 07-04\n") + (insert ":END:\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; Should have birthday timestamp with current year + (should (string-match-p (format "<%d-07-04 [A-Za-z]\\{3\\} \\+1y>" current-year) content))))))) + +(ert-deftest test-contacts-capture-finalize-without-birthday () + "Test that finalize does nothing when no birthday property." + (with-temp-buffer + (org-mode) + (insert "* Carol Chen\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: carol@example.com\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-with-empty-birthday () + "Test that finalize skips empty birthday values." + (with-temp-buffer + (org-mode) + (insert "* David Davis\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: \n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content)) + ;; Should have no timestamp + (should-not (string-match-p "<[0-9]\\{4\\}-[0-9]\\{2\\}-[0-9]\\{2\\}" (buffer-string)))))) + +(ert-deftest test-contacts-capture-finalize-prevents-duplicates () + "Test that finalize doesn't add duplicate timestamps." + (with-temp-buffer + (org-mode) + (insert "* Eve Evans\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + (insert "<2000-01-01 Sat +1y>\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Should have exactly one timestamp + (should (= 1 (how-many "<2000-01-01 [A-Za-z]\\{3\\} \\+1y>" (point-min) (point-max))))))) + +(ert-deftest test-contacts-capture-finalize-only-for-contact-template () + "Test that finalize only runs for 'C' template key." + (with-temp-buffer + (org-mode) + (insert "* Task with birthday property\n") + (insert ":PROPERTIES:\n") + (insert ":BIRTHDAY: 2000-01-01\n") + (insert ":END:\n") + + (let ((original-content (buffer-string)) + (org-capture-plist '(:key "t"))) ; Different template key + (cj/org-contacts-finalize-birthday-timestamp) + + ;; Content should be unchanged + (should (string= (buffer-string) original-content))))) + +(ert-deftest test-contacts-capture-finalize-preserves-existing-content () + "Test that finalize preserves all existing content." + (with-temp-buffer + (org-mode) + (insert "* Alice Anderson\n") + (insert ":PROPERTIES:\n") + (insert ":EMAIL: alice@example.com\n") + (insert ":PHONE: 555-1234\n") + (insert ":BIRTHDAY: 1985-03-15\n") + (insert ":NICKNAME: Ali\n") + (insert ":NOTE: Met at conference\n") + (insert ":END:\n") + (insert "Added: [2025-11-01 Fri 20:30]\n") + + (let ((org-capture-plist '(:key "C"))) + (cj/org-contacts-finalize-birthday-timestamp) + + (let ((content (buffer-string))) + ;; All properties should still be present + (should (string-search ":EMAIL: alice@example.com" content)) + (should (string-search ":PHONE: 555-1234" content)) + (should (string-search ":BIRTHDAY: 1985-03-15" content)) + (should (string-search ":NICKNAME: Ali" content)) + (should (string-search ":NOTE: Met at conference" content)) + ;; Added timestamp should still be there + (should (string-search "Added: [2025-11-01 Fri 20:30]" content)) + ;; Birthday timestamp should be added + (should (string-match-p "<1985-03-15 [A-Za-z]\\{3\\} \\+1y>" content)))))) + +(provide 'test-org-contacts-capture-finalize) +;;; test-org-contacts-capture-finalize.el ends here diff --git a/tests/test-transcription-audio-file.el b/tests/test-transcription-audio-file.el new file mode 100644 index 00000000..f40d9ca6 --- /dev/null +++ b/tests/test-transcription-audio-file.el @@ -0,0 +1,83 @@ +;;; test-transcription-audio-file.el --- Tests for audio file detection -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--audio-file-p function +;; Categories: Normal cases, Boundary cases, Error cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--audio-file-p-m4a () + "Test that .m4a files are recognized as audio." + (should (cj/--audio-file-p "meeting.m4a"))) + +(ert-deftest test-cj/--audio-file-p-mp3 () + "Test that .mp3 files are recognized as audio." + (should (cj/--audio-file-p "podcast.mp3"))) + +(ert-deftest test-cj/--audio-file-p-wav () + "Test that .wav files are recognized as audio." + (should (cj/--audio-file-p "recording.wav"))) + +(ert-deftest test-cj/--audio-file-p-flac () + "Test that .flac files are recognized as audio." + (should (cj/--audio-file-p "music.flac"))) + +(ert-deftest test-cj/--audio-file-p-with-path () + "Test audio file recognition with full path." + (should (cj/--audio-file-p "/home/user/recordings/meeting.m4a"))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--audio-file-p-uppercase-extension () + "Test that uppercase extensions are recognized." + (should (cj/--audio-file-p "MEETING.M4A"))) + +(ert-deftest test-cj/--audio-file-p-mixed-case () + "Test that mixed case extensions are recognized." + (should (cj/--audio-file-p "podcast.Mp3"))) + +(ert-deftest test-cj/--audio-file-p-no-extension () + "Test that files without extension are not recognized." + (should-not (cj/--audio-file-p "meeting"))) + +(ert-deftest test-cj/--audio-file-p-empty-string () + "Test that empty string is not recognized as audio." + (should-not (cj/--audio-file-p ""))) + +(ert-deftest test-cj/--audio-file-p-dotfile () + "Test that dotfiles without proper extension are not recognized." + (should-not (cj/--audio-file-p ".hidden"))) + +(ert-deftest test-cj/--audio-file-p-multiple-dots () + "Test file with multiple dots but audio extension." + (should (cj/--audio-file-p "meeting.2025-11-04.final.m4a"))) + +;; ------------------------------ Error Cases ---------------------------------- + +(ert-deftest test-cj/--audio-file-p-not-audio () + "Test that non-audio files are not recognized." + (should-not (cj/--audio-file-p "document.pdf"))) + +(ert-deftest test-cj/--audio-file-p-text-file () + "Test that text files are not recognized as audio." + (should-not (cj/--audio-file-p "notes.txt"))) + +(ert-deftest test-cj/--audio-file-p-org-file () + "Test that org files are not recognized as audio." + (should-not (cj/--audio-file-p "tasks.org"))) + +(ert-deftest test-cj/--audio-file-p-video-file () + "Test that video files are not recognized as audio." + (should-not (cj/--audio-file-p "video.mp4"))) + +(ert-deftest test-cj/--audio-file-p-nil () + "Test that nil input returns nil." + (should-not (cj/--audio-file-p nil))) + +(provide 'test-transcription-audio-file) +;;; test-transcription-audio-file.el ends here diff --git a/tests/test-transcription-config--transcription-script-path.el b/tests/test-transcription-config--transcription-script-path.el new file mode 100644 index 00000000..a56cb05c --- /dev/null +++ b/tests/test-transcription-config--transcription-script-path.el @@ -0,0 +1,106 @@ +;;; test-transcription-config--transcription-script-path.el --- Tests for cj/--transcription-script-path -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for the cj/--transcription-script-path function from transcription-config.el +;; +;; This function returns the absolute path to the transcription script based on +;; the current value of cj/transcribe-backend. + +;;; Code: + +(require 'ert) + +;; Add modules directory to load path +(add-to-list 'load-path (expand-file-name "modules" user-emacs-directory)) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Stub notification function +(unless (fboundp 'notifications-notify) + (defun notifications-notify (&rest _args) + "Stub notification function for testing." + nil)) + +;; Now load the actual production module +(require 'transcription-config) + +;;; Setup and Teardown + +(defun test-transcription-script-path-setup () + "Set up test environment." + ;; Save original backend setting + (setq test-transcription-original-backend cj/transcribe-backend)) + +(defun test-transcription-script-path-teardown () + "Clean up test environment." + ;; Restore original backend setting + (setq cj/transcribe-backend test-transcription-original-backend)) + +;;; Normal Cases + +(ert-deftest test-transcription-config--transcription-script-path-normal-openai-api-returns-oai-transcribe () + "Should return oai-transcribe script path for openai-api backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'openai-api) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/oai-transcribe" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-assemblyai-returns-assemblyai-transcribe () + "Should return assemblyai-transcribe script path for assemblyai backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'assemblyai) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/assemblyai-transcribe" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-local-whisper-returns-local-whisper () + "Should return local-whisper script path for local-whisper backend." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'local-whisper) + (let ((result (cj/--transcription-script-path))) + (should (stringp result)) + (should (string-suffix-p "scripts/local-whisper" result)) + (should (string-prefix-p (expand-file-name user-emacs-directory) result)))) + (test-transcription-script-path-teardown))) + +(ert-deftest test-transcription-config--transcription-script-path-normal-returns-absolute-path () + "Should return absolute path starting with user-emacs-directory." + (test-transcription-script-path-setup) + (unwind-protect + (progn + (setq cj/transcribe-backend 'openai-api) + (let ((result (cj/--transcription-script-path))) + (should (file-name-absolute-p result)) + (should (string-prefix-p "/" result)))) + (test-transcription-script-path-teardown))) + +;;; Boundary Cases + +(ert-deftest test-transcription-config--transcription-script-path-boundary-path-format-consistent () + "Should return paths in consistent format across backends." + (test-transcription-script-path-setup) + (unwind-protect + (let (paths) + (dolist (backend '(openai-api assemblyai local-whisper)) + (setq cj/transcribe-backend backend) + (push (cj/--transcription-script-path) paths)) + ;; All paths should have same structure: <emacs-dir>/scripts/<name> + (should (= (length paths) 3)) + (should (seq-every-p (lambda (p) (string-match-p "/scripts/[^/]+$" p)) paths))) + (test-transcription-script-path-teardown))) + +(provide 'test-transcription-config--transcription-script-path) +;;; test-transcription-config--transcription-script-path.el ends here diff --git a/tests/test-transcription-counter.el b/tests/test-transcription-counter.el new file mode 100644 index 00000000..fae353ba --- /dev/null +++ b/tests/test-transcription-counter.el @@ -0,0 +1,98 @@ +;;; test-transcription-counter.el --- Tests for active transcription counting -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--count-active-transcriptions and modeline integration +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--count-active-transcriptions-empty () + "Test count when no transcriptions are active." + (let ((cj/transcriptions-list '())) + (should (= 0 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-one-running () + "Test count with one running transcription." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (should (= 1 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-multiple-running () + "Test count with multiple running transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running) + (proc3 "file3.m4a" nil running)))) + (should (= 3 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-mixed-status () + "Test count excludes completed/errored transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil complete) + (proc3 "file3.m4a" nil running) + (proc4 "file4.m4a" nil error)))) + (should (= 2 (cj/--count-active-transcriptions))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--count-active-transcriptions-only-complete () + "Test count when all transcriptions are complete." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil complete) + (proc2 "file2.m4a" nil complete)))) + (should (= 0 (cj/--count-active-transcriptions))))) + +(ert-deftest test-cj/--count-active-transcriptions-only-error () + "Test count when all transcriptions errored." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil error) + (proc2 "file2.m4a" nil error)))) + (should (= 0 (cj/--count-active-transcriptions))))) + +;; ----------------------------- Modeline Tests -------------------------------- + +(ert-deftest test-cj/--transcription-modeline-string-none-active () + "Test modeline string when no transcriptions active." + (let ((cj/transcriptions-list '())) + (should-not (cj/--transcription-modeline-string)))) + +(ert-deftest test-cj/--transcription-modeline-string-one-active () + "Test modeline string with one active transcription." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should result) + (should (string-match-p "⏺1" result))))) + +(ert-deftest test-cj/--transcription-modeline-string-multiple-active () + "Test modeline string with multiple active transcriptions." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running) + (proc2 "file2.m4a" nil running) + (proc3 "file3.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should result) + (should (string-match-p "⏺3" result))))) + +(ert-deftest test-cj/--transcription-modeline-string-has-help-echo () + "Test that modeline string has help-echo property." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should (get-text-property 0 'help-echo result))))) + +(ert-deftest test-cj/--transcription-modeline-string-has-face () + "Test that modeline string has warning face." + (let ((cj/transcriptions-list + '((proc1 "file1.m4a" nil running)))) + (let ((result (cj/--transcription-modeline-string))) + (should (eq 'warning (get-text-property 0 'face result)))))) + +(provide 'test-transcription-counter) +;;; test-transcription-counter.el ends here diff --git a/tests/test-transcription-duration.el b/tests/test-transcription-duration.el new file mode 100644 index 00000000..370c439b --- /dev/null +++ b/tests/test-transcription-duration.el @@ -0,0 +1,58 @@ +;;; test-transcription-duration.el --- Tests for duration calculation -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--transcription-duration function +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--transcription-duration-zero-seconds () + "Test duration calculation for current time (should be 00:00)." + (let ((now (current-time))) + (should (string= (cj/--transcription-duration now) "00:00")))) + +(ert-deftest test-cj/--transcription-duration-30-seconds () + "Test duration calculation for 30 seconds ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 30)))) + (should (string= (cj/--transcription-duration start-time) "00:30")))) + +(ert-deftest test-cj/--transcription-duration-1-minute () + "Test duration calculation for 1 minute ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 60)))) + (should (string= (cj/--transcription-duration start-time) "01:00")))) + +(ert-deftest test-cj/--transcription-duration-2-minutes-30-seconds () + "Test duration calculation for 2:30 ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 150)))) + (should (string= (cj/--transcription-duration start-time) "02:30")))) + +(ert-deftest test-cj/--transcription-duration-10-minutes () + "Test duration calculation for 10 minutes ago." + (let ((start-time (time-subtract (current-time) (seconds-to-time 600)))) + (should (string= (cj/--transcription-duration start-time) "10:00")))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--transcription-duration-59-seconds () + "Test duration just before 1 minute." + (let ((start-time (time-subtract (current-time) (seconds-to-time 59)))) + (should (string= (cj/--transcription-duration start-time) "00:59")))) + +(ert-deftest test-cj/--transcription-duration-1-hour () + "Test duration for 1 hour (60 minutes)." + (let ((start-time (time-subtract (current-time) (seconds-to-time 3600)))) + (should (string= (cj/--transcription-duration start-time) "60:00")))) + +(ert-deftest test-cj/--transcription-duration-format () + "Test that duration is always in MM:SS format with zero-padding." + (let ((start-time (time-subtract (current-time) (seconds-to-time 65)))) + (let ((result (cj/--transcription-duration start-time))) + (should (string-match-p "^[0-9][0-9]:[0-9][0-9]$" result))))) + +(provide 'test-transcription-duration) +;;; test-transcription-duration.el ends here diff --git a/tests/test-transcription-log-cleanup.el b/tests/test-transcription-log-cleanup.el new file mode 100644 index 00000000..82c902d8 --- /dev/null +++ b/tests/test-transcription-log-cleanup.el @@ -0,0 +1,44 @@ +;;; test-transcription-log-cleanup.el --- Tests for log cleanup logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--should-keep-log function +;; Categories: Normal cases, Boundary cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--should-keep-log-success-keep-disabled () + "Test that logs are deleted on success when keep-log is nil." + (let ((cj/transcription-keep-log-when-done nil)) + (should-not (cj/--should-keep-log t)))) + +(ert-deftest test-cj/--should-keep-log-success-keep-enabled () + "Test that logs are kept on success when keep-log is t." + (let ((cj/transcription-keep-log-when-done t)) + (should (cj/--should-keep-log t)))) + +(ert-deftest test-cj/--should-keep-log-error-keep-disabled () + "Test that logs are always kept on error, even if keep-log is nil." + (let ((cj/transcription-keep-log-when-done nil)) + (should (cj/--should-keep-log nil)))) + +(ert-deftest test-cj/--should-keep-log-error-keep-enabled () + "Test that logs are kept on error when keep-log is t." + (let ((cj/transcription-keep-log-when-done t)) + (should (cj/--should-keep-log nil)))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--should-keep-log-default-behavior () + "Test default behavior (should not keep on success)." + ;; Default is nil based on defcustom + (let ((cj/transcription-keep-log-when-done nil)) + (should-not (cj/--should-keep-log t)) + (should (cj/--should-keep-log nil)))) + +(provide 'test-transcription-log-cleanup) +;;; test-transcription-log-cleanup.el ends here diff --git a/tests/test-transcription-paths.el b/tests/test-transcription-paths.el new file mode 100644 index 00000000..5ee80e67 --- /dev/null +++ b/tests/test-transcription-paths.el @@ -0,0 +1,80 @@ +;;; test-transcription-paths.el --- Tests for transcription file path logic -*- lexical-binding: t; -*- + +;;; Commentary: +;; Tests for cj/--transcription-output-files and cj/--transcription-script-path +;; Categories: Normal cases, Boundary cases, Error cases + +;;; Code: + +(require 'ert) +(require 'transcription-config) + +;; ----------------------------- Normal Cases ---------------------------------- + +(ert-deftest test-cj/--transcription-output-files-simple () + "Test output file paths for simple filename." + (let ((result (cj/--transcription-output-files "meeting.m4a"))) + (should (string= (car result) "meeting.txt")) + (should (string= (cdr result) "meeting.log")))) + +(ert-deftest test-cj/--transcription-output-files-with-path () + "Test output file paths with full path." + (let ((result (cj/--transcription-output-files "/home/user/audio/podcast.mp3"))) + (should (string= (car result) "/home/user/audio/podcast.txt")) + (should (string= (cdr result) "/home/user/audio/podcast.log")))) + +(ert-deftest test-cj/--transcription-output-files-different-extensions () + "Test output files for various audio extensions." + (dolist (ext '("m4a" "mp3" "wav" "flac" "ogg")) + (let* ((input (format "audio.%s" ext)) + (result (cj/--transcription-output-files input))) + (should (string= (car result) "audio.txt")) + (should (string= (cdr result) "audio.log"))))) + +;; ----------------------------- Boundary Cases -------------------------------- + +(ert-deftest test-cj/--transcription-output-files-multiple-dots () + "Test output files for filename with multiple dots." + (let ((result (cj/--transcription-output-files "meeting.2025-11-04.final.m4a"))) + (should (string= (car result) "meeting.2025-11-04.final.txt")) + (should (string= (cdr result) "meeting.2025-11-04.final.log")))) + +(ert-deftest test-cj/--transcription-output-files-no-extension () + "Test output files for filename without extension." + (let ((result (cj/--transcription-output-files "meeting"))) + (should (string= (car result) "meeting.txt")) + (should (string= (cdr result) "meeting.log")))) + +(ert-deftest test-cj/--transcription-output-files-spaces-in-name () + "Test output files for filename with spaces." + (let ((result (cj/--transcription-output-files "team meeting 2025.m4a"))) + (should (string= (car result) "team meeting 2025.txt")) + (should (string= (cdr result) "team meeting 2025.log")))) + +(ert-deftest test-cj/--transcription-output-files-special-chars () + "Test output files for filename with special characters." + (let ((result (cj/--transcription-output-files "meeting_(final).m4a"))) + (should (string= (car result) "meeting_(final).txt")) + (should (string= (cdr result) "meeting_(final).log")))) + +;; ----------------------------- Script Path Tests ----------------------------- + +(ert-deftest test-cj/--transcription-script-path-local-whisper () + "Test script path for local-whisper backend." + (let ((cj/transcribe-backend 'local-whisper)) + (should (string-suffix-p "scripts/local-whisper" + (cj/--transcription-script-path))))) + +(ert-deftest test-cj/--transcription-script-path-openai-api () + "Test script path for openai-api backend." + (let ((cj/transcribe-backend 'openai-api)) + (should (string-suffix-p "scripts/oai-transcribe" + (cj/--transcription-script-path))))) + +(ert-deftest test-cj/--transcription-script-path-absolute () + "Test that script path is absolute." + (let ((path (cj/--transcription-script-path))) + (should (file-name-absolute-p path)))) + +(provide 'test-transcription-paths) +;;; test-transcription-paths.el ends here diff --git a/tests/test-video-audio-recording-check-ffmpeg.el b/tests/test-video-audio-recording-check-ffmpeg.el new file mode 100644 index 00000000..5c264b64 --- /dev/null +++ b/tests/test-video-audio-recording-check-ffmpeg.el @@ -0,0 +1,46 @@ +;;; test-video-audio-recording-check-ffmpeg.el --- Tests for cj/recording-check-ffmpeg -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-check-ffmpeg function. +;; Tests detection of ffmpeg availability. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-normal-ffmpeg-found-returns-t () + "Test that function returns t when ffmpeg is found." + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) + (when (equal cmd "ffmpeg") "/usr/bin/ffmpeg")))) + (let ((result (cj/recording-check-ffmpeg))) + (should (eq t result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-ffmpeg-not-found-signals-error () + "Test that function signals user-error when ffmpeg is not found." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (should-error (cj/recording-check-ffmpeg) :type 'user-error))) + +(ert-deftest test-video-audio-recording-check-ffmpeg-error-message-mentions-pacman () + "Test that error message includes installation command." + (cl-letf (((symbol-function 'executable-find) + (lambda (_cmd) nil))) + (condition-case err + (cj/recording-check-ffmpeg) + (user-error + (should (string-match-p "pacman -S ffmpeg" (error-message-string err))))))) + +(provide 'test-video-audio-recording-check-ffmpeg) +;;; test-video-audio-recording-check-ffmpeg.el ends here diff --git a/tests/test-video-audio-recording-detect-mic-device.el b/tests/test-video-audio-recording-detect-mic-device.el new file mode 100644 index 00000000..e95889e3 --- /dev/null +++ b/tests/test-video-audio-recording-detect-mic-device.el @@ -0,0 +1,152 @@ +;;; test-video-audio-recording-detect-mic-device.el --- Tests for cj/recording-detect-mic-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-detect-mic-device function. +;; Tests auto-detection of microphone input device from pactl output. +;; Mocks shell-command-to-string to test regex matching logic. +;; +;; IMPORTANT: These tests document actual behavior, which appears to have a bug. +;; The function currently returns the pactl ID number (e.g., "50") instead of +;; the device name (e.g., "alsa_input.pci-0000_00_1f.3.analog-stereo"). +;; This is because the regex captures group 1 is \\([^\t\n]+\\) which stops +;; at the first tab, capturing only the ID. +;; +;; This function may not be actively used (parse-sources is preferred). +;; Tests document current behavior to catch regressions if function is fixed. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-built-in-analog-stereo-found () + "Test detection of built-in analog stereo microphone. +Note: Returns first match which is the monitor (ID 49), not the input." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (stringp result)) + ;; BUG: Returns first match "49" (monitor), not input "50" + (should (equal "49" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-usb-analog-stereo-found () + "Test detection of USB analog stereo microphone. +Note: Returns ID '100', not device name." + (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (stringp result)) + ;; Current behavior: returns ID "100" + (should (equal "100" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-normal-first-match-returned () + "Test that first matching device is returned when multiple exist. +Note: Returns first ID, not device name." + (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "100\talsa_input.usb-device.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; Current behavior: returns first ID "50" + (should (equal "50" result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-empty-output-returns-nil () + "Test that empty output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-only-monitors-returns-nil () + "Test that output with only monitor devices still matches (documents bug). +Current regex doesn't exclude monitors, so this returns ID '49'." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; BUG: Should return nil for monitors, but regex doesn't exclude them + (should (equal "49" result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-mono-fallback-no-match () + "Test that mono-fallback device doesn't match (not stereo)." + (let ((output "100\talsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-bluetooth-no-match () + "Test that Bluetooth devices without 'analog stereo' don't match." + (let ((output "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-whitespace-only-returns-nil () + "Test that whitespace-only output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-boundary-case-insensitive-analog () + "Test that 'ANALOG' (uppercase) matches (case-insensitive regex). +Documents that regex is actually case-insensitive." + (let ((output "50\talsa_input.pci-0000_00_1f.3.ANALOG-STEREO\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; Regex is case-insensitive, matches uppercase + (should (equal "50" result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-detect-mic-device-error-malformed-output-returns-nil () + "Test that malformed output returns nil." + (let ((output "This is not valid pactl output\nRandom text here\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-analog-only () + "Test that 'analog' without 'stereo' doesn't match." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-mono\tPipeWire\ts32le 1ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-partial-match-stereo-only () + "Test that 'stereo' without 'analog' doesn't match." + (let ((output "50\talsa_input.pci-0000_00_1f.3.digital-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-mic-device-error-monitor-with-analog-stereo-matches-bug () + "Test that monitor device with 'analog stereo' incorrectly matches (documents bug). +Should return nil for monitors, but current regex doesn't filter them." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-mic-device))) + ;; BUG: Returns ID "49" even though this is a monitor (output device) + (should (equal "49" result)))))) + +(provide 'test-video-audio-recording-detect-mic-device) +;;; test-video-audio-recording-detect-mic-device.el ends here diff --git a/tests/test-video-audio-recording-detect-system-device.el b/tests/test-video-audio-recording-detect-system-device.el new file mode 100644 index 00000000..bea20e8a --- /dev/null +++ b/tests/test-video-audio-recording-detect-system-device.el @@ -0,0 +1,151 @@ +;;; test-video-audio-recording-detect-system-device.el --- Tests for cj/recording-detect-system-device -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-detect-system-device function. +;; Tests auto-detection of system audio monitor device from pactl output. +;; Mocks shell-command-to-string to test regex matching logic. +;; +;; NOTE: This function works correctly - returns the full device name ending in .monitor. +;; The regex \\([^\t\n]+\\.monitor\\) matches any non-tab/newline chars ending with .monitor, +;; which correctly captures the device name field from pactl output. +;; +;; This function may not be actively used (parse-sources is preferred). +;; Tests document current behavior to catch regressions. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-detect-system-device-normal-built-in-monitor-found () + "Test detection of built-in system audio monitor. +Returns full device name." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-usb-monitor-found () + "Test detection of USB system audio monitor." + (let ((output "99\talsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "alsa_output.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.analog-stereo.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-bluetooth-monitor-found () + "Test detection of Bluetooth monitor device." + (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (stringp result)) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-normal-first-match-returned () + "Test that first matching monitor is returned when multiple exist." + (let ((output (concat "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n" + "99\talsa_output.usb-device.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Returns first monitor device name + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-empty-output-returns-nil () + "Test that empty output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-detect-system-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-only-inputs-returns-nil () + "Test that output with only input devices (no monitors) returns nil." + (let ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-whitespace-only-returns-nil () + "Test that whitespace-only output returns nil." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-detect-system-device))) + (should (null result))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-monitor-different-states () + "Test that monitors in different states are all matched." + (let ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Should match regardless of state (RUNNING, SUSPENDED, IDLE) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-boundary-case-insensitive-monitor () + "Test that regex is case-insensitive for '.monitor' suffix. +Documents that .MONITOR (uppercase) also matches." + (let ((output "49\talsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Case-insensitive: .MONITOR matches + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.MONITOR" result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-detect-system-device-error-malformed-output-returns-nil () + "Test that malformed output returns nil." + (let ((output "This is not valid pactl output\nRandom text here\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-partial-monitor-matches () + "Test that device with .monitor in middle partially matches (documents quirk). +The regex matches up to first .monitor occurrence, even if not at end of device name." + (let ((output "50\talsa_input.monitor-device.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; QUIRK: Matches partial string "alsa_input.monitor" + (should (equal "alsa_input.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-incomplete-line () + "Test that incomplete lines with .monitor are still matched." + (let ((output "49\tincomplete-line.monitor\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + ;; Should match device name ending in .monitor + (should (equal "incomplete-line.monitor" result)))))) + +(ert-deftest test-video-audio-recording-detect-system-device-error-mixed-valid-invalid () + "Test that mix of valid and invalid lines returns first valid monitor." + (let ((output (concat "invalid line without tabs\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "another invalid line\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-detect-system-device))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" result)))))) + +(provide 'test-video-audio-recording-detect-system-device) +;;; test-video-audio-recording-detect-system-device.el ends here diff --git a/tests/test-video-audio-recording-friendly-state.el b/tests/test-video-audio-recording-friendly-state.el new file mode 100644 index 00000000..91b47998 --- /dev/null +++ b/tests/test-video-audio-recording-friendly-state.el @@ -0,0 +1,65 @@ +;;; test-video-audio-recording-friendly-state.el --- Tests for cj/recording-friendly-state -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-friendly-state function. +;; Tests conversion of technical pactl state names to user-friendly labels. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-friendly-state-normal-suspended-returns-ready () + "Test that SUSPENDED state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "SUSPENDED")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-running-returns-active () + "Test that RUNNING state converts to Active." + (should (string= "Active" (cj/recording-friendly-state "RUNNING")))) + +(ert-deftest test-video-audio-recording-friendly-state-normal-idle-returns-ready () + "Test that IDLE state converts to Ready." + (should (string= "Ready" (cj/recording-friendly-state "IDLE")))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-friendly-state-boundary-empty-string-returns-empty () + "Test that empty string passes through unchanged." + (should (string= "" (cj/recording-friendly-state "")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-lowercase-suspended-returns-unchanged () + "Test that lowercase 'suspended' is not converted (case-sensitive)." + (should (string= "suspended" (cj/recording-friendly-state "suspended")))) + +(ert-deftest test-video-audio-recording-friendly-state-boundary-mixed-case-returns-unchanged () + "Test that mixed case 'Running' passes through unchanged." + (should (string= "Running" (cj/recording-friendly-state "Running")))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-friendly-state-error-unknown-state-returns-unchanged () + "Test that unknown state passes through unchanged." + (should (string= "UNKNOWN" (cj/recording-friendly-state "UNKNOWN")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-random-string-returns-unchanged () + "Test that random string passes through unchanged." + (should (string= "foobar" (cj/recording-friendly-state "foobar")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-numeric-string-returns-unchanged () + "Test that numeric string passes through unchanged." + (should (string= "12345" (cj/recording-friendly-state "12345")))) + +(ert-deftest test-video-audio-recording-friendly-state-error-special-chars-returns-unchanged () + "Test that string with special characters passes through unchanged." + (should (string= "!@#$%" (cj/recording-friendly-state "!@#$%")))) + +(provide 'test-video-audio-recording-friendly-state) +;;; test-video-audio-recording-friendly-state.el ends here diff --git a/tests/test-video-audio-recording-get-devices.el b/tests/test-video-audio-recording-get-devices.el new file mode 100644 index 00000000..b1b8470b --- /dev/null +++ b/tests/test-video-audio-recording-get-devices.el @@ -0,0 +1,142 @@ +;;; test-video-audio-recording-get-devices.el --- Tests for cj/recording-get-devices -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-get-devices function. +;; Tests device auto-detection fallback logic. +;; +;; Note: This function has interactive prompts, but we test the core logic paths +;; without mocking y-or-n-p. We focus on testing: +;; - Already-set devices (no auto-detection needed) +;; - Successful auto-detection +;; - Failed auto-detection → error + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Setup and Teardown + +(defun test-get-devices-setup () + "Reset device variables before each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +(defun test-get-devices-teardown () + "Clean up device variables after each test." + (setq cj/recording-mic-device nil) + (setq cj/recording-system-device nil)) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-get-devices-normal-already-set-returns-devices () + "Test that already-set devices are returned without auto-detection." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "test-mic") + (setq cj/recording-system-device "test-monitor") + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "test-mic" (car result))) + (should (equal "test-monitor" (cdr result))))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-auto-detect-success () + "Test that auto-detection succeeds and returns devices." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () "auto-detected-mic")) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () "auto-detected-monitor"))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "auto-detected-mic" (car result))) + (should (equal "auto-detected-monitor" (cdr result))) + ;; Verify variables were set + (should (equal "auto-detected-mic" cj/recording-mic-device)) + (should (equal "auto-detected-monitor" cj/recording-system-device)))) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-normal-partial-auto-detect () + "Test when only one device is already set, only the other is auto-detected." + (test-get-devices-setup) + (unwind-protect + (progn + (setq cj/recording-mic-device "preset-mic") + (cl-letf (((symbol-function 'cj/recording-detect-system-device) + (lambda () "auto-detected-monitor"))) + (let ((result (cj/recording-get-devices))) + (should (consp result)) + (should (equal "preset-mic" (car result))) + (should (equal "auto-detected-monitor" (cdr result)))))) + (test-get-devices-teardown))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-get-devices-error-auto-detect-fails-signals-error () + "Test that failed auto-detection signals user-error. +When auto-detection fails and user doesn't manually select, function errors." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ;; Mock y-or-n-p to say no to manual selection + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-only-mic-detected-signals-error () + "Test that detecting only mic (no monitor) signals error." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () "detected-mic")) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-only-monitor-detected-signals-error () + "Test that detecting only monitor (no mic) signals error." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () "detected-monitor")) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (should-error (cj/recording-get-devices) :type 'user-error)) + (test-get-devices-teardown))) + +(ert-deftest test-video-audio-recording-get-devices-error-message-mentions-select-devices () + "Test that error message guides user to manual selection command." + (test-get-devices-setup) + (unwind-protect + (cl-letf (((symbol-function 'cj/recording-detect-mic-device) + (lambda () nil)) + ((symbol-function 'cj/recording-detect-system-device) + (lambda () nil)) + ((symbol-function 'y-or-n-p) + (lambda (_prompt) nil))) + (condition-case err + (cj/recording-get-devices) + (user-error + (should (string-match-p "cj/recording-select-devices" (error-message-string err)))))) + (test-get-devices-teardown))) + +(provide 'test-video-audio-recording-get-devices) +;;; test-video-audio-recording-get-devices.el ends here diff --git a/tests/test-video-audio-recording-group-devices-by-hardware.el b/tests/test-video-audio-recording-group-devices-by-hardware.el new file mode 100644 index 00000000..0abe5f6c --- /dev/null +++ b/tests/test-video-audio-recording-group-devices-by-hardware.el @@ -0,0 +1,194 @@ +;;; test-video-audio-recording-group-devices-by-hardware.el --- Tests for cj/recording-group-devices-by-hardware -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-group-devices-by-hardware function. +;; Tests grouping of audio sources by physical hardware device. +;; Critical test: Bluetooth MAC address normalization (colons vs underscores). +;; +;; This function is used by the quick setup command to automatically pair +;; microphone and monitor devices from the same hardware. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-all-types-grouped () + "Test grouping of all three device types (built-in, USB, Bluetooth). +This is the key test validating the complete grouping logic." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (= 3 (length result))) + ;; Check that we have all three device types + (let ((names (mapcar #'car result))) + (should (member "Built-in Laptop Audio" names)) + (should (member "Bluetooth Headset" names)) + (should (member "Jabra SPEAK 510 USB" names))) + ;; Verify each device has both mic and monitor + (dolist (device result) + (should (stringp (car device))) ; friendly name + (should (stringp (cadr device))) ; mic device + (should (stringp (cddr device))) ; monitor device + (should-not (string-suffix-p ".monitor" (cadr device))) ; mic not monitor + (should (string-suffix-p ".monitor" (cddr device)))))))) ; monitor has suffix + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-built-in-paired () + "Test that built-in laptop audio devices are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (built-in (assoc "Built-in Laptop Audio" result))) + (should built-in) + (should (string-match-p "pci-0000_00_1f" (cadr built-in))) + (should (string-match-p "pci-0000_00_1f" (cddr built-in))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (cadr built-in))) + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" (cddr built-in))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-usb-paired () + "Test that USB devices (Jabra) are correctly paired." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (jabra (assoc "Jabra SPEAK 510 USB" result))) + (should jabra) + (should (string-match-p "Jabra" (cadr jabra))) + (should (string-match-p "Jabra" (cddr jabra))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-normal-bluetooth-paired () + "Test that Bluetooth devices are correctly paired. +CRITICAL: Tests MAC address normalization (colons in input, underscores in output)." + (let ((output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let* ((result (cj/recording-group-devices-by-hardware)) + (bluetooth (assoc "Bluetooth Headset" result))) + (should bluetooth) + ;; Input has colons: bluez_input.00:1B:66:C0:91:6D + (should (equal "bluez_input.00:1B:66:C0:91:6D" (cadr bluetooth))) + ;; Output has underscores: bluez_output.00_1B_66_C0_91_6D.1.monitor + ;; But they should still be grouped together (MAC address normalized) + (should (equal "bluez_output.00_1B_66_C0_91_6D.1.monitor" (cddr bluetooth))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-empty-returns-empty () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-inputs-returns-empty () + "Test that only input devices (no monitors) returns empty list. +Devices must have BOTH mic and monitor to be included." + (let ((output (test-load-fixture "pactl-output-inputs-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-only-monitors-returns-empty () + "Test that only monitor devices (no inputs) returns empty list." + (let ((output (test-load-fixture "pactl-output-monitors-only.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-single-complete-device () + "Test that single device with both mic and monitor is returned." + (let ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-boundary-mixed-complete-incomplete () + "Test that only devices with BOTH mic and monitor are included. +Incomplete devices (only mic or only monitor) are filtered out." + (let ((output (concat + ;; Complete device (built-in) + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "49\talsa_output.pci-0000_00_1f.3.analog-stereo.monitor\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + ;; Incomplete: USB mic with no monitor + "100\talsa_input.usb-device.mono-fallback\tPipeWire\ts16le 1ch 16000Hz\tSUSPENDED\n" + ;; Incomplete: Bluetooth monitor with no mic + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + ;; Only the complete built-in device should be returned + (should (= 1 (length result))) + (should (equal "Built-in Laptop Audio" (caar result))))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-malformed-output-returns-empty () + "Test that malformed pactl output returns empty list." + (let ((output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-unknown-device-type () + "Test that unknown device types get generic 'USB Audio Device' name." + (let ((output (concat + "100\talsa_input.usb-unknown_device-00.analog-stereo\tPipeWire\ts16le 2ch 16000Hz\tSUSPENDED\n" + "99\talsa_output.usb-unknown_device-00.analog-stereo.monitor\tPipeWire\ts16le 2ch 48000Hz\tSUSPENDED\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + ;; Should get generic USB name (not matching Jabra pattern) + (should (equal "USB Audio Device" (caar result))))))) + +(ert-deftest test-video-audio-recording-group-devices-by-hardware-error-bluetooth-mac-case-variations () + "Test that Bluetooth MAC addresses work with different formatting. +Tests the normalization logic handles various MAC address formats." + (let ((output (concat + ;; Input with colons (typical) + "79\tbluez_input.AA:BB:CC:DD:EE:FF\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + ;; Output with underscores (typical) + "81\tbluez_output.AA_BB_CC_DD_EE_FF.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording-group-devices-by-hardware))) + (should (= 1 (length result))) + (should (equal "Bluetooth Headset" (caar result))) + ;; Verify both devices paired despite different MAC formats + (let ((device (car result))) + (should (string-match-p "AA:BB:CC" (cadr device))) + (should (string-match-p "AA_BB_CC" (cddr device)))))))) + +(provide 'test-video-audio-recording-group-devices-by-hardware) +;;; test-video-audio-recording-group-devices-by-hardware.el ends here diff --git a/tests/test-video-audio-recording-parse-pactl-output.el b/tests/test-video-audio-recording-parse-pactl-output.el new file mode 100644 index 00000000..db49a897 --- /dev/null +++ b/tests/test-video-audio-recording-parse-pactl-output.el @@ -0,0 +1,157 @@ +;;; test-video-audio-recording-parse-pactl-output.el --- Tests for cj/recording--parse-pactl-output -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--parse-pactl-output function. +;; Tests parsing of pactl sources output into structured data. +;; Uses fixture files with sample pactl output for reproducible testing. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-all-devices-returns-list () + "Test parsing normal pactl output with all device types." + (let* ((output (test-load-fixture "pactl-output-normal.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 6 (length result))) + ;; Check first device (built-in monitor) + (should (equal '("alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + "PipeWire" + "SUSPENDED") + (nth 0 result))) + ;; Check Bluetooth input + (should (equal '("bluez_input.00:1B:66:C0:91:6D" + "PipeWire" + "SUSPENDED") + (nth 2 result))) + ;; Check USB device + (should (equal '("alsa_input.usb-0b0e_Jabra_SPEAK_510_USB_1C48F9C067D5020A00-00.mono-fallback" + "PipeWire" + "SUSPENDED") + (nth 5 result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-single-device-returns-list () + "Test parsing output with single device." + (let* ((output (test-load-fixture "pactl-output-single.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 1 (length result))) + (should (equal '("alsa_input.pci-0000_00_1f.3.analog-stereo" + "PipeWire" + "SUSPENDED") + (car result))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-monitors-only-returns-list () + "Test parsing output with only monitor devices." + (let* ((output (test-load-fixture "pactl-output-monitors-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; All should end with .monitor + (dolist (device result) + (should (string-suffix-p ".monitor" (car device)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-normal-inputs-only-returns-list () + "Test parsing output with only input devices." + (let* ((output (test-load-fixture "pactl-output-inputs-only.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (= 3 (length result))) + ;; None should end with .monitor + (dolist (device result) + (should-not (string-suffix-p ".monitor" (car device)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-string-returns-empty-list () + "Test parsing empty string returns empty list." + (let ((result (cj/recording--parse-pactl-output ""))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-empty-file-returns-empty-list () + "Test parsing empty file returns empty list." + (let* ((output (test-load-fixture "pactl-output-empty.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-whitespace-only-returns-empty-list () + "Test parsing whitespace-only string returns empty list." + (let ((result (cj/recording--parse-pactl-output " \n\t\n "))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-single-newline-returns-empty-list () + "Test parsing single newline returns empty list." + (let ((result (cj/recording--parse-pactl-output "\n"))) + (should (listp result)) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-running-state-parsed () + "Test that RUNNING state (not just SUSPENDED) is parsed correctly." + (let* ((output "81\tbluez_output.00_1B_66_C0_91_6D.1.monitor\tPipeWire\ts24le 2ch 48000Hz\tRUNNING\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "RUNNING" (nth 2 (car result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-boundary-device-with-idle-state-parsed () + "Test that IDLE state is parsed correctly." + (let* ((output "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tIDLE\n") + (result (cj/recording--parse-pactl-output output))) + (should (= 1 (length result))) + (should (equal "IDLE" (nth 2 (car result)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-malformed-lines-ignored () + "Test that malformed lines are silently ignored." + (let* ((output (test-load-fixture "pactl-output-malformed.txt")) + (result (cj/recording--parse-pactl-output output))) + (should (listp result)) + (should (null result)))) ; All lines malformed, so empty list + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-mixed-valid-invalid-returns-valid () + "Test that mix of valid and invalid lines returns only valid ones." + (let* ((output (concat "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "This is invalid\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tSUSPENDED\n" + "Also invalid\n")) + (result (cj/recording--parse-pactl-output output))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-missing-fields-ignored () + "Test that lines with missing fields are ignored." + (let* ((output "50\tincomplete-line\tPipeWire\n") ; Missing state and format + (result (cj/recording--parse-pactl-output output))) + (should (null result)))) + +(ert-deftest test-video-audio-recording-parse-pactl-output-error-nil-input-returns-error () + "Test that nil input signals an error." + (should-error (cj/recording--parse-pactl-output nil))) + +(provide 'test-video-audio-recording-parse-pactl-output) +;;; test-video-audio-recording-parse-pactl-output.el ends here diff --git a/tests/test-video-audio-recording-parse-sources.el b/tests/test-video-audio-recording-parse-sources.el new file mode 100644 index 00000000..d6d445b5 --- /dev/null +++ b/tests/test-video-audio-recording-parse-sources.el @@ -0,0 +1,98 @@ +;;; test-video-audio-recording-parse-sources.el --- Tests for cj/recording-parse-sources -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording-parse-sources function. +;; Tests the wrapper that calls pactl and delegates to internal parser. +;; Mocks shell-command-to-string to avoid system dependencies. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +;; Now load the actual production module +(require 'video-audio-recording) + +;;; Test Fixtures Helper + +(defun test-load-fixture (filename) + "Load fixture file FILENAME from tests/fixtures directory." + (let ((fixture-path (expand-file-name + (concat "tests/fixtures/" filename) + user-emacs-directory))) + (with-temp-buffer + (insert-file-contents fixture-path) + (buffer-string)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording-parse-sources-normal-calls-pactl-and-parses () + "Test that parse-sources calls shell command and returns parsed list." + (let ((fixture-output (test-load-fixture "pactl-output-normal.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 6 (length result))) + ;; Verify it returns structured data + (should (equal "alsa_output.pci-0000_00_1f.3.analog-stereo.monitor" + (car (nth 0 result)))) + (should (equal "PipeWire" (nth 1 (nth 0 result)))) + (should (equal "SUSPENDED" (nth 2 (nth 0 result)))))))) + +(ert-deftest test-video-audio-recording-parse-sources-normal-single-device-returns-list () + "Test parse-sources with single device." + (let ((fixture-output (test-load-fixture "pactl-output-single.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (= 1 (length result))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording-parse-sources-boundary-empty-output-returns-empty-list () + "Test that empty pactl output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +(ert-deftest test-video-audio-recording-parse-sources-boundary-whitespace-output-returns-empty-list () + "Test that whitespace-only output returns empty list." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) " \n\t\n "))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording-parse-sources-error-malformed-output-returns-empty-list () + "Test that malformed output is handled gracefully." + (let ((fixture-output (test-load-fixture "pactl-output-malformed.txt"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) fixture-output))) + (let ((result (cj/recording-parse-sources))) + (should (listp result)) + (should (null result)))))) + +(ert-deftest test-video-audio-recording-parse-sources-error-mixed-valid-invalid-returns-valid-only () + "Test that mix of valid and invalid lines returns only valid entries." + (let ((mixed-output (concat + "50\talsa_input.pci-0000_00_1f.3.analog-stereo\tPipeWire\ts32le 2ch 48000Hz\tSUSPENDED\n" + "invalid line\n" + "79\tbluez_input.00:1B:66:C0:91:6D\tPipeWire\tfloat32le 1ch 48000Hz\tRUNNING\n"))) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) mixed-output))) + (let ((result (cj/recording-parse-sources))) + (should (= 2 (length result))) + (should (equal "alsa_input.pci-0000_00_1f.3.analog-stereo" (car (nth 0 result)))) + (should (equal "bluez_input.00:1B:66:C0:91:6D" (car (nth 1 result)))))))) + +(provide 'test-video-audio-recording-parse-sources) +;;; test-video-audio-recording-parse-sources.el ends here |
