mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 14:07:32 +01:00
Two defences, because one press and one held press are different problems. **Prevention — _inhibit.py.** A logind block-mode inhibitor on handle-power-key:shutdown:idle, taken while the alarm is audible and released in on_close(). Confirmed on the host before writing it that PowerKeyIgnoreInhibited=no is in force, so no logind.conf drop-in is needed -- and none is added: a drop-in changes the machine permanently, an inhibitor is scoped to the alarm. Verified live: the inhibitor appears in `systemd-inhibit --list` as `wake-alarm ... shutdown:idle:handle-power-key ... block` and disappears on release. The child runs `sleep 7200`, never `sleep infinity`. An orphaned block-mode inhibitor would hold the power button down until reboot -- worse than the bug being fixed -- so it self-terminates, and a stale one is cleared at startup. That cleanup was wrong on the first attempt and the host run caught it: `pkill -f "--who=wake-alarm"` matched the *shell that had just typed the pattern* and killed it, leaving the real inhibitor alive (`killed 0`, one inhibitor still listed). Substring matching is inherently self-matching. Now it lists `pgrep -x systemd-inhibit` and confirms each pid via /proc/<pid>/cmdline. Re-verified: orphan reaped, exactly one live inhibitor, zero remaining after release. **Consequence — _alarm_session.py.** The real gap was that nothing on disk ever said "ringing, not yet dismissed": wake_state.json is written only on dismissal, so a hard power-off left no trace and the catch-up window closed while the machine was down. A signed record is now written at fire time, and _trigger re-fires on one still marked ringing, ignoring the window. Deliberately no date field. _state.py keys by UTC and screen_locker/_wake_state.py mirrors that helper to read the very same file from another repo, so the UTC basis there is load-bearing. The plan's _today_str() flip is therefore NOT done -- it would have made any dismissal between 00:00-02:00 local read as yesterday's in screen-locker and silently lose an earned workout skip. This record carries only an aware fired_at and compares elapsed time, so the midnight question never arises. Age-bounded at 5400 s: an alarm that dies before dismissal leaves a record nothing ever clears, and unbounded this would restart the alarm every minute forever. Two bugs the unit tests could not see, both found by running it: - A future-stamped record returned None, i.e. "not ringing". But a backward clock jump is routine on a machine that hibernates nightly (NTP correcting after resume), so that failed *open* on exactly the escape it closes: ring on a fast clock, clock corrects, power-off works again. Now clamped to zero within a 900 s tolerance and logged. - _alarm.py derived the correlation id from the alarm's *start* instant. On a catch-up or re-fire the PC would write wake-...-0735 while the phone wrote wake-...-0700 and the two log streams stop joining. Now correlation_id_for_today() derives it from the resolved alarm time, which is what _correlation.py always specified. Every trigger test patched should_refire, so _decide against a real record was untested -- which is why only the manual run found the first one. There is now an end-to-end test that writes a real session and asserts the decision. Splits carried: test_trigger.py 294 -> 197 + test_trigger_main.py, test_alarm_part2.py 266 -> 170 + test_alarm_timers_flash.py. The inhibitor handle moved into _AlarmHardware, which also settles pylint's attribute count and puts it beside the other thing on_close() gives back. No new suppressions: the one inline pylint disable was redundant, since consider-using-with is already disabled repo-wide in pyproject.toml. 452 tests pass, 100% branch coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
144 lines
5.4 KiB
Python
144 lines
5.4 KiB
Python
"""Tests for _trigger.py — the systemd invocation and the CLI entry point.
|
|
|
|
Split from ``test_trigger.py`` for the 250-line cap. The header is copied
|
|
wholesale rather than trimmed: without the autouse ``_ungated`` fixture these
|
|
tests read the *real* gates on the host and would pass or fail by accident.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
|
|
from wake_alarm._sync import Alarm
|
|
from wake_alarm._trigger import (
|
|
_parse_args,
|
|
_start_target_unit,
|
|
main,
|
|
)
|
|
|
|
|
|
def _at(hour: int, minute: int) -> datetime:
|
|
"""Return a Sunday at the given local wall-clock time, timezone-aware.
|
|
|
|
Built the same way production does — naive wall clock, then astimezone()
|
|
to attach the system offset — so the tests exercise the real comparison
|
|
semantics rather than a hand-picked fixed offset.
|
|
"""
|
|
return datetime(2026, 7, 19, hour, minute, tzinfo=None).astimezone()
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def _ungated() -> None:
|
|
"""Open every gate unless a test closes one.
|
|
|
|
``should_refire`` is stubbed here too: unstubbed it reads the *real*
|
|
session record on the host, so a test run started while an alarm was
|
|
actually ringing would silently invert half these assertions.
|
|
"""
|
|
with (
|
|
patch("wake_alarm._trigger.was_alarm_dismissed_today", return_value=False),
|
|
patch("wake_alarm._trigger.was_workout_logged_today", return_value=False),
|
|
patch("wake_alarm._trigger.should_refire", return_value=False),
|
|
):
|
|
yield
|
|
|
|
|
|
class TestStartTargetUnit:
|
|
"""Tests for the systemctl invocation."""
|
|
|
|
def test_starts_the_orchestrator_not_the_alarm(self) -> None:
|
|
"""The trigger must start morning-routine, never wake-alarm."""
|
|
completed = MagicMock(returncode=0)
|
|
with patch("subprocess.run", return_value=completed) as run:
|
|
assert _start_target_unit()
|
|
assert run.call_args[0][0] == [
|
|
"systemctl",
|
|
"--user",
|
|
"start",
|
|
"morning-routine.service",
|
|
]
|
|
|
|
def test_nonzero_exit_is_reported(self) -> None:
|
|
"""A systemctl failure returns False rather than raising."""
|
|
completed = MagicMock(returncode=1, stderr=b"no such unit")
|
|
with patch("subprocess.run", return_value=completed):
|
|
assert not _start_target_unit()
|
|
|
|
def test_missing_systemctl_is_swallowed(self) -> None:
|
|
"""An OSError (no systemctl) returns False rather than raising."""
|
|
with patch("subprocess.run", side_effect=OSError("not found")):
|
|
assert not _start_target_unit()
|
|
|
|
def test_does_not_raise_on_failure(self) -> None:
|
|
"""check=False: a failing start never raises CalledProcessError."""
|
|
completed = MagicMock(returncode=1, stderr=b"")
|
|
with patch("subprocess.run", return_value=completed) as run:
|
|
_start_target_unit()
|
|
assert run.call_args.kwargs["check"] is False
|
|
|
|
|
|
class TestMain:
|
|
"""Tests for the tick entry point."""
|
|
|
|
def test_parse_args_default(self) -> None:
|
|
"""--dry-run defaults to off."""
|
|
assert not _parse_args([]).dry_run
|
|
|
|
def _run_main(self, argv: list[str], now: datetime, alarm: Alarm) -> MagicMock:
|
|
with (
|
|
patch("sys.argv", ["_trigger", *argv]),
|
|
patch("wake_alarm._trigger._now", return_value=now),
|
|
patch("wake_alarm._trigger.get_alarm", return_value=alarm),
|
|
patch("wake_alarm._trigger._start_target_unit") as start,
|
|
):
|
|
main()
|
|
return start
|
|
|
|
def test_fires_at_the_alarm_minute(self) -> None:
|
|
"""On the alarm minute the target unit is started."""
|
|
start = self._run_main([], _at(6, 45), Alarm(6, 45, "remote"))
|
|
start.assert_called_once()
|
|
|
|
def test_does_not_fire_early(self) -> None:
|
|
"""Before the alarm nothing is started."""
|
|
start = self._run_main([], _at(5, 0), Alarm(6, 45, "remote"))
|
|
start.assert_not_called()
|
|
|
|
def test_dry_run_never_starts_anything(
|
|
self, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""--dry-run reports WOULD FIRE without touching systemd."""
|
|
start = self._run_main(["--dry-run"], _at(6, 45), Alarm(6, 45, "remote"))
|
|
start.assert_not_called()
|
|
assert "WOULD FIRE" in capsys.readouterr().out
|
|
|
|
def test_dry_run_reports_not_firing(
|
|
self, capsys: pytest.CaptureFixture[str]
|
|
) -> None:
|
|
"""--dry-run explains why it is not firing."""
|
|
self._run_main(["--dry-run"], _at(5, 0), Alarm(6, 45, "remote"))
|
|
out = capsys.readouterr().out
|
|
assert "not firing" in out
|
|
assert "still ahead" in out
|
|
|
|
@pytest.mark.parametrize("source", ["fallback", "stale-cache"])
|
|
def test_warns_when_firing_on_a_degraded_source(
|
|
self, caplog: pytest.LogCaptureFixture, source: str
|
|
) -> None:
|
|
"""Firing on a non-phone alarm time is logged loudly."""
|
|
with caplog.at_level("WARNING"):
|
|
self._run_main([], _at(6, 45), Alarm(6, 45, source))
|
|
assert "degraded alarm source" in caplog.text
|
|
|
|
@pytest.mark.parametrize("source", ["remote", "cache"])
|
|
def test_quiet_when_firing_on_the_phone_alarm(
|
|
self, caplog: pytest.LogCaptureFixture, source: str
|
|
) -> None:
|
|
"""A normal fire does not emit the degraded-source warning."""
|
|
with caplog.at_level("WARNING"):
|
|
self._run_main([], _at(6, 45), Alarm(6, 45, source))
|
|
assert "degraded alarm source" not in caplog.text
|