mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 18:34:05 +01:00
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
128 lines
3.8 KiB
Python
128 lines
3.8 KiB
Python
"""The ring loop: polling cadence, backend fallback, always silenced."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
from pathlib import Path
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from wake_alarm import _fallback_ring
|
|
from wake_alarm._fallback_ring import BACKEND_FAILED, Cadence, ring
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Iterator
|
|
|
|
RISE, LOOP = Path("rise.wav"), Path("loop.wav")
|
|
|
|
|
|
class FakeBackend:
|
|
def __init__(self, name: str, *, start_ok: bool = True, pumps: int = 10**6) -> None:
|
|
self.name = name
|
|
self.start_ok = start_ok
|
|
self.pumps_left = pumps
|
|
self.stopped = 0
|
|
|
|
def start(self) -> tuple[bool, str]:
|
|
return self.start_ok, "detail"
|
|
|
|
def pump(self) -> bool:
|
|
self.pumps_left -= 1
|
|
return self.pumps_left >= 0
|
|
|
|
def stop(self) -> None:
|
|
self.stopped += 1
|
|
|
|
|
|
class Clock:
|
|
def __init__(self) -> None:
|
|
self.t = 0.0
|
|
|
|
def __call__(self) -> float:
|
|
return self.t
|
|
|
|
def sleep(self, seconds: float) -> None:
|
|
self.t += seconds
|
|
|
|
|
|
def _polls(*answers: str | None) -> Iterator[str | None]:
|
|
return iter(answers)
|
|
|
|
|
|
def _ring(backend: FakeBackend, answers: Iterator[str | None], clock: Clock) -> str:
|
|
with patch.object(_fallback_ring, "select_backend", return_value=(backend, "why")):
|
|
return ring(
|
|
lambda: next(answers),
|
|
rise=RISE,
|
|
loop=LOOP,
|
|
cadence=Cadence(clock, clock.sleep, 20),
|
|
)
|
|
|
|
|
|
def test_polls_every_20_seconds_until_a_stop_reason(
|
|
caplog: pytest.LogCaptureFixture,
|
|
) -> None:
|
|
clock, backend = Clock(), FakeBackend("pipewire")
|
|
with caplog.at_level(logging.INFO):
|
|
assert _ring(backend, _polls(None, None, "session_started"), clock) == (
|
|
"session_started"
|
|
)
|
|
assert clock.t == pytest.approx(60, abs=1)
|
|
assert backend.stopped == 1
|
|
assert "ring_backend: pipewire (started)" in caplog.text
|
|
|
|
|
|
def test_pipewire_refusing_falls_back_to_alsa() -> None:
|
|
alsa = FakeBackend("alsa")
|
|
with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa):
|
|
alsa.name = "alsa"
|
|
result = _ring(FakeBackend("pipewire", start_ok=False), _polls("cap"), Clock())
|
|
assert result == "cap"
|
|
assert alsa.stopped == 1
|
|
|
|
|
|
def test_no_backend_at_all() -> None:
|
|
alsa = FakeBackend("alsa", start_ok=False)
|
|
with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa):
|
|
assert _ring(alsa, _polls(), Clock()) == BACKEND_FAILED
|
|
|
|
|
|
def test_pipewire_dying_mid_ring_hands_over_to_alsa() -> None:
|
|
pipewire, alsa = FakeBackend("pipewire", pumps=3), FakeBackend("alsa")
|
|
with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa):
|
|
assert _ring(pipewire, _polls("escape"), Clock()) == "escape"
|
|
assert pipewire.stopped == 1
|
|
assert alsa.stopped == 1
|
|
|
|
|
|
def test_alsa_dying_mid_ring_ends_the_ring() -> None:
|
|
alsa = FakeBackend("alsa", pumps=2)
|
|
assert _ring(alsa, _polls(None), Clock()) == BACKEND_FAILED
|
|
assert alsa.stopped >= 1
|
|
|
|
|
|
def test_replacement_alsa_failing_ends_the_ring() -> None:
|
|
pipewire, alsa = (
|
|
FakeBackend("pipewire", pumps=1),
|
|
FakeBackend("alsa", start_ok=False),
|
|
)
|
|
with patch.object(_fallback_ring, "AlsaUnitBackend", return_value=alsa):
|
|
assert _ring(pipewire, _polls(), Clock()) == BACKEND_FAILED
|
|
|
|
|
|
def test_an_exception_still_silences_the_backend() -> None:
|
|
backend = FakeBackend("pipewire")
|
|
|
|
def poll() -> str | None:
|
|
raise SystemExit(143)
|
|
|
|
clock = Clock()
|
|
with (
|
|
patch.object(_fallback_ring, "select_backend", return_value=(backend, "w")),
|
|
pytest.raises(SystemExit),
|
|
):
|
|
ring(poll, rise=RISE, loop=LOOP, cadence=Cadence(clock, clock.sleep, 0))
|
|
assert backend.stopped == 1
|