mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 13:47:31 +01:00
_reactivate.py shipped in phase 2a with tests but nothing called it, so the
HDMI sink fix was never actually in the alarm's path. WakeAlarm.__init__ now
starts the retry next to _activate_all_outputs(), stopped by the same
_stop_beep event the beep loop watches.
Verified on the host, not just in tests. Starting state was the exact bug:
alsa_card.pci-0000_01_00.1 Active Profile: off
sinks: alsa_output.pci-0000_0d_00.6.analog-stereo
After the retry ran, both passes logged:
2 sink(s) present: alsa_output.pci-0000_01_00.1.hdmi-stereo,
alsa_output.pci-0000_0d_00.6.analog-stereo
default=alsa_output.pci-0000_01_00.1.hdmi-stereo
and the captured card profiles restored cleanly afterwards.
Two wiring details worth keeping:
- _activate_outputs_only() drops what _activate_all_outputs() returns. Only
the first call sees the user's real audio state; every retry captures the
alarm's own. Returning one would let a later caller restore the alarm's
settings as if they were the user's.
- _list_real_sinks_now() re-resolves pactl each pass and reports no sinks
when it is absent. The retry outlives the startup lookup and runs on a
thread nobody is watching, where an exception would vanish silently.
_alarm.py was exactly at the 250-line cap, so the flash timers move to
_alarm_flash.py as a mixin, mirroring AlarmTimersMixin. AlarmTimersMixin's
abstract _start_flash_countdown stub had to go: it is first in the MRO and
would shadow the real implementation. main() stays in _alarm.py --
morning_routine/_orchestrator.py:39 hardcodes that path.
conftest stubs the reactivation autouse; without it every WakeAlarm test
would reshape the host's audio routing.
409 tests pass, 100% branch coverage.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
214 lines
6.4 KiB
Python
214 lines
6.4 KiB
Python
"""Tests for repeated card-profile reactivation while the alarm rings."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
import time
|
|
|
|
import pytest
|
|
|
|
from wake_alarm import _reactivate
|
|
from wake_alarm._reactivate import (
|
|
reactivate_until_stable,
|
|
start_background_reactivation,
|
|
)
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _fast_schedule(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""Use a short, deterministic retry schedule instead of the real one."""
|
|
monkeypatch.setattr(_reactivate, "REACTIVATE_ATTEMPT_DELAYS", (1.0, 2.0))
|
|
|
|
|
|
def test_runs_one_pass_per_scheduled_delay_plus_the_first() -> None:
|
|
"""A schedule of two delays means three activation passes."""
|
|
calls: list[int] = []
|
|
reactivate_until_stable(
|
|
lambda: calls.append(1),
|
|
lambda: ["sink"],
|
|
sleep=lambda _d: None,
|
|
)
|
|
assert len(calls) == 3
|
|
|
|
|
|
def test_sleeps_for_each_configured_delay() -> None:
|
|
"""The decaying schedule is honoured, not collapsed into a busy loop."""
|
|
slept: list[float] = []
|
|
reactivate_until_stable(
|
|
lambda: None,
|
|
list,
|
|
sleep=slept.append,
|
|
)
|
|
assert slept == [1.0, 2.0]
|
|
|
|
|
|
def test_returns_the_final_sink_set() -> None:
|
|
"""The caller learns which outputs ended up present."""
|
|
result = reactivate_until_stable(
|
|
lambda: None,
|
|
lambda: ["a", "b"],
|
|
sleep=lambda _d: None,
|
|
)
|
|
assert result == frozenset({"a", "b"})
|
|
|
|
|
|
def test_reports_a_sink_that_appears_late(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""The HDMI case: a sink absent at first must be announced when it lands."""
|
|
sequence = [["analog"], ["analog"], ["analog", "hdmi"]]
|
|
result = reactivate_until_stable(
|
|
lambda: None,
|
|
lambda: sequence.pop(0),
|
|
sleep=lambda _d: None,
|
|
)
|
|
assert result == frozenset({"analog", "hdmi"})
|
|
assert "hdmi" in caplog.text
|
|
|
|
|
|
def test_every_pass_logs_the_enumerated_sinks(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""Each pass records what it saw, so a silent alarm is diagnosable."""
|
|
with caplog.at_level("INFO"):
|
|
reactivate_until_stable(
|
|
lambda: None,
|
|
lambda: ["analog"],
|
|
sleep=lambda _d: None,
|
|
)
|
|
assert caplog.text.count("Sink reactivation pass") == 3
|
|
|
|
|
|
def test_logs_none_when_no_sink_is_present(caplog: pytest.LogCaptureFixture) -> None:
|
|
"""An empty enumeration is stated explicitly rather than logged blank."""
|
|
with caplog.at_level("INFO"):
|
|
reactivate_until_stable(lambda: None, list, sleep=lambda _d: None)
|
|
assert "(none)" in caplog.text
|
|
|
|
|
|
def test_stop_event_ends_retrying_before_the_sleep() -> None:
|
|
"""Dismissing the alarm stops the retry loop promptly."""
|
|
stop = threading.Event()
|
|
stop.set()
|
|
calls: list[int] = []
|
|
reactivate_until_stable(
|
|
lambda: calls.append(1),
|
|
list,
|
|
sleep=lambda _d: None,
|
|
stop=stop,
|
|
)
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_stop_event_set_during_the_sleep_ends_retrying() -> None:
|
|
"""A dismissal landing mid-delay is noticed on wake, not ignored."""
|
|
stop = threading.Event()
|
|
calls: list[int] = []
|
|
reactivate_until_stable(
|
|
lambda: calls.append(1),
|
|
list,
|
|
sleep=lambda _d: stop.set(),
|
|
stop=stop,
|
|
)
|
|
assert len(calls) == 1
|
|
|
|
|
|
def test_background_thread_runs_the_passes() -> None:
|
|
"""The helper actually executes the work off the beep loop's thread."""
|
|
done = threading.Event()
|
|
stop = threading.Event()
|
|
thread = start_background_reactivation(
|
|
done.set,
|
|
lambda: ["s"],
|
|
sleep=lambda _d: None,
|
|
stop=stop,
|
|
)
|
|
thread.join(timeout=5)
|
|
assert done.is_set()
|
|
|
|
|
|
def test_background_thread_is_a_daemon() -> None:
|
|
"""A stuck retry must never keep the process alive after dismissal."""
|
|
stop = threading.Event()
|
|
stop.set()
|
|
thread = start_background_reactivation(
|
|
lambda: None,
|
|
list,
|
|
sleep=lambda _d: None,
|
|
stop=stop,
|
|
)
|
|
thread.join(timeout=5)
|
|
assert thread.daemon
|
|
|
|
|
|
def test_activate_only_discards_the_captured_state(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Retry passes must not hand back a state anyone could restore.
|
|
|
|
Only the *first* activation sees the user's real audio settings; every
|
|
later pass captures the alarm's own. Returning one would let a future
|
|
caller restore the alarm's settings as if they were the user's.
|
|
"""
|
|
calls: list[str] = []
|
|
|
|
def fake_activate() -> str:
|
|
calls.append("activated")
|
|
return "prior-state"
|
|
|
|
monkeypatch.setattr(_reactivate, "_activate_all_outputs", fake_activate)
|
|
_reactivate._activate_outputs_only()
|
|
assert calls == ["activated"]
|
|
|
|
|
|
def test_list_sinks_now_returns_the_real_sinks(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The zero-arg adapter resolves pactl and forwards the path."""
|
|
monkeypatch.setattr(_reactivate, "_pactl_path", lambda: "/usr/bin/pactl")
|
|
monkeypatch.setattr(
|
|
_reactivate,
|
|
"_list_real_sinks",
|
|
lambda pactl: [f"sink-via-{pactl}"],
|
|
)
|
|
assert _reactivate._list_real_sinks_now() == ["sink-via-/usr/bin/pactl"]
|
|
|
|
|
|
def test_list_sinks_now_is_empty_without_pactl(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A machine with no pactl reports no sinks instead of raising.
|
|
|
|
The adapter runs on a background thread nobody is watching, so an
|
|
exception there would vanish silently.
|
|
"""
|
|
monkeypatch.setattr(_reactivate, "_pactl_path", lambda: None)
|
|
assert _reactivate._list_real_sinks_now() == []
|
|
|
|
|
|
def test_start_alarm_reactivation_binds_the_real_callables(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The one wiring point passes the real pactl adapters and time.sleep."""
|
|
captured: dict[str, object] = {}
|
|
|
|
sentinel = threading.Thread(target=lambda: None)
|
|
|
|
def fake_start(
|
|
activate: object,
|
|
list_sinks: object,
|
|
*,
|
|
sleep: object,
|
|
stop: object,
|
|
) -> threading.Thread:
|
|
captured.update(
|
|
activate=activate, list_sinks=list_sinks, sleep=sleep, stop=stop
|
|
)
|
|
return sentinel
|
|
|
|
monkeypatch.setattr(_reactivate, "start_background_reactivation", fake_start)
|
|
stop = threading.Event()
|
|
|
|
assert _reactivate.start_alarm_reactivation(stop) is sentinel
|
|
|
|
assert captured["activate"] is _reactivate._activate_outputs_only
|
|
assert captured["list_sinks"] is _reactivate._list_real_sinks_now
|
|
assert captured["sleep"] is time.sleep
|
|
assert captured["stop"] is stop
|