wake-alarm/wake_alarm/tests/test_alarm_playback.py
Krzysztof kuhy Rudnicki 142f4484d8
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Play alarm audio on every sink, not one hardcoded output
At wake time PipeWire often has only the auto_null sink (verified from
journalctl on 2026-07-11: "ONLY auto_null PipeWire sink available — alarm
will be SILENT"). The old code forced only the single HDMI monitor card
on and waited for its one sink; when that never enumerated, all audio
drained into auto_null = silence.

Now activation forces *every* card's output profile on, unmutes and maxes
every sink, and the beep loop plays on *all* sinks each cycle
(paplay --device), re-enumerating so late/added outputs are covered. A
switched-off external speaker can't be revived by software, so every
other output is tried in parallel instead of betting on one. Prior
per-card profile and per-sink volume/mute are captured and restored on
dismiss.

Concurrent paplay children are now reaped each cycle (was 1/cycle, would
have been N/cycle of <defunct> over the 30-min loud phase).

Extract the pactl sink/card management into wake_alarm/_sinks.py to keep
_audio.py under the 500-line limit. 100% branch coverage retained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018SJ6HYXhBmYs3SiGBZv1rF
2026-07-11 18:12:39 +02:00

137 lines
5.6 KiB
Python

"""Tests for per-sink alarm playback (_play_on_all_sinks and helpers)."""
from __future__ import annotations
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from wake_alarm._audio import _play_on_all_sinks, _play_wav_on_sink, _reap_players
from wake_alarm._sinks import _ensure_sink_audible
if TYPE_CHECKING:
from pathlib import Path
class TestPlayOnAllSinks:
"""Tests for _play_on_all_sinks."""
def test_noop_when_pactl_missing(self) -> None:
"""No pactl → returns without looking up paplay or spawning Popen."""
with (
patch("wake_alarm._audio._pactl_path", return_value=None),
patch("wake_alarm._audio.shutil.which") as mock_which,
patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
):
_play_on_all_sinks(1000)
mock_which.assert_not_called()
mock_popen.assert_not_called()
def test_warns_when_paplay_missing(self) -> None:
"""pactl present but paplay absent → warning, no Popen."""
with (
patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"),
patch("wake_alarm._audio.shutil.which", return_value=None),
patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
):
_play_on_all_sinks(1000)
mock_popen.assert_not_called()
def test_returns_when_wav_generation_fails(self) -> None:
"""OSError generating the tone WAV → returns, no Popen."""
with (
patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"),
patch("wake_alarm._audio.shutil.which", return_value="/usr/bin/paplay"),
patch(
"wake_alarm._audio._ensure_tone_wav",
side_effect=OSError("no tempdir"),
),
patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
):
_play_on_all_sinks(1000)
mock_popen.assert_not_called()
def test_plays_and_maxes_each_sink(self, tmp_path: Path) -> None:
"""Each real sink is made audible, then played on via paplay --device."""
wav = tmp_path / "tone.wav"
with (
patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"),
patch("wake_alarm._audio.shutil.which", return_value="/usr/bin/paplay"),
patch("wake_alarm._audio._ensure_tone_wav", return_value=wav),
patch(
"wake_alarm._audio._list_real_sinks",
return_value=["sink_a", "sink_b"],
),
patch("wake_alarm._audio._ensure_sink_audible") as mock_audible,
patch("wake_alarm._audio.subprocess.Popen") as mock_popen,
):
_play_on_all_sinks(1000)
assert mock_audible.call_count == 2
devices = [call.args[0][1] for call in mock_popen.call_args_list]
assert devices == ["--device=sink_a", "--device=sink_b"]
def test_skips_sink_whose_launch_failed(self) -> None:
"""A sink whose paplay fails to launch (None) is not reaped."""
with (
patch("wake_alarm._audio._pactl_path", return_value="/usr/bin/pactl"),
patch("wake_alarm._audio.shutil.which", return_value="/usr/bin/paplay"),
patch("wake_alarm._audio._ensure_tone_wav", return_value=MagicMock()),
patch("wake_alarm._audio._list_real_sinks", return_value=["sink_a"]),
patch("wake_alarm._audio._ensure_sink_audible"),
patch("wake_alarm._audio._play_wav_on_sink", return_value=None),
patch("wake_alarm._audio._reap_players") as mock_reap,
):
_play_on_all_sinks(1000)
mock_reap.assert_called_once_with([])
class TestReapPlayers:
"""Tests for _reap_players."""
def test_waits_for_each(self) -> None:
"""Each launched paplay is waited on (reaped)."""
procs = [MagicMock(), MagicMock()]
_reap_players(procs)
for proc in procs:
proc.wait.assert_called_once()
def test_kills_hung_player(self) -> None:
"""A paplay that exceeds the timeout is killed and then reaped."""
proc = MagicMock()
proc.wait.side_effect = [subprocess.TimeoutExpired("paplay", 6), None]
_reap_players([proc])
proc.kill.assert_called_once()
assert proc.wait.call_count == 2
class TestEnsureSinkAudible:
"""Tests for _ensure_sink_audible."""
def test_unmutes_and_maxes(self) -> None:
"""Runs set-sink-mute 0 then set-sink-volume 100% for the sink."""
with patch("wake_alarm._sinks.subprocess.run") as mock_run:
_ensure_sink_audible("/usr/bin/pactl", "sink_a")
cmds = [call.args[0] for call in mock_run.call_args_list]
assert ["/usr/bin/pactl", "set-sink-mute", "sink_a", "0"] in cmds
assert ["/usr/bin/pactl", "set-sink-volume", "sink_a", "100%"] in cmds
class TestPlayWavOnSink:
"""Tests for _play_wav_on_sink."""
def test_spawns_paplay_with_device(self, tmp_path: Path) -> None:
"""Popen is called with paplay --device=<sink> and the wav path."""
with patch("wake_alarm._audio.subprocess.Popen") as mock_popen:
_play_wav_on_sink("/usr/bin/paplay", "sink_a", tmp_path / "tone.wav")
args = mock_popen.call_args.args[0]
assert args[0] == "/usr/bin/paplay"
assert args[1] == "--device=sink_a"
def test_ignores_oserror(self, tmp_path: Path) -> None:
"""OSError from Popen is swallowed (must not raise)."""
with patch(
"wake_alarm._audio.subprocess.Popen",
side_effect=OSError("device busy"),
):
_play_wav_on_sink("/usr/bin/paplay", "sink_a", tmp_path / "tone.wav")