wake-alarm/wake_alarm/tests/test_alarm_sinks.py

282 lines
9.5 KiB
Python
Raw Normal View History

"""Tests for sink management and parse_args in wake alarm."""
from __future__ import annotations
import subprocess
from unittest.mock import MagicMock, patch
from wake_alarm._alarm import _parse_args
from wake_alarm._audio import (
_activate_alarm_audio,
_alarm_sink_present,
_current_default_sink,
_restore_alarm_audio,
_warn_if_no_real_sink,
)
class TestWarnIfNoRealSink:
"""Tests for _warn_if_no_real_sink."""
def test_logs_when_pactl_missing(self) -> None:
"""No pactl on PATH → warns and returns."""
with (
patch(
"wake_alarm._audio.shutil.which",
return_value=None,
),
patch(
"wake_alarm._audio.subprocess.run",
) as mock_run,
):
_warn_if_no_real_sink()
mock_run.assert_not_called()
def test_warns_when_only_auto_null(self) -> None:
"""Only auto_null sink → warning is emitted."""
result = MagicMock()
result.stdout = b"4319\tauto_null\tPipeWire\tfloat32le 2ch 48000Hz\tIDLE\n"
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"wake_alarm._audio.subprocess.run",
return_value=result,
),
patch("wake_alarm._audio._logger") as mock_log,
):
_warn_if_no_real_sink()
mock_log.warning.assert_called()
def test_info_when_real_sink_present(self) -> None:
"""A non-auto_null sink → info log, no warning."""
result = MagicMock()
result.stdout = b"1\talsa_output.pci-0000_01_00.1.hdmi-stereo\tPipeWire\t-\t-\n"
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"wake_alarm._audio.subprocess.run",
return_value=result,
),
patch("wake_alarm._audio._logger") as mock_log,
):
_warn_if_no_real_sink()
mock_log.info.assert_called()
mock_log.warning.assert_not_called()
def test_handles_pactl_failure(self) -> None:
"""OSError/TimeoutExpired running pactl → warning, no raise."""
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("pactl", 5),
),
):
_warn_if_no_real_sink() # must not raise
class TestAlarmSinkPresent:
"""Tests for _alarm_sink_present."""
def test_true_when_sink_listed(self) -> None:
"""Returns True when the alarm sink name appears in pactl output."""
from wake_alarm._constants import ALARM_AUDIO_SINK
proc = MagicMock(stdout=ALARM_AUDIO_SINK.encode() + b"\tPipeWire\n")
with patch(
"wake_alarm._audio.subprocess.run",
return_value=proc,
):
assert _alarm_sink_present("/usr/bin/pactl") is True
def test_false_when_sink_absent(self) -> None:
"""Returns False when the alarm sink is not in pactl output."""
proc = MagicMock(stdout=b"auto_null\tPipeWire\n")
with patch(
"wake_alarm._audio.subprocess.run",
return_value=proc,
):
assert _alarm_sink_present("/usr/bin/pactl") is False
def test_false_on_subprocess_error(self) -> None:
"""OSError while listing sinks → False, no raise."""
with patch(
"wake_alarm._audio.subprocess.run",
side_effect=OSError("boom"),
):
assert _alarm_sink_present("/usr/bin/pactl") is False
class TestCurrentDefaultSink:
"""Tests for _current_default_sink."""
def test_returns_sink_name(self) -> None:
"""Returns the trimmed default sink name."""
proc = MagicMock(stdout=b"jbl_sink\n")
with patch(
"wake_alarm._audio.subprocess.run",
return_value=proc,
):
assert _current_default_sink("/usr/bin/pactl") == "jbl_sink"
def test_returns_none_when_empty(self) -> None:
"""Empty output → None."""
proc = MagicMock(stdout=b"\n")
with patch(
"wake_alarm._audio.subprocess.run",
return_value=proc,
):
assert _current_default_sink("/usr/bin/pactl") is None
def test_returns_none_on_error(self) -> None:
"""TimeoutExpired → None, no raise."""
with patch(
"wake_alarm._audio.subprocess.run",
side_effect=subprocess.TimeoutExpired("pactl", 3),
):
assert _current_default_sink("/usr/bin/pactl") is None
class TestActivateAlarmAudio:
"""Tests for _activate_alarm_audio."""
def test_returns_none_when_pactl_missing(self) -> None:
"""No pactl on PATH → returns None without touching audio."""
with (
patch("wake_alarm._audio.shutil.which", return_value=None),
patch("wake_alarm._audio.subprocess.run") as mock_run,
):
assert _activate_alarm_audio() is None
mock_run.assert_not_called()
def test_activates_and_returns_old_default(self) -> None:
"""Sink present → routes audio there and returns prior default sink."""
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"wake_alarm._audio._alarm_sink_present",
return_value=True,
),
patch(
"wake_alarm._audio._current_default_sink",
return_value="jbl_sink",
),
patch("wake_alarm._audio.subprocess.run") as mock_run,
):
result = _activate_alarm_audio()
assert result == "jbl_sink"
cmds = [call.args[0] for call in mock_run.call_args_list]
from wake_alarm._constants import (
ALARM_AUDIO_CARD,
ALARM_AUDIO_PROFILE,
ALARM_AUDIO_SINK,
)
assert [
"/usr/bin/pactl",
"set-card-profile",
ALARM_AUDIO_CARD,
ALARM_AUDIO_PROFILE,
] in cmds
assert ["/usr/bin/pactl", "set-default-sink", ALARM_AUDIO_SINK] in cmds
def test_returns_none_when_sink_never_appears(self) -> None:
"""Sink never shows up → returns None after polling (no raise)."""
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"wake_alarm._audio._alarm_sink_present",
return_value=False,
),
patch("wake_alarm._audio.time.sleep") as mock_sleep,
patch("wake_alarm._audio.subprocess.run"),
):
assert _activate_alarm_audio() is None
mock_sleep.assert_called()
def test_waits_then_succeeds(self) -> None:
"""Sink absent then present → sleeps once, then routes audio."""
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch(
"wake_alarm._audio._alarm_sink_present",
side_effect=[False, True],
),
patch(
"wake_alarm._audio._current_default_sink",
return_value="old",
),
patch("wake_alarm._audio.time.sleep") as mock_sleep,
patch("wake_alarm._audio.subprocess.run"),
):
assert _activate_alarm_audio() == "old"
mock_sleep.assert_called_once()
class TestRestoreAlarmAudio:
"""Tests for _restore_alarm_audio."""
def test_none_is_noop(self) -> None:
"""None default → does nothing, no pactl lookup."""
with patch("wake_alarm._audio.shutil.which") as mock_which:
_restore_alarm_audio(None)
mock_which.assert_not_called()
def test_no_pactl_returns_silently(self) -> None:
"""Default present but pactl missing → no raise, no run."""
with (
patch("wake_alarm._audio.shutil.which", return_value=None),
patch("wake_alarm._audio.subprocess.run") as mock_run,
):
_restore_alarm_audio("jbl_sink")
mock_run.assert_not_called()
def test_restores_default_sink(self) -> None:
"""Calls set-default-sink with the captured prior default."""
with (
patch(
"wake_alarm._audio.shutil.which",
return_value="/usr/bin/pactl",
),
patch("wake_alarm._audio.subprocess.run") as mock_run,
):
_restore_alarm_audio("jbl_sink")
cmds = [call.args[0] for call in mock_run.call_args_list]
assert ["/usr/bin/pactl", "set-default-sink", "jbl_sink"] in cmds
class TestParseArgs:
"""Tests for _parse_args."""
def test_default_flags_are_false(self) -> None:
"""No CLI args means every flag is False."""
ns = _parse_args([])
assert ns.demo is False
assert ns.trigger_now is False
assert ns.production is False
def test_flags_parse(self) -> None:
"""Each flag flips to True when passed."""
ns = _parse_args(["--production", "--demo", "--trigger-now"])
assert ns.production is True
assert ns.demo is True
assert ns.trigger_now is True