diff options
Diffstat (limited to 'tests/test-chime-sound.el')
| -rw-r--r-- | tests/test-chime-sound.el | 334 |
1 files changed, 334 insertions, 0 deletions
diff --git a/tests/test-chime-sound.el b/tests/test-chime-sound.el new file mode 100644 index 0000000..c43231a --- /dev/null +++ b/tests/test-chime-sound.el @@ -0,0 +1,334 @@ +;;; test-chime-sound.el --- Tests for chime's sound playback -*- lexical-binding: t; -*- + +;; Copyright (C) 2024-2026 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. + +;; This program is distributed in the hope that it will be useful, +;; but WITHOUT ANY WARRANTY; without even the implied warranty of +;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +;; GNU General Public License for more details. + +;; You should have received a copy of the GNU General Public License +;; along with this program. If not, see <http://www.gnu.org/licenses/>. + +;;; Commentary: + +;; Unit tests for `chime--play-sound', `chime--find-sound-player' and +;; `chime--play-sound-external'. +;; +;; Emacs' built-in `play-sound-file' talks to ALSA directly and dies with +;; "No usable sound device driver found" on any system whose ALSA `default' +;; PCM doesn't resolve -- which is every PipeWire box that never got +;; `pcm.!default' pointed at the sound server. It is also synchronous. +;; So chime prefers an external player and keeps `play-sound-file' as the +;; fallback. These tests pin that contract. +;; +;; `play-sound-file' is the system boundary here and is always mocked, so +;; no test opens an audio device. Where a test needs a real process -- +;; asynchrony and the exit-status sentinel cannot be observed through a +;; mock -- it spawns `true' or `false' as a stand-in player. No real audio +;; player is ever run. +;; +;; Tests cover normal cases, boundary cases, and error cases. + +;;; Code: + +(require 'test-bootstrap (expand-file-name "test-bootstrap.el")) + +;;; Helpers + +(defconst test-chime-sound--real-start-process + (symbol-function 'start-process) + "The real `start-process', captured so mocks can still spawn a stand-in. +Mocking `set-process-query-on-exit-flag' would need a native-comp +trampoline, so tests hand the production code a real process instead.") + +(defun test-chime-sound--existing-file () + "Return the path of a real file inside the test base dir." + (let ((file (expand-file-name "chime-test.wav" chime-test-base-dir))) + (with-temp-file file (insert "not really a wav")) + file)) + +(defun test-chime-sound--stand-in-process () + "Spawn a real, immediately-exiting process to stand in for a player." + (funcall test-chime-sound--real-start-process "chime-sound-test" nil "true")) + +(defun test-chime-sound--chime-messages (messages) + "Return only the chime-emitted entries of MESSAGES. +A test that mocks `message' captures every message Emacs emits, not just +chime's. Under a coverage run the source is loaded uninstrumented and the +sentinel lambda is compiled on first call, so a byte-compiler warning lands +in the capture list and a bare count assertion fails. Assert on what chime +said, not on Emacs having said nothing." + (seq-filter (lambda (m) (string-prefix-p "chime:" m)) messages)) + +(defun test-chime-sound--await-exit (process &optional until) + "Wait for PROCESS to exit and for its sentinel to have run. +UNTIL, when given, is polled after each turn of the event loop; waiting +stops as soon as it returns non-nil. + +`process-live-p' goes nil the moment the child exits, but the sentinel is +queued and runs on a *later* turn of the event loop. A test that stops +waiting at process death therefore lets the sentinel's message land inside +the next test's `message' mock. That is not hypothetical: it put \"false +exited with status 1\" into the silent-player test under a coverage run, +where instrumentation slowed things just enough to expose the race." + (while (process-live-p process) + (accept-process-output process 0 50)) + (let ((deadline (time-add (current-time) + (seconds-to-time (if until 2 0.3))))) + (while (and (time-less-p (current-time) deadline) + (not (and until (funcall until)))) + (accept-process-output nil 0 20)))) + +;;; Normal Cases + +(chime-deftest test-chime-sound-find-sound-player-auto-returns-first-available () + "Auto mode returns the only candidate found on PATH." + (let ((chime-sound-player 'auto)) + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) (equal cmd "aplay")))) + ;; The sound-server players are absent, so aplay is all that is left. + (should (equal (chime--find-sound-player) "aplay"))))) + +(chime-deftest test-chime-sound-find-sound-player-prefers-sound-server-over-raw-alsa () + "Auto mode prefers a sound-server player over raw ALSA." + (let ((chime-sound-player 'auto)) + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) (member cmd '("pw-play" "paplay" "aplay"))))) + (should (equal (chime--find-sound-player) "pw-play"))))) + +(chime-deftest test-chime-sound-find-sound-player-explicit-string-is-used () + "An explicit player string is used when it is on PATH." + (let ((chime-sound-player "mpv")) + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) (equal cmd "mpv")))) + (should (equal (chime--find-sound-player) "mpv"))))) + +(chime-deftest test-chime-sound-find-sound-player-emacs-mode-returns-nil () + "Emacs mode never selects an external player." + (let ((chime-sound-player 'emacs)) + (cl-letf (((symbol-function 'executable-find) (lambda (_cmd) t))) + (should-not (chime--find-sound-player))))) + +(chime-deftest test-chime-sound-play-sound-spawns-external-player () + "The external player is spawned with the sound file, and Emacs' player is not used." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'auto) + (spawned nil) + (emacs-player-called nil)) + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) (equal cmd "pw-play"))) + ((symbol-function 'start-process) + (lambda (_name _buffer program &rest args) + (setq spawned (cons program args)) + (test-chime-sound--stand-in-process))) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (setq emacs-player-called t)))) + (chime--play-sound) + (should (equal spawned (list "pw-play" file))) + (should-not emacs-player-called)))) + +(chime-deftest test-chime-sound-play-sound-external-returns-a-process () + "External playback hands back a process rather than awaiting the clip. +Runs the real spawn path against `true', a harmless stand-in player. +Returning a process is the evidence that playback is asynchronous: the +synchronous `play-sound-file' path returns t instead." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player "true") + (emacs-player-called nil)) + (cl-letf (((symbol-function 'play-sound-file) + (lambda (&rest _) (setq emacs-player-called t)))) + (should (processp (chime--play-sound))) + (should-not emacs-player-called)))) + +(chime-deftest test-chime-sound-play-sound-emacs-mode-passes-device () + "Emacs mode calls `play-sound-file' with `chime-sound-device' as DEVICE." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'emacs) + (chime-sound-device "pipewire") + (call-args nil)) + (cl-letf (((symbol-function 'play-sound-file) + (lambda (&rest args) (setq call-args args)))) + (chime--play-sound) + (should (equal call-args (list file nil "pipewire")))))) + +;;; Boundary Cases + +(chime-deftest test-chime-sound-play-sound-nil-device-passed-through () + "A nil `chime-sound-device' is still passed as the DEVICE argument." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'emacs) + (chime-sound-device nil) + (call-args nil)) + (cl-letf (((symbol-function 'play-sound-file) + (lambda (&rest args) (setq call-args args)))) + (chime--play-sound) + (should (equal call-args (list file nil nil)))))) + +(chime-deftest test-chime-sound-play-sound-nil-sound-file-plays-nothing () + "No sound file configured means nothing is played." + (let ((chime-sound-file nil) + (chime-sound-player 'auto) + (played nil)) + (cl-letf (((symbol-function 'executable-find) (lambda (_cmd) t)) + ((symbol-function 'start-process) + (lambda (&rest _) (setq played t) 'fake-process)) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (setq played t)))) + (chime--play-sound) + (should-not played)))) + +(chime-deftest test-chime-sound-play-sound-missing-file-plays-nothing () + "A configured but absent sound file means nothing is played." + (let ((chime-sound-file (expand-file-name "absent.wav" chime-test-base-dir)) + (chime-sound-player 'auto) + (played nil)) + (cl-letf (((symbol-function 'executable-find) (lambda (_cmd) t)) + ((symbol-function 'start-process) + (lambda (&rest _) (setq played t) 'fake-process)) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (setq played t)))) + (chime--play-sound) + (should-not played)))) + +(chime-deftest test-chime-sound-play-sound-no-player-found-falls-back-to-emacs () + "With no external player on PATH, Emacs' `play-sound-file' is used." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'auto) + (emacs-player-called nil)) + (cl-letf (((symbol-function 'executable-find) (lambda (_cmd) nil)) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (setq emacs-player-called t)))) + (chime--play-sound) + (should emacs-player-called)))) + +(chime-deftest test-chime-sound-play-sound-explicit-player-absent-falls-back-to-emacs () + "An explicit player that is not installed falls back to Emacs' player." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player "no-such-player") + (emacs-player-called nil)) + (cl-letf (((symbol-function 'executable-find) (lambda (_cmd) nil)) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (setq emacs-player-called t)))) + (chime--play-sound) + (should emacs-player-called)))) + +;;; Error Cases + +(chime-deftest test-chime-sound-external-spawn-failure-falls-back-to-emacs () + "When spawning the external player signals, Emacs' player is used instead." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'auto) + (emacs-player-called nil)) + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) (equal cmd "pw-play"))) + ((symbol-function 'start-process) + (lambda (&rest _) (error "Spawning failed"))) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (setq emacs-player-called t)))) + (chime--play-sound) + (should emacs-player-called)))) + +(chime-deftest test-chime-sound-play-sound-external-returns-nil-on-spawn-error () + "`chime--play-sound-external' reports failure rather than signalling." + (cl-letf (((symbol-function 'start-process) + (lambda (&rest _) (error "Spawning failed")))) + (should-not (chime--play-sound-external "pw-play" "/tmp/whatever.wav")))) + +(chime-deftest test-chime-sound-external-nonzero-exit-is-reported () + "A player that spawns but fails is reported rather than failing silently. +`false' stands in for a player that cannot decode the file." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player "false") + (messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))) + (let ((process (chime--play-sound))) + (should (processp process)) + ;; The sentinel fires after the player exits, on a later turn of the + ;; event loop -- wait for the message, not merely for the exit. + (test-chime-sound--await-exit + process (lambda () (test-chime-sound--chime-messages messages)))) + (let ((chime-messages (test-chime-sound--chime-messages messages))) + (should (= (length chime-messages) 1)) + (should (string-match-p "Failed to play sound" (car chime-messages))) + (should (string-match-p "false" (car chime-messages))))))) + +(chime-deftest test-chime-sound-external-zero-exit-is-silent () + "A player that succeeds reports nothing." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player "true") + (messages nil)) + (cl-letf (((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))) + (let ((process (chime--play-sound))) + ;; Drain the sentinel too, so a late message can't slip past the + ;; assertion and into the next test. + (test-chime-sound--await-exit process)) + (should-not (test-chime-sound--chime-messages messages))))) + +(chime-deftest test-chime-sound-both-players-failing-reports-once-and-does-not-signal () + "When both players fail, the error is reported and not propagated." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'auto) + (messages nil)) + (cl-letf (((symbol-function 'executable-find) + (lambda (cmd) (equal cmd "pw-play"))) + ((symbol-function 'start-process) + (lambda (&rest _) (error "Spawning failed"))) + ((symbol-function 'play-sound-file) + (lambda (&rest _) (error "No usable sound device driver found"))) + ((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))) + ;; Must not signal. + (should-not (condition-case nil + (progn (chime--play-sound) nil) + (error t))) + (let ((chime-messages (test-chime-sound--chime-messages messages))) + (should (= (length chime-messages) 1)) + (should (string-match-p "Failed to play sound" (car chime-messages))))))) + +(chime-deftest test-chime-sound-emacs-player-error-is-reported-not-signalled () + "A `play-sound-file' failure is reported and swallowed." + (let* ((file (test-chime-sound--existing-file)) + (chime-sound-file file) + (chime-sound-player 'emacs) + (messages nil)) + (cl-letf (((symbol-function 'play-sound-file) + (lambda (&rest _) (error "No usable sound device driver found"))) + ((symbol-function 'message) + (lambda (fmt &rest args) (push (apply #'format fmt args) messages)))) + (should-not (condition-case nil + (progn (chime--play-sound) nil) + (error t))) + (let ((chime-messages (test-chime-sound--chime-messages messages))) + (should (= (length chime-messages) 1)) + (should (string-match-p "Failed to play sound" (car chime-messages))))))) + +(chime-deftest test-chime-sound-device-rejects-non-string-at-customize-time () + "Setting `chime-sound-device' to a non-string is rejected." + (should-error (customize-set-variable 'chime-sound-device 42) + :type 'user-error) + ;; nil is allowed -- it means "let the system decide". + (customize-set-variable 'chime-sound-device nil) + (should-not chime-sound-device)) + +(provide 'test-chime-sound) +;;; test-chime-sound.el ends here |
