mirror of
https://github.com/kuhyx/wake-alarm.git
synced 2026-11-10 19:27:02 +01:00
Do not ring at all on a shared free day
Fourth app onto freedays (~/utils/freedays), the shared pool of pre-declared days when every gate app stands down. _skip_reason is the right seam: it already exists to say why the alarm must not fire, and the free-day check joins the day gate and the once-per-day latch as another clock-independent reason. It goes first because it is the cheapest and the most absolute. "free day" means the alarm does not ring -- not that it rings later. The orchestrator is never started, so neither the alarm nor the workout lock behind it appears. That is the one app in this fleet where standing down is a physical event rather than an absent dialog, so it is worth being explicit: nothing happens that morning. Fail closed, as everywhere else: an unreadable pool reads as "not free" and the alarm rings normally. Silence is never the failure mode -- there is a test that a corrupt pool still wakes you up. The pool is one local file read, so the per-minute tick stays a millisecond-scale process with no network in it. The pylint hook's gatelock pin had drifted all the way back to v0.4.1; it moves to v0.7.3 alongside crdt-sync and freedays. pip refuses two different direct URLs for one distribution, so that stale pin would have made the hook environment unresolvable rather than merely old. requirements.txt also takes pytest-randomly 5.0.0 and ruff 0.16.6, which the freshness gate flagged once the file was touched. 171 tests, 100% coverage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
parent
4e5908baa7
commit
981396442d
@ -95,7 +95,13 @@ repos:
|
||||
- --jobs=4
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.4.1#subdirectory=gatelock
|
||||
# Kept in step with pyproject.toml: pip refuses two different
|
||||
# direct URLs for one distribution, so a stale pin here becomes
|
||||
# an unresolvable hook environment once freedays -- which needs
|
||||
# gatelock v0.7.3 -- joins the list.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays
|
||||
# Test suites intentionally use patterns (protected-access, magic-value
|
||||
# comparisons) that don't belong in the source-code 10/10 gate.
|
||||
exclude: ^(\.venv/)|(^|/)(tests/|conftest\.py)
|
||||
|
||||
@ -13,6 +13,7 @@ dependencies = [
|
||||
# falls back to its stdlib GitHub path if the import fails, so a broken
|
||||
# install degrades the read instead of killing the per-minute trigger.
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays",
|
||||
]
|
||||
|
||||
# Package discovery must be explicit: phone_app/ is a Flutter app, not a Python
|
||||
|
||||
@ -6,13 +6,14 @@ bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.0
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.1.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.7.3#subdirectory=gatelock
|
||||
mypy==2.3.1
|
||||
pre-commit==4.6.2
|
||||
pylint==4.0.8
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
pytest-randomly==4.1.0
|
||||
pytest-randomly==5.0.0
|
||||
pytest-sugar==1.1.1
|
||||
pytest-xdist==3.8.0
|
||||
ruff==0.16.5
|
||||
ruff==0.16.6
|
||||
|
||||
@ -26,6 +26,8 @@ import logging
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import freedays
|
||||
|
||||
from wake_alarm._constants import (
|
||||
ALARM_DAYS,
|
||||
FALLBACK_ALARM_HOUR,
|
||||
@ -81,8 +83,13 @@ def _fire_window(alarm: Alarm, now: datetime) -> tuple[datetime, datetime]:
|
||||
def _skip_reason(now: datetime) -> str | None:
|
||||
"""Return why the alarm must not fire right now, or None if it may.
|
||||
|
||||
Checks only the gates that are independent of the clock: the day gate and
|
||||
the once-per-day latch.
|
||||
Checks only the gates that are independent of the clock: the shared
|
||||
free-day pool, the day gate and the once-per-day latch.
|
||||
|
||||
A free day means the alarm does not ring at all -- the orchestrator is
|
||||
never started, so neither the alarm nor the workout lock behind it
|
||||
appears. The pool is one local file read, never the network, so this
|
||||
stays a millisecond-scale tick.
|
||||
|
||||
Args:
|
||||
now: Current local time.
|
||||
@ -90,6 +97,8 @@ def _skip_reason(now: datetime) -> str | None:
|
||||
Returns:
|
||||
A human-readable reason, or None when nothing blocks firing.
|
||||
"""
|
||||
if freedays.is_free_day(now.date()):
|
||||
return "free day"
|
||||
if now.weekday() not in ALARM_DAYS:
|
||||
return "not an alarm day"
|
||||
if was_trigger_fired_today():
|
||||
|
||||
@ -21,6 +21,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -36,5 +37,12 @@ def _hermetic(tmp_path: Path) -> Generator[None]:
|
||||
with (
|
||||
patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key_file),
|
||||
patch("wake_alarm._state.WAKE_STATE_FILE", tmp_path / "wake_state.json"),
|
||||
# The shared free-day pool lives under ~/.local/share, outside every
|
||||
# path above. Without this, a real free day on the developer's
|
||||
# machine would silence the alarm in every trigger test here.
|
||||
patch(
|
||||
"freedays._api.resolve_paths",
|
||||
lambda paths: paths or freedays.Paths.under(tmp_path / "freedays"),
|
||||
),
|
||||
):
|
||||
yield
|
||||
|
||||
@ -2,9 +2,11 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import freedays
|
||||
import pytest
|
||||
|
||||
from wake_alarm._sync import Alarm
|
||||
@ -15,6 +17,9 @@ from wake_alarm._trigger import (
|
||||
_skip_reason,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _at(hour: int, minute: int) -> datetime:
|
||||
"""Return a Sunday at the given local wall-clock time, timezone-aware.
|
||||
@ -103,6 +108,36 @@ class TestSkipReason:
|
||||
):
|
||||
assert _skip_reason(_at(7, 0)) == "already fired today"
|
||||
|
||||
def test_a_free_day_silences_the_alarm(self, tmp_path: Path) -> None:
|
||||
"""A free day means it does not ring at all, not that it rings later."""
|
||||
moment = _at(7, 0)
|
||||
assert _skip_reason(moment) is None, "precondition: nothing else blocks"
|
||||
|
||||
freedays.mark(
|
||||
moment.date(),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
now=moment.date(),
|
||||
)
|
||||
assert _skip_reason(moment) == "free day"
|
||||
|
||||
def test_a_free_day_on_another_date_does_not_silence_it(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
moment = _at(7, 0)
|
||||
freedays.mark(
|
||||
date(2026, 12, 24),
|
||||
paths=freedays.Paths.under(tmp_path / "freedays"),
|
||||
now=moment.date(),
|
||||
)
|
||||
assert _skip_reason(moment) is None
|
||||
|
||||
def test_an_unreadable_pool_still_lets_the_alarm_ring(self, tmp_path: Path) -> None:
|
||||
"""Fail closed: a corrupt pool must not silence the morning routine."""
|
||||
pool_dir = tmp_path / "freedays"
|
||||
pool_dir.mkdir(exist_ok=True)
|
||||
(pool_dir / "free_days.json").write_text("{ not json", encoding="utf-8")
|
||||
assert _skip_reason(_at(7, 0)) is None
|
||||
|
||||
|
||||
class TestDecide:
|
||||
"""Tests for the fire/don't-fire decision."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user