mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 13:27:41 +01:00
Lose Tue/Wed/Thu leniency when wake-alarm signals a missed prior-day ring
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
e86dd57817
commit
584177ec2e
@ -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, ...] = (
|
||||
|
||||
@ -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(
|
||||
|
||||
49
screen_locker/_workday_penalty.py
Normal file
49
screen_locker/_workday_penalty.py
Normal file
@ -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)
|
||||
@ -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"),
|
||||
),
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
62
screen_locker/tests/test_workday_penalty.py
Normal file
62
screen_locker/tests/test_workday_penalty.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user