diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index ca2c056..f83ae63 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -149,6 +149,15 @@ MORNING_SESSION_FILE = ( Path.home() / ".local" / "state" / "wake_alarm" / "morning_session.json" ) +# A separate file from MORNING_SESSION_FILE: that one always describes +# *today* and is overwritten at the date rollover, so it cannot carry a +# marker forward to tomorrow. wake-alarm writes this one when a Tue/Wed/Thu +# ring goes unanswered; today == penalty_date means today's relaxed-day +# leniency is off. Keep the path in step with wake_alarm/_constants.py. +WORKDAY_PENALTY_FILE = ( + Path.home() / ".local" / "state" / "wake_alarm" / "workday_penalty.json" +) + # Directories where RunnerUp writes per-activity TCX exports (File Synchronizer). # Listed in preference order; both resolve to the same path on most devices. RUNNERUP_EXPORT_DIRS: tuple[str, ...] = ( diff --git a/screen_locker/_weekly_check.py b/screen_locker/_weekly_check.py index 252c7a4..4614dfb 100644 --- a/screen_locker/_weekly_check.py +++ b/screen_locker/_weekly_check.py @@ -17,6 +17,7 @@ import logging from typing import TYPE_CHECKING, Any from screen_locker._log_io import load_workout_log +from screen_locker._workday_penalty import workday_penalty_today if TYPE_CHECKING: from pathlib import Path @@ -77,14 +78,20 @@ RELAXED_DAY_SKIP_TYPE: str = "relaxed_day_skip" def is_relaxed_day(*, today: datetime | None = None) -> bool: """Return True if today is a relaxed day (Tue, Wed, or Thu). + False when a missed wake-alarm ring on a prior stick-day cost today its + leniency -- see :func:`screen_locker._workday_penalty.workday_penalty_today`. + Args: today: Override for the current local datetime (for testing). Returns: - True when the current weekday is Tuesday, Wednesday, or Thursday. + True when the current weekday is Tuesday, Wednesday, or Thursday and + no penalty is in force. """ dt = today if today is not None else datetime.now(tz=UTC).astimezone() - return dt.weekday() in _RELAXED_WEEKDAYS + if dt.weekday() not in _RELAXED_WEEKDAYS: + return False + return not workday_penalty_today(dt) def credit_key( diff --git a/screen_locker/_workday_penalty.py b/screen_locker/_workday_penalty.py new file mode 100644 index 0000000..efc3fe1 --- /dev/null +++ b/screen_locker/_workday_penalty.py @@ -0,0 +1,49 @@ +"""Read the workday-stick penalty wake-alarm signed for today. + +A missed Tuesday/Wednesday/Thursday wake-alarm ring costs tomorrow's +relaxed-day leniency (:func:`screen_locker._weekly_check.is_relaxed_day`) +instead of just resetting to normal. This module never re-derives that +policy: the file carries a signed ``penalty_date``, and the only decision +here is *HMAC ok, dated today*. Anything else -- missing file, stale date, +bad signature -- is "no penalty", i.e. today's ordinary relaxed-day rule. +""" + +from __future__ import annotations + +import json +import logging +from typing import TYPE_CHECKING + +from gatelock.log_integrity import verify_entry_hmac + +from screen_locker._constants import WORKDAY_PENALTY_FILE +from screen_locker._day import today_str + +if TYPE_CHECKING: + from datetime import datetime + +_logger = logging.getLogger(__name__) + + +def _read_verified() -> dict[str, object] | None: + """The file's entry when it exists, parses and verifies; else None.""" + if not WORKDAY_PENALTY_FILE.exists(): + _logger.debug("No workday penalty file at %s", WORKDAY_PENALTY_FILE) + return None + try: + entry = json.loads(WORKDAY_PENALTY_FILE.read_text()) + except (OSError, json.JSONDecodeError) as exc: + _logger.warning("Cannot read %s: %s", WORKDAY_PENALTY_FILE, exc) + return None + if not isinstance(entry, dict) or not verify_entry_hmac(entry): + _logger.warning("Workday penalty file is not a signed object") + return None + return entry + + +def workday_penalty_today(now: datetime | None = None) -> bool: + """Whether today lost its relaxed-day leniency to a missed prior ring.""" + entry = _read_verified() + if entry is None: + return False + return entry.get("penalty_date") == today_str(now) diff --git a/screen_locker/tests/_isolated_state.py b/screen_locker/tests/_isolated_state.py index ade4eae..727aa1c 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -79,4 +79,10 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( "leetcode_ledger.json", ("_leetcode_bonus.LEETCODE_LEDGER_FILE",), ), + # wake-alarm's workday-stick file. A test that forgot this would read + # the developer's real penalty state. + ( + "workday_penalty.json", + ("_constants.WORKDAY_PENALTY_FILE", "_workday_penalty.WORKDAY_PENALTY_FILE"), + ), ) diff --git a/screen_locker/tests/test_weekly_check.py b/screen_locker/tests/test_weekly_check.py index 275e505..8c02757 100644 --- a/screen_locker/tests/test_weekly_check.py +++ b/screen_locker/tests/test_weekly_check.py @@ -89,6 +89,20 @@ class TestIsRelaxedDay: result = is_relaxed_day() assert isinstance(result, bool) + def test_a_workday_penalty_overrides_a_relaxed_day(self) -> None: + """A missed prior-day ring costs Tue/Wed/Thu their leniency.""" + target = "screen_locker._weekly_check.workday_penalty_today" + with patch(target, return_value=True): + assert is_relaxed_day(today=_dt(1)) is False + + def test_an_enforced_day_is_unaffected_by_the_penalty_check(self) -> None: + """The penalty is never even consulted on a day that was already enforced.""" + with patch( + "screen_locker._weekly_check.workday_penalty_today", + side_effect=AssertionError("must not be called"), + ): + assert is_relaxed_day(today=_dt(0)) is False + # --------------------------------------------------------------------------- # count_weekly_workouts diff --git a/screen_locker/tests/test_workday_penalty.py b/screen_locker/tests/test_workday_penalty.py new file mode 100644 index 0000000..2aed6d3 --- /dev/null +++ b/screen_locker/tests/test_workday_penalty.py @@ -0,0 +1,62 @@ +"""screen_locker._workday_penalty — the stick file, read and never re-derived.""" + +from __future__ import annotations + +from datetime import datetime +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from gatelock.log_integrity import compute_entry_hmac +import pytest + +from screen_locker import _workday_penalty +from screen_locker._workday_penalty import workday_penalty_today + +if TYPE_CHECKING: + from pathlib import Path + +NOW = datetime(2026, 9, 23, 9, 0).astimezone() + + +def _write(entry: dict[str, object], *, sign: bool = True) -> Path: + if sign: + entry["hmac"] = compute_entry_hmac(entry) + path = _workday_penalty.WORKDAY_PENALTY_FILE + path.write_text(json.dumps(entry)) + return path + + +@pytest.fixture(autouse=True) +def _signing_key(tmp_path: Path) -> None: + key = tmp_path / "hmac.key" + key.write_bytes(b"1" * 32) + with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key): + yield + + +class TestVerdict: + def test_todays_penalty_applies(self) -> None: + _write({"penalty_date": "2026-09-23", "issued_date": "2026-09-22"}) + assert workday_penalty_today(NOW) is True + + def test_a_different_dates_penalty_does_not_apply(self) -> None: + _write({"penalty_date": "2026-09-24", "issued_date": "2026-09-23"}) + assert workday_penalty_today(NOW) is False + + def test_missing_unreadable_tampered_are_all_no_penalty( + self, tmp_path: Path + ) -> None: + assert workday_penalty_today(NOW) is False + path = _write({"penalty_date": "2026-09-23"}, sign=False) + assert workday_penalty_today(NOW) is False + path.write_text("not json") + assert workday_penalty_today(NOW) is False + path.write_text("[]") + assert workday_penalty_today(NOW) is False + path.unlink() + assert workday_penalty_today(NOW) is False + + def test_default_now_is_used_when_unset(self) -> None: + # Not asserting a value -- just that omitting `now` does not raise. + assert workday_penalty_today() is False