wake-alarm/wake_alarm/tests/test_trigger_main.py
Krzysztof kuhy Rudnicki e67cef0c54 Retire the PC's waking role; keep the trigger
The PC no longer wakes anyone -- the phone does. This deletes the alarm
window, the audio escalation, the sink/reactivation machinery, the power-key
inhibitor, the escape hatch, the curtains, the smart plug, the brightness and
DDC/CI control, the hibernate path (shutdown wrapper + rtcwake + sleep hook)
and the challenge set: 30 modules, 34 test files, ~5k lines.

What stays is the part that still earns its keep: the per-minute trigger
reads the alarm the phone published and starts morning-routine.service, so
the workout lock still runs at the right time.

THE ONE LOAD-BEARING BEHAVIOUR CHANGE. Deleting _alarm.py orphaned the only
writer of wake_state.json, and _trigger gated on it via
was_alarm_dismissed_today(). With that gone -- and was_workout_logged_today()
already permanently False, since WORKOUT_LOG_FILE does not exist -- both
gates would have been dead and the routine restarted on every tick for the
rest of the 60-minute catch-up window. The unit is Type=simple, so a start
while it runs is a no-op, but the moment the workout lock finished the next
tick would relaunch it on top of the user. So _trigger now writes its own
once-per-day latch (save_trigger_fired / was_trigger_fired_today) and gates
on that. Collapsing the catch-up window instead was rejected: that window is
the entire reason a suspended or just-booted machine still fires. It also
replaces the deleted sleep hook, via Persistent=true.

_trigger also imported should_refire from _alarm_session, which was on the
delete list. Since start_session lived only in _alarm.py, no session record
is ever written again and the branch was unreachable, not merely unused.

WAKE_STATE_FILE is now redirected in tests/conftest.py rather than per-file.
The trigger tests never redirected it, because until this commit nothing on
that path wrote anything -- and a per-file redirect is exactly the kind that
gets forgotten when a new writer appears, with a silent failure: a green
suite that quietly stamped the live state file. Verified by mtime that it
did not.

Cross-repo, same change: morning_routine._orchestrator loses its alarm leg
and its --with-alarm flag. Leaving an orchestrator pointing at a deleted
module caused a month-long silent failure once already.

uninstall.sh is new, and matters more than it looks. /usr/local/bin/shutdown
is root-owned and shadows /usr/bin/shutdown; deleting this repo would leave
it in place, and with the alarm cache gone it falls through to rtcwake +
hibernate -- so "shutdown now" would silently stop being a poweroff, every
day, forever. /etc/sudoers.d/wake-alarm is deliberately NOT removed:
screen-locker's own _wake_alarm.py still needs passwordless rtcwake.

159 tests pass at 100% branch coverage; all 26 pre-commit hooks green.
Verified by running it: _sync --show and _trigger --dry-run both resolve.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PcdngP688mW6rJye3LRkSN
2026-08-30 15:24:02 +02:00

179 lines
6.8 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.
The once-per-day latch is stubbed: 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_trigger_fired_today", 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_a_successful_start_latches_the_day(self) -> None:
"""Firing records the latch, so later ticks in the window skip.
Without this the routine is restarted on every tick for the rest of
``TRIGGER_CATCHUP_MINUTES``: the unit is Type=simple, so a start while
it runs is a no-op, but the moment the workout lock finishes the next
tick relaunches it on top of the user.
"""
with (
patch("sys.argv", ["_trigger"]),
patch("wake_alarm._trigger._now", return_value=_at(6, 45)),
patch(
"wake_alarm._trigger.get_alarm",
return_value=Alarm(6, 45, "remote"),
),
patch("wake_alarm._trigger._start_target_unit", return_value=True),
patch("wake_alarm._trigger.save_trigger_fired") as saved,
):
main()
assert saved.call_count == 1
assert saved.call_args.kwargs["fired_at"] == _at(6, 45).isoformat()
def test_a_failed_start_does_not_latch(self) -> None:
"""A start that failed must stay retryable on the next tick."""
with (
patch("sys.argv", ["_trigger"]),
patch("wake_alarm._trigger._now", return_value=_at(6, 45)),
patch(
"wake_alarm._trigger.get_alarm",
return_value=Alarm(6, 45, "remote"),
),
patch("wake_alarm._trigger._start_target_unit", return_value=False),
patch("wake_alarm._trigger.save_trigger_fired") as saved,
):
main()
saved.assert_not_called()
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