1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
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
|