aboutsummaryrefslogtreecommitdiff
path: root/tests/test-chime-sound.el
diff options
context:
space:
mode:
Diffstat (limited to 'tests/test-chime-sound.el')
-rw-r--r--tests/test-chime-sound.el334
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