mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-11 17:13:49 +01:00
The alarm was opt-in: it only armed when the user tapped "Set Alarm & Sync to PC", so it could be defeated by simply not tapping it, or by cancelling it the night before. It is now automatic and there is no lever a half-asleep user can pull that buys them sleep. - AlarmPolicy bakes the schedule in at 08:00 daily. The only edit the UI offers is moving the NEXT alarm earlier; setEarlier() refuses anything at or after the baseline, and refuses to relax an active override. Earlier-only is the whole design: every available change costs the user sleep. - The ratchet resets after each fire, so an early start is one-shot. A one-way ratchet with no reset is a trap, not a defence — the first 05:00 would otherwise be permanent with no way back short of reinstalling. - load() re-validates the stored value and ignores anything at or after the baseline. SharedPreferences is not a trust boundary. - No cancel. The Kotlin handler is deleted, not just the Dart wrapper, so the capability genuinely does not exist rather than being one invokeMethod away. - Arming happens in main()'s init seam and via AlarmWatchdog, a daily self-rescheduling check that re-arms if nothing is scheduled — closing the gap where a force-stop or a failed re-arm goes unnoticed until the morning it does not ring. BootReceiver re-establishes it, since setInexactRepeating does not survive a reboot. - Publishing to the PC is automatic too; it used to need the same tap. PC half, so the two agree with the network down: - FALLBACK_ALARM_HOUR 13 -> 8, matching the phone's baseline. At 13:00 a Firebase outage silently moved the alarm five hours LATER — the one direction the design refuses to allow. - _trigger._clamp_to_baseline() refuses any resolved alarm later than the baseline, whatever its source. Without it the PC would faithfully reproduce a stale or tampered remote value and the forbidden direction stays reachable through sync. Tests: 465 Python at 100% branch coverage, 79 Dart. Both ratchet directions are covered, per the standing rule about monotonic counters. test_sync.py and test_trigger.py were split (payload, token, CLI, clamp) to stay under the 250-line gate; the split lost indirect coverage of extract_hlc/newest_alarm, which now have direct tests instead. `python -m wake_alarm._sync --show` and `_trigger --dry-run` both re-run and still print real output. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01EckWmyfetYq3Mi8MAnUudh
67 lines
2.5 KiB
Python
67 lines
2.5 KiB
Python
"""Tests for the PC-side clamp — a synced alarm may never land later."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from wake_alarm._constants import FALLBACK_ALARM_HOUR
|
|
from wake_alarm._sync import Alarm
|
|
from wake_alarm._trigger import (
|
|
_clamp_to_baseline,
|
|
)
|
|
|
|
|
|
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 TestClampToBaseline:
|
|
"""The PC-side watchdog: a synced alarm may never land later than 08:00."""
|
|
|
|
def test_an_earlier_alarm_passes_through_untouched(self) -> None:
|
|
"""The phone ratchets earlier, so earlier values are legitimate."""
|
|
alarm = Alarm(6, 30, "remote")
|
|
assert _clamp_to_baseline(alarm) == alarm
|
|
|
|
def test_the_baseline_itself_passes_through(self) -> None:
|
|
"""Equal to the baseline is not later than it."""
|
|
alarm = Alarm(FALLBACK_ALARM_HOUR, 0, "remote")
|
|
assert _clamp_to_baseline(alarm) == alarm
|
|
|
|
def test_a_later_alarm_is_clamped_to_the_baseline(self) -> None:
|
|
"""The one direction the design forbids must not be reachable."""
|
|
clamped = _clamp_to_baseline(Alarm(13, 0, "remote"))
|
|
assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
|
|
assert clamped.source == "clamped-remote"
|
|
|
|
def test_a_later_minute_within_the_baseline_hour_is_clamped(self) -> None:
|
|
"""08:30 is later than 08:00, so the minute matters too."""
|
|
clamped = _clamp_to_baseline(Alarm(FALLBACK_ALARM_HOUR, 30, "cache"))
|
|
assert (clamped.hour, clamped.minute) == (FALLBACK_ALARM_HOUR, 0)
|
|
assert clamped.source == "clamped-cache"
|