diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index ae45da0..b6fd85e 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -134,7 +134,7 @@ repos: # direct URLs for one distribution, so any pin that drifts from # the manifest becomes an unresolvable hook environment rather # than merely an old one. - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock + - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.2#subdirectory=earned_time - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays @@ -156,7 +156,7 @@ repos: # direct URLs for one distribution, so any pin that drifts from # the manifest becomes an unresolvable hook environment rather # than merely an old one. - - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock + - gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock - crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync - earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.2#subdirectory=earned_time - freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays diff --git a/install_systemd.sh b/install_systemd.sh index 971b6f1..c4ce5df 100755 --- a/install_systemd.sh +++ b/install_systemd.sh @@ -71,7 +71,7 @@ ensure_runtime_deps() { echo "WARNING: could not install $1 automatically" >&2 } _pip_runtime_dep gatelock \ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock" + "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock" _pip_runtime_dep crdt_sync \ "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync" } diff --git a/pyproject.toml b/pyproject.toml index b2fccea..75ee816 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ requires-python = ">=3.14" # crdt-sync: shared CRDT merge scheme + sync transports, also used by # diet_guard; here for phone-workout sync, Firebase RTDB only since 2026-10-09 dependencies = [ - "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock", + "gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock", "crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync", "freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays", "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.2#subdirectory=earned_time", diff --git a/requirements.txt b/requirements.txt index 96fe4a7..097033e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,7 +6,7 @@ coverage==7.16.2 crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.2#subdirectory=earned_time freedays @ git+https://github.com/kuhyx/utils@freedays-v0.4.0#subdirectory=freedays -gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock +gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.10.0#subdirectory=gatelock mcp==2.3.0 mypy==2.4.0 pre-commit==4.7.0 diff --git a/screen_locker/_auto_upgrade.py b/screen_locker/_auto_upgrade.py index a02a5d6..9f9bfde 100644 --- a/screen_locker/_auto_upgrade.py +++ b/screen_locker/_auto_upgrade.py @@ -25,6 +25,7 @@ import sys from screen_locker._decision_log import LockDecision, record_decision from screen_locker._decision_reasons import reasons_extra from screen_locker._morning_session import has_workout_skip_today +from screen_locker._night_lockdown import has_evening_lockdown_today from screen_locker._sick import stands_down_workout from screen_locker._weekly_check import has_weekly_minimum, is_relaxed_day @@ -71,6 +72,7 @@ class _ReasonsMixin: "wake_alarm_skip": has_workout_skip_today, "relaxed_day": is_relaxed_day, "weekly_minimum_met": lambda: has_weekly_minimum(self.log_file), + "evening_lockdown_today": has_evening_lockdown_today, }, ) diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index 3e0ba56..3a4e634 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -66,6 +66,7 @@ def explain_lock_decision( weekly_minimum_met: bool, relaxed_day: bool, wake_skip: bool = False, + evening_lockdown: bool = False, now: datetime | None = None, degraded_sources: Sequence[DegradedSource] = (), ) -> LockExplanation: @@ -215,6 +216,17 @@ def explain_lock_decision( if result is not None: return result + result = _check( + "evening_lockdown_today", + fired=evening_lockdown, + reason_true="Tonight's night lockdown already fired (>= 12:00 today).", + reason_false="No evening night lockdown yet today.", + terminal_reason="Night lockdown already happened tonight — the workout " + "lock does not re-arm.", + ) + if result is not None: + return result + # Named for the conclusion; "..._pending_heat_check" read as "stuck". return _stage( fired=True, diff --git a/screen_locker/_night_lockdown.py b/screen_locker/_night_lockdown.py new file mode 100644 index 0000000..70e0539 --- /dev/null +++ b/screen_locker/_night_lockdown.py @@ -0,0 +1,70 @@ +"""The workout lock does not re-arm after tonight's evening night-lockdown. + +The predicate itself is ``gatelock.night_lockdown``, shared with +leetcode-guard and book-guard (diet-guard deliberately does not opt in); this +module only binds screen-locker's path, which the test suite redirects, and +the ladder rung that honours it. + +Why: once tonight's lockdown has fired, a late credit (``_late_credit``) can +lift it -- and re-locking behind the workout gate the same evening would make +a finished workout impossible to submit. Only an EVENING lock counts (local +date today, >= 12:00); the 04:01 morning re-lock does not. Missing or garbage +stamp means "no lockdown": the workout lock behaves exactly as before. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from gatelock.night_lockdown import ( + NIGHT_LOCKDOWN_STAMP as _SHARED_STAMP, +) +from gatelock.night_lockdown import ( + evening_lockdown_today, +) + +if TYPE_CHECKING: + from collections.abc import Callable + from datetime import datetime + from pathlib import Path + +__all__ = [ + "EVENING_LOCKDOWN_REASON", + "NIGHT_LOCKDOWN_STAMP", + "has_evening_lockdown_today", + "skip_after_evening_lockdown", + "tonights_lockdown", +] + +NIGHT_LOCKDOWN_STAMP: Path = _SHARED_STAMP + +EVENING_LOCKDOWN_REASON = "evening_lockdown_today" + + +def tonights_lockdown(now: datetime | None = None) -> datetime | None: + """When tonight's evening lockdown locked, or None if it has not yet.""" + return evening_lockdown_today(NIGHT_LOCKDOWN_STAMP, now) + + +def has_evening_lockdown_today() -> bool: + """Whether tonight's evening lockdown already happened. Read-only.""" + return tonights_lockdown() is not None + + +def skip_after_evening_lockdown(record_skip: Callable[..., None]) -> bool: + """Skip enforcement once tonight's evening lockdown has fired. + + Takes the locker's ``_record_skip`` (which exits the process) rather than + the locker, like ``_lock_invariant``. Returns True when the skip was + recorded, False when the lock may go ahead. + """ + locked_at = tonights_lockdown() + if locked_at is None: + return False + record_skip( + EVENING_LOCKDOWN_REASON, + f"Tonight's night lockdown already locked at {locked_at:%H:%M}; the " + "workout lock does not re-arm after it.", + locked_at=locked_at.isoformat(), + ) + return True diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index 0d0f759..9a2637e 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -29,6 +29,7 @@ from screen_locker._decision_log import ( from screen_locker._degraded_sources import degraded_sources from screen_locker._extra_benefits import process_week_transition from screen_locker._lock_invariant import refuse_to_lock_over_logged_workout +from screen_locker._night_lockdown import skip_after_evening_lockdown from screen_locker._shutdown_base import ( apply_flat_bonuses_if_new, reset_to_base_if_new_day, @@ -157,7 +158,11 @@ class StartupChecksMixin(SyncMixin): # there is no banked "skip a workout" credit — that mechanic works # against the goal of maximizing weekly workouts, so it was removed. # A logged workout outranks every rung above, whatever their order. - if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip): + # Then: once tonight's lockdown fired (a late credit may have lifted + # it), never re-arm behind the workout gate the same evening. + if refuse_to_lock_over_logged_workout( + self.has_logged_today, self._record_skip + ) or skip_after_evening_lockdown(self._record_skip): return heat = self._check_heat_skip_exit() # Nothing excused today: falling through here means the lock WILL be diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index 537f935..d610534 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -51,6 +51,7 @@ from screen_locker._extra_benefits import ( ) from screen_locker._log_io import load_workout_log from screen_locker._morning_session import has_workout_skip_today +from screen_locker._night_lockdown import tonights_lockdown from screen_locker._sick import workout_standdown_dates from screen_locker._weekly_check import ( COUNTED_WORKOUT_TYPES, @@ -167,6 +168,7 @@ def gather_status( weekly_minimum_met=week.counted_count >= WEEKLY_WORKOUT_MINIMUM, relaxed_day=is_relaxed_day(today=today_local), wake_skip=has_workout_skip_today(), + evening_lockdown=tonights_lockdown(instant) is not None, now=instant, ) diff --git a/screen_locker/tests/_real_state_fixtures.py b/screen_locker/tests/_real_state_fixtures.py index c960f26..e7d7546 100644 --- a/screen_locker/tests/_real_state_fixtures.py +++ b/screen_locker/tests/_real_state_fixtures.py @@ -170,3 +170,20 @@ def pre_ladder(monkeypatch: pytest.MonkeyPatch) -> None: Opt-in: tests that assert the ladder itself must keep the real cutover. """ monkeypatch.setattr("earned_time._ladder.LADDER_FROM", date.max) + + +@pytest.fixture(autouse=True) +def _no_real_night_lockdown(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + """Never let the lock chain read the machine's real night-lockdown stamp. + + ``/var/lib/night-lockdown/locked_at_epoch`` is world-readable, so without + this every enforce-path test would skip the lock on any evening after the + real lockdown fired -- and pass in the morning. + """ + import screen_locker._night_lockdown + + monkeypatch.setattr( + screen_locker._night_lockdown, + "NIGHT_LOCKDOWN_STAMP", + tmp_path / "locked_at_epoch", + ) diff --git a/screen_locker/tests/test_night_lockdown_rung.py b/screen_locker/tests/test_night_lockdown_rung.py new file mode 100644 index 0000000..6557c8c --- /dev/null +++ b/screen_locker/tests/test_night_lockdown_rung.py @@ -0,0 +1,167 @@ +"""The workout lock does not re-arm after tonight's evening night-lockdown. + +``gatelock.night_lockdown`` decides "has the evening lockdown happened?"; +these tests pin screen-locker's binding of its path, the ladder rung that +honours it, and the status view's mirror of that rung. +""" + +from __future__ import annotations + +from contextlib import ExitStack +from datetime import UTC, datetime +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker import _night_lockdown +from screen_locker._compliance_state import explain_lock_decision +from screen_locker._night_lockdown import ( + EVENING_LOCKDOWN_REASON, + skip_after_evening_lockdown, + tonights_lockdown, +) +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + +NOW = datetime(2026, 10, 17, 22, 30).astimezone() +EVENING = datetime(2026, 10, 17, 21, 5).astimezone() + + +def _stamp(when: str) -> None: + """Write the redirected stamp for a LOCAL wall-clock time.""" + epoch = int(datetime.fromisoformat(when).astimezone().timestamp()) + _night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text(f"{epoch}\n") + + +class TestTonightsLockdown: + """The binding reads the (redirected) stamp through gatelock.""" + + def test_missing_stamp_is_no_lockdown(self) -> None: + assert tonights_lockdown(NOW) is None + + def test_evening_stamp_today_is_tonights_lockdown(self) -> None: + _stamp("2026-10-17T21:05") + assert tonights_lockdown(NOW) == EVENING + + def test_morning_relock_today_is_not(self) -> None: + _stamp("2026-10-17T04:01") + assert tonights_lockdown(NOW) is None + + def test_yesterdays_evening_is_not(self) -> None: + _stamp("2026-10-16T21:05") + assert tonights_lockdown(NOW) is None + + def test_garbage_is_not(self) -> None: + _night_lockdown.NIGHT_LOCKDOWN_STAMP.write_text("not an epoch") + assert tonights_lockdown(NOW) is None + + +class TestSkipAfterEveningLockdown: + """The rung on its own.""" + + def test_no_lockdown_lets_the_lock_proceed(self) -> None: + record_skip = MagicMock() + with patch.object(_night_lockdown, "tonights_lockdown", return_value=None): + assert skip_after_evening_lockdown(record_skip) is False + record_skip.assert_not_called() + + def test_tonights_lockdown_records_the_skip(self) -> None: + record_skip = MagicMock() + with patch.object(_night_lockdown, "tonights_lockdown", return_value=EVENING): + assert skip_after_evening_lockdown(record_skip) is True + reason, detail = record_skip.call_args.args + assert reason == EVENING_LOCKDOWN_REASON == "evening_lockdown_today" + assert "21:05" in detail + assert record_skip.call_args.kwargs == {"locked_at": EVENING.isoformat()} + + +def _fall_through_the_ladder(locker: object) -> ExitStack: + """Every rung above the new one falls through (see test_lock_invariant).""" + stack = ExitStack() + for patcher in ( + patch.object(locker, "_is_scheduled_skip_today", return_value=False), + patch.object(locker, "_ingest_synced_manual_workouts"), + patch.object(locker, "_auto_fill_week_runnerup_bonus"), + patch.object(locker, "_check_today_state_exits", return_value=False), + patch.object(locker, "has_logged_today", return_value=False), + patch("screen_locker._startup_checks.is_relaxed_day", return_value=False), + patch("screen_locker._startup_checks.has_weekly_minimum", return_value=False), + patch("screen_locker._startup_checks.process_week_transition", return_value=[]), + patch( + "screen_locker._startup_checks.reset_to_base_if_new_day", + return_value=False, + ), + ): + stack.enter_context(patcher) + return stack + + +class TestLadder: + """``_check_non_verify_exits`` reaching the enforce point.""" + + def test_after_tonights_lockdown_the_lock_does_not_rearm( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with ( + patch.object(_night_lockdown, "tonights_lockdown", return_value=EVENING), + patch("screen_locker._startup_checks.record_decision") as record, + patch.object(locker, "_check_heat_skip_exit") as heat, + patch("screen_locker._startup_checks.apply_flat_bonuses_if_new") as bonus, + _fall_through_the_ladder(locker), + ): + locker._check_non_verify_exits() + assert [c.args[0].reason for c in record.call_args_list] == [ + "evening_lockdown_today" + ] + assert record.call_args.args[0].locked is False + heat.assert_not_called() + # Credits still land: the rung sits below the reset/bonus/ingest block. + bonus.assert_called_once() + mock_sys_exit.assert_called_once_with(0) + + def test_morning_relock_still_enforces( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + _stamp(f"{datetime.now(tz=UTC).astimezone():%Y-%m-%d}T04:01") + locker = create_locker(mock_tk, tmp_path) + with ( + patch("screen_locker._startup_checks.record_decision") as record, + patch.object(locker, "_check_heat_skip_exit", return_value="skipped"), + patch("screen_locker._startup_checks.apply_flat_bonuses_if_new"), + _fall_through_the_ladder(locker), + ): + locker._check_non_verify_exits() + assert [c.args[0].reason for c in record.call_args_list] == ["enforced"] + mock_sys_exit.assert_not_called() + + def test_other_conditions_name_tonights_lockdown( + self, mock_tk: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch.object(_night_lockdown, "tonights_lockdown", return_value=EVENING): + also = locker._other_conditions("enforced") + assert "evening_lockdown_today" in str(also) + + +class TestStatusMirror: + """``explain_lock_decision`` names the rung instead of ``would_lock``.""" + + def _explain(self, tmp_path: Path, *, evening_lockdown: bool) -> object: + return explain_lock_decision( + log_file=tmp_path / "log.json", + early_bird_pending_file=tmp_path / "early_bird_pending.json", + weekly_minimum_met=False, + relaxed_day=False, + evening_lockdown=evening_lockdown, + ) + + def test_tonights_lockdown_is_the_terminal_stage(self, tmp_path: Path) -> None: + result = self._explain(tmp_path, evening_lockdown=True) + assert result.stage == "evening_lockdown_today" + assert result.fired is False + + def test_without_it_the_chain_still_would_lock(self, tmp_path: Path) -> None: + result = self._explain(tmp_path, evening_lockdown=False) + assert result.stage == "would_lock"