From a46f8af939b112b603a2c95b2e83a1932b208e20 Mon Sep 17 00:00:00 2001 From: Craig Jennings Date: Thu, 26 Feb 2026 22:41:46 -0600 Subject: test(recording): add tests for label-devices, label-sinks, get-sink-apps Fill test gaps from quality-engineer review: 3 new test files (28 tests) and error cases for get-available-mics and get-available-sinks (+4 tests). --- ...st-video-audio-recording--get-available-mics.el | 17 +++ ...t-video-audio-recording--get-available-sinks.el | 16 +++ tests/test-video-audio-recording--get-sink-apps.el | 118 +++++++++++++++++++++ tests/test-video-audio-recording--label-devices.el | 110 +++++++++++++++++++ tests/test-video-audio-recording--label-sinks.el | 111 +++++++++++++++++++ 5 files changed, 372 insertions(+) create mode 100644 tests/test-video-audio-recording--get-sink-apps.el create mode 100644 tests/test-video-audio-recording--label-devices.el create mode 100644 tests/test-video-audio-recording--label-sinks.el diff --git a/tests/test-video-audio-recording--get-available-mics.el b/tests/test-video-audio-recording--get-available-mics.el index 86a9d05c..5b549da3 100644 --- a/tests/test-video-audio-recording--get-available-mics.el +++ b/tests/test-video-audio-recording--get-available-mics.el @@ -109,5 +109,22 @@ Each source is (name description mute state)." ("sink-b.monitor" "Monitor B" "no" "SUSPENDED")))))) (should (null (cj/recording--get-available-mics))))) +;;; Error Cases + +(ert-deftest test-video-audio-recording--get-available-mics-error-garbled-output () + "Test that garbled pactl output returns empty list, not an error." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) "random garbage\nwith newlines\nbut no structure\n"))) + (should (null (cj/recording--get-available-mics))))) + +(ert-deftest test-video-audio-recording--get-available-mics-error-missing-fields () + "Test that source with partial fields does not crash." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) "Source #1\n\tState: RUNNING\n\tName: partial-source\n"))) + ;; Missing Description and Mute — should not error + (let ((mics (cj/recording--get-available-mics))) + ;; May return empty or partial; must not signal + (should (listp mics))))) + (provide 'test-video-audio-recording--get-available-mics) ;;; test-video-audio-recording--get-available-mics.el ends here diff --git a/tests/test-video-audio-recording--get-available-sinks.el b/tests/test-video-audio-recording--get-available-sinks.el index 2f0d965c..62d9a41a 100644 --- a/tests/test-video-audio-recording--get-available-sinks.el +++ b/tests/test-video-audio-recording--get-available-sinks.el @@ -86,5 +86,21 @@ Each sink is (name description mute state)." (lambda (_cmd) ""))) (should (null (cj/recording--get-available-sinks))))) +;;; Error Cases + +(ert-deftest test-get-available-sinks-error-garbled-output () + "Test that garbled pactl output returns empty list, not an error." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) "random garbage\nwith newlines\nbut no structure\n"))) + (should (null (cj/recording--get-available-sinks))))) + +(ert-deftest test-get-available-sinks-error-missing-fields () + "Test that sink with partial fields does not crash." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) "Sink #1\n\tState: RUNNING\n\tName: partial-sink\n"))) + ;; Missing Description and Mute — should not error + (let ((sinks (cj/recording--get-available-sinks))) + (should (listp sinks))))) + (provide 'test-video-audio-recording--get-available-sinks) ;;; test-video-audio-recording--get-available-sinks.el ends here diff --git a/tests/test-video-audio-recording--get-sink-apps.el b/tests/test-video-audio-recording--get-sink-apps.el new file mode 100644 index 00000000..8712fa97 --- /dev/null +++ b/tests/test-video-audio-recording--get-sink-apps.el @@ -0,0 +1,118 @@ +;;; test-video-audio-recording--get-sink-apps.el --- Tests for sink app discovery -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--get-sink-apps. +;; Verifies parsing of `pactl list sink-inputs' output to map +;; sink indices to application names. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'video-audio-recording) + +;;; Helpers + +(defvar test-sink-apps--dir + (file-name-directory (or load-file-name (locate-library "test-video-audio-recording--get-sink-apps"))) + "Directory containing this test file.") + +(defun test-sink-apps--fixture (filename) + "Read fixture FILENAME from the fixtures directory." + (with-temp-buffer + (insert-file-contents (expand-file-name (concat "fixtures/" filename) test-sink-apps--dir)) + (buffer-string))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording--get-sink-apps-normal-single-app () + "Test parsing a single app on a single sink." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) (test-sink-apps--fixture "pactl-sink-inputs-active.txt")))) + (let ((result (cj/recording--get-sink-apps))) + (should (= 1 (length result))) + (should (equal '("Firefox") (cdr (assoc "65" result))))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-normal-different-sink () + "Test that sink index is correctly parsed from different fixture." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) (test-sink-apps--fixture "pactl-sink-inputs-different-sink.txt")))) + (let ((result (cj/recording--get-sink-apps))) + (should (= 1 (length result))) + (should (assoc "73" result)) + (should-not (assoc "65" result))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-normal-multiple-sinks () + "Test parsing apps across multiple sinks." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) + (concat (test-sink-apps--fixture "pactl-sink-inputs-active.txt") + "\n" + (test-sink-apps--fixture "pactl-sink-inputs-different-sink.txt"))))) + (let ((result (cj/recording--get-sink-apps))) + (should (= 2 (length result))) + (should (assoc "65" result)) + (should (assoc "73" result))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-normal-multiple-apps-same-sink () + "Test that multiple apps on the same sink are collected together." + (let ((output "Sink Input #1\n\tSink: 65\n\tProperties:\n\t\tapplication.name = \"Firefox\"\nSink Input #2\n\tSink: 65\n\tProperties:\n\t\tapplication.name = \"Spotify\"\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording--get-sink-apps))) + (should (= 1 (length result))) + (should (equal '("Firefox" "Spotify") (cdr (assoc "65" result)))))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-normal-deduplicates () + "Test that duplicate app names on the same sink are deduplicated." + (let ((output "Sink Input #1\n\tSink: 65\n\tProperties:\n\t\tapplication.name = \"Firefox\"\nSink Input #2\n\tSink: 65\n\tProperties:\n\t\tapplication.name = \"Firefox\"\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (let ((result (cj/recording--get-sink-apps))) + (should (equal '("Firefox") (cdr (assoc "65" result)))))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording--get-sink-apps-boundary-empty () + "Test that empty pactl output returns empty alist." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (should (null (cj/recording--get-sink-apps))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-boundary-no-properties () + "Test sink input with no properties section returns empty." + (let ((output "Sink Input #1\n\tSink: 65\n\tDriver: PipeWire\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + (should (null (cj/recording--get-sink-apps)))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-boundary-no-sink-line () + "Test sink input with no Sink: line does not crash." + (let ((output "Sink Input #1\n\tDriver: PipeWire\n\tProperties:\n\t\tapplication.name = \"Firefox\"\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; current-sink is nil so app name won't be stored + (should (null (cj/recording--get-sink-apps)))))) + +;;; Error Cases + +(ert-deftest test-video-audio-recording--get-sink-apps-error-garbled-output () + "Test that garbled output does not crash, returns empty." + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) "random garbage\nwith\nnewlines\n"))) + (should (null (cj/recording--get-sink-apps))))) + +(ert-deftest test-video-audio-recording--get-sink-apps-error-missing-app-name () + "Test sink input with application.name missing value." + (let ((output "Sink Input #1\n\tSink: 65\n\tProperties:\n\t\tapplication.name = \"\"\n")) + (cl-letf (((symbol-function 'shell-command-to-string) + (lambda (_cmd) output))) + ;; Empty string between quotes won't match [^\"]+ so no app stored + (should (null (cj/recording--get-sink-apps)))))) + +(provide 'test-video-audio-recording--get-sink-apps) +;;; test-video-audio-recording--get-sink-apps.el ends here diff --git a/tests/test-video-audio-recording--label-devices.el b/tests/test-video-audio-recording--label-devices.el new file mode 100644 index 00000000..e7ce70fb --- /dev/null +++ b/tests/test-video-audio-recording--label-devices.el @@ -0,0 +1,110 @@ +;;; test-video-audio-recording--label-devices.el --- Tests for device labeling -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--label-devices. +;; Verifies label formatting ("Description [status]") and sort order +;; (in use → ready → available → muted). +;; Also indirectly tests cj/recording--device-sort-key and +;; cj/recording--device-status-label since they are trivial helpers +;; consumed entirely by label-devices. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'video-audio-recording) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording--label-devices-normal-format () + "Test that labels are formatted as \"Description [status]\"." + (let ((result (cj/recording--label-devices + '(("sink-a" "JDS Labs" "RUNNING" "no"))))) + (should (= 1 (length result))) + (should (equal "JDS Labs [in use]" (caar result))) + (should (equal "sink-a" (cdar result))))) + +(ert-deftest test-video-audio-recording--label-devices-normal-sort-order () + "Test that devices sort: in use → ready → available → muted." + (let ((result (cj/recording--label-devices + '(("d-suspended" "Suspended" "SUSPENDED" "no") + ("d-muted" "Muted" "RUNNING" "yes") + ("d-running" "Running" "RUNNING" "no") + ("d-idle" "Idle" "IDLE" "no"))))) + (should (equal '("d-running" "d-idle" "d-suspended" "d-muted") + (mapcar #'cdr result))))) + +(ert-deftest test-video-audio-recording--label-devices-normal-muted-label () + "Test that muted devices get [muted] regardless of state." + (let ((result (cj/recording--label-devices + '(("m1" "Mic" "RUNNING" "yes"))))) + (should (string-match-p "\\[muted\\]" (caar result))))) + +(ert-deftest test-video-audio-recording--label-devices-normal-idle-label () + "Test that IDLE unmuted devices get [ready]." + (let ((result (cj/recording--label-devices + '(("d1" "Device" "IDLE" "no"))))) + (should (string-match-p "\\[ready\\]" (caar result))))) + +(ert-deftest test-video-audio-recording--label-devices-normal-suspended-label () + "Test that SUSPENDED unmuted devices get [available]." + (let ((result (cj/recording--label-devices + '(("d1" "Device" "SUSPENDED" "no"))))) + (should (string-match-p "\\[available\\]" (caar result))))) + +(ert-deftest test-video-audio-recording--label-devices-normal-returns-alist () + "Test that result is a proper (label . name) alist." + (let ((result (cj/recording--label-devices + '(("a" "Alpha" "IDLE" "no") + ("b" "Beta" "RUNNING" "no"))))) + (should (= 2 (length result))) + (dolist (entry result) + (should (consp entry)) + (should (stringp (car entry))) + (should (stringp (cdr entry)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording--label-devices-boundary-empty () + "Test that empty device list returns empty alist." + (should (null (cj/recording--label-devices nil)))) + +(ert-deftest test-video-audio-recording--label-devices-boundary-single () + "Test that single device returns single-element alist." + (let ((result (cj/recording--label-devices + '(("only" "Only Device" "IDLE" "no"))))) + (should (= 1 (length result))) + (should (equal "only" (cdar result))))) + +(ert-deftest test-video-audio-recording--label-devices-boundary-all-muted () + "Test that all-muted devices sort together and all get [muted]." + (let ((result (cj/recording--label-devices + '(("a" "A" "RUNNING" "yes") + ("b" "B" "IDLE" "yes") + ("c" "C" "SUSPENDED" "yes"))))) + (should (= 3 (length result))) + (dolist (entry result) + (should (string-match-p "\\[muted\\]" (car entry)))))) + +(ert-deftest test-video-audio-recording--label-devices-boundary-all-same-status () + "Test that devices with identical status preserve relative order." + (let ((result (cj/recording--label-devices + '(("a" "Alpha" "RUNNING" "no") + ("b" "Beta" "RUNNING" "no"))))) + (should (= 2 (length result))) + ;; Both should have [in use] label + (dolist (entry result) + (should (string-match-p "\\[in use\\]" (car entry)))))) + +(ert-deftest test-video-audio-recording--label-devices-boundary-unknown-state () + "Test that unknown state falls through to [available] (sort key 2)." + (let ((result (cj/recording--label-devices + '(("d1" "Device" "UNKNOWN_STATE" "no"))))) + (should (string-match-p "\\[available\\]" (caar result))))) + +(provide 'test-video-audio-recording--label-devices) +;;; test-video-audio-recording--label-devices.el ends here diff --git a/tests/test-video-audio-recording--label-sinks.el b/tests/test-video-audio-recording--label-sinks.el new file mode 100644 index 00000000..6bec9349 --- /dev/null +++ b/tests/test-video-audio-recording--label-sinks.el @@ -0,0 +1,111 @@ +;;; test-video-audio-recording--label-sinks.el --- Tests for sink labeling -*- lexical-binding: t; -*- + +;;; Commentary: +;; Unit tests for cj/recording--label-sinks. +;; Verifies that sink labels include app names for active sinks, +;; e.g. "JDS Labs [in use] (Firefox)" and sort correctly. + +;;; Code: + +(require 'ert) + +;; Stub dependencies before loading the module +(defvar cj/custom-keymap (make-sparse-keymap) + "Stub keymap for testing.") + +(require 'video-audio-recording) + +;;; Helpers + +(defun test-label-sinks--stub (sink-apps-alist sinks-short-output) + "Return a cl-letf binding list that stubs get-sink-apps and sinks short. +SINK-APPS-ALIST is the return value for `cj/recording--get-sink-apps'. +SINKS-SHORT-OUTPUT is the raw pactl list sinks short output." + ;; We use cl-letf in each test to stub both get-sink-apps and + ;; shell-command-to-string (for the sinks short call inside label-sinks). + `(((symbol-function 'cj/recording--get-sink-apps) + (lambda () ',sink-apps-alist)) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) ,sinks-short-output)))) + +;;; Normal Cases + +(ert-deftest test-video-audio-recording--label-sinks-normal-appends-app-names () + "Test that active sinks show app names in parentheses." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () '(("65" "Firefox")))) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) "65\tjds-labs\tmodule\ts16le\tRUNNING\n"))) + (let ((result (cj/recording--label-sinks + '(("jds-labs" "JDS Labs" "RUNNING" "no"))))) + (should (= 1 (length result))) + (should (string-match-p "JDS Labs \\[in use\\] (Firefox)" (caar result)))))) + +(ert-deftest test-video-audio-recording--label-sinks-normal-multiple-apps () + "Test that multiple apps are comma-separated." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () '(("65" "Firefox" "Spotify")))) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) "65\tjds-labs\tmodule\ts16le\tRUNNING\n"))) + (let ((result (cj/recording--label-sinks + '(("jds-labs" "JDS Labs" "RUNNING" "no"))))) + (should (string-match-p "(Firefox, Spotify)" (caar result)))))) + +(ert-deftest test-video-audio-recording--label-sinks-normal-no-apps () + "Test that sinks without apps have no parenthesized suffix." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () nil)) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) "65\tjds-labs\tmodule\ts16le\tSUSPENDED\n"))) + (let ((result (cj/recording--label-sinks + '(("jds-labs" "JDS Labs" "SUSPENDED" "no"))))) + (should (string-match-p "JDS Labs \\[available\\]$" (caar result)))))) + +(ert-deftest test-video-audio-recording--label-sinks-normal-sort-order () + "Test that sinks sort: in use → ready → available → muted." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () '(("65" "Firefox")))) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) + "65\tsink-run\tmodule\ts16le\tRUNNING\n73\tsink-idle\tmodule\ts16le\tIDLE\n80\tsink-sus\tmodule\ts16le\tSUSPENDED\n"))) + (let ((result (cj/recording--label-sinks + '(("sink-sus" "Suspended" "SUSPENDED" "no") + ("sink-run" "Running" "RUNNING" "no") + ("sink-idle" "Idle" "IDLE" "no"))))) + (should (equal '("sink-run" "sink-idle" "sink-sus") + (mapcar #'cdr result)))))) + +(ert-deftest test-video-audio-recording--label-sinks-normal-muted-sink () + "Test that muted sinks get [muted] label and no apps shown." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () nil)) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) "65\tmuted-sink\tmodule\ts16le\tSUSPENDED\n"))) + (let ((result (cj/recording--label-sinks + '(("muted-sink" "Muted Sink" "SUSPENDED" "yes"))))) + (should (string-match-p "\\[muted\\]" (caar result)))))) + +;;; Boundary Cases + +(ert-deftest test-video-audio-recording--label-sinks-boundary-empty () + "Test that empty sink list returns empty alist." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () nil)) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (should (null (cj/recording--label-sinks nil))))) + +(ert-deftest test-video-audio-recording--label-sinks-boundary-sink-not-in-short () + "Test that sink not found in sinks short still gets label (no apps)." + (cl-letf (((symbol-function 'cj/recording--get-sink-apps) + (lambda () '(("65" "Firefox")))) + ((symbol-function 'shell-command-to-string) + (lambda (_cmd) ""))) + (let ((result (cj/recording--label-sinks + '(("unknown-sink" "Unknown" "IDLE" "no"))))) + (should (= 1 (length result))) + ;; No app suffix since index lookup returned nil + (should (string-match-p "Unknown \\[ready\\]$" (caar result)))))) + +(provide 'test-video-audio-recording--label-sinks) +;;; test-video-audio-recording--label-sinks.el ends here -- cgit v1.2.3