mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Refuse to build a lock screen over a workout already logged today
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
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
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
bdc6cc4df1
commit
c0099dff94
59
screen_locker/_lock_invariant.py
Normal file
59
screen_locker/_lock_invariant.py
Normal file
@ -0,0 +1,59 @@
|
||||
"""The one thing the enforce path must never do: lock over a logged workout.
|
||||
|
||||
Every skip in ``_startup_checks._check_non_verify_exits`` is a rung of a
|
||||
first-match-wins ladder, and the ladder's ORDER is what decides whether a
|
||||
logged workout counts. That order has been wrong before: on 2026-09-18 an
|
||||
expired early-bird marker sat one rung above ``workout_logged_today``, so a
|
||||
walk logged on the lock screen at 09:32 closed the lock and the 09:35 tick
|
||||
locked again -- and would have every five minutes for the rest of the day.
|
||||
The DECISION line even printed ``also=workout_logged_today``; the evidence
|
||||
was there, only the gate was missing.
|
||||
|
||||
This module is that gate. It runs right before the ``enforced`` decision is
|
||||
recorded and refuses to build a lock screen when a counted workout for today
|
||||
is already in the log, whatever the ladder above it concluded. Reaching it
|
||||
with a workout logged is a bug in the ladder, so it is reported at ERROR --
|
||||
the locker must fail safe (no lock) and say so, not fail closed on the user.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
GUARD_REASON = "workout_logged_today_guard"
|
||||
GUARD_DETAIL = (
|
||||
"A counted workout is already logged today but the exemption ladder fell "
|
||||
"through to enforcement — refusing to lock (ladder bug, see journal ERROR)."
|
||||
)
|
||||
|
||||
|
||||
def refuse_to_lock_over_logged_workout(
|
||||
has_logged_today: Callable[[], bool],
|
||||
record_skip: Callable[[str, str], None],
|
||||
) -> bool:
|
||||
"""Skip enforcement if today already holds a counted workout.
|
||||
|
||||
Takes the locker's two relevant methods rather than the locker itself so
|
||||
the guard stays a pure function of "is there a workout?" and "how do I
|
||||
skip?". Returns True when the guard fired (the skip was recorded through
|
||||
*record_skip*, which exits the process), False when the lock may go
|
||||
ahead. The ERROR is deliberate: a fired guard means a rung above it is
|
||||
misordered, and that has to be visible in the journal rather than quietly
|
||||
absorbed as one more skip.
|
||||
"""
|
||||
if not has_logged_today():
|
||||
return False
|
||||
_logger.error(
|
||||
"Enforcement reached with a workout already logged today — the "
|
||||
"early-exit ladder in _startup_checks/_auto_upgrade is misordered. "
|
||||
"Refusing to lock; fix the ladder so workout_logged_today is reached."
|
||||
)
|
||||
record_skip(GUARD_REASON, GUARD_DETAIL)
|
||||
return True
|
||||
@ -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._shutdown_base import (
|
||||
apply_leetcode_bonus_if_new,
|
||||
reset_to_base_if_new_day,
|
||||
@ -161,6 +162,9 @@ class StartupChecksMixin(SyncMixin):
|
||||
# "skip a workout" credit — that mechanic works against the goal of
|
||||
# maximizing weekly workouts, so it was removed in favor of a
|
||||
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
|
||||
# A logged workout outranks every rung above, whatever their order.
|
||||
if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip):
|
||||
return
|
||||
heat = self._check_heat_skip_exit()
|
||||
# Nothing excused today: falling through here means the lock WILL be
|
||||
# built. Recorded explicitly so the trail shows enforcement happening,
|
||||
|
||||
117
screen_locker/tests/test_lock_invariant.py
Normal file
117
screen_locker/tests/test_lock_invariant.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""Tests for the enforce-path guard in ``_lock_invariant``.
|
||||
|
||||
The ladder above the guard is exercised rung-by-rung elsewhere
|
||||
(``test_weekly_logic_part2``). These tests force the whole ladder to fall
|
||||
through -- the shape of every ordering bug it could ever have -- and check
|
||||
that a logged workout still cannot be locked over.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._lock_invariant import (
|
||||
GUARD_DETAIL,
|
||||
GUARD_REASON,
|
||||
refuse_to_lock_over_logged_workout,
|
||||
)
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestRefuseToLockOverLoggedWorkout:
|
||||
"""The guard function on its own."""
|
||||
|
||||
def test_nothing_logged_lets_the_lock_proceed(self) -> None:
|
||||
"""No workout today: the guard is inert and records nothing."""
|
||||
record_skip = MagicMock()
|
||||
assert refuse_to_lock_over_logged_workout(lambda: False, record_skip) is False
|
||||
record_skip.assert_not_called()
|
||||
|
||||
def test_logged_workout_records_the_guard_skip_and_logs_error(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A logged workout fires the guard: skip recorded, ERROR in the journal."""
|
||||
record_skip = MagicMock()
|
||||
with caplog.at_level(logging.ERROR, logger="screen_locker._lock_invariant"):
|
||||
assert refuse_to_lock_over_logged_workout(lambda: True, record_skip) is True
|
||||
record_skip.assert_called_once_with(GUARD_REASON, GUARD_DETAIL)
|
||||
assert any(
|
||||
record.levelno == logging.ERROR and "misordered" in record.getMessage()
|
||||
for record in caplog.records
|
||||
)
|
||||
|
||||
|
||||
class TestEnforcePathChokepoint:
|
||||
"""``_check_non_verify_exits`` with every rung above the guard falling through.
|
||||
|
||||
``_check_today_state_exits`` is stubbed to return False -- that is what an
|
||||
expired early-bird marker did on 2026-09-18, and what any future
|
||||
misordering of that ladder would do again -- so the only thing standing
|
||||
between a logged workout and a lock screen is the guard.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _fall_through_the_ladder(locker: object) -> ExitStack:
|
||||
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, "_check_heat_skip_exit", return_value="skipped"),
|
||||
patch(
|
||||
"screen_locker._startup_checks.process_week_transition", return_value=[]
|
||||
),
|
||||
patch(
|
||||
"screen_locker._startup_checks.reset_to_base_if_new_day",
|
||||
return_value=False,
|
||||
),
|
||||
patch("screen_locker._startup_checks.apply_leetcode_bonus_if_new"),
|
||||
):
|
||||
stack.enter_context(patcher)
|
||||
return stack
|
||||
|
||||
def test_logged_workout_is_never_locked_over(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Ladder fell through with a workout logged: guard skip, no ``enforced``."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
with (
|
||||
patch.object(locker, "has_logged_today", return_value=True),
|
||||
patch("screen_locker._startup_checks.record_decision") as record,
|
||||
self._fall_through_the_ladder(locker),
|
||||
):
|
||||
locker._check_non_verify_exits()
|
||||
reasons = [call.args[0].reason for call in record.call_args_list]
|
||||
assert reasons == [GUARD_REASON]
|
||||
assert record.call_args.args[0].locked is False
|
||||
mock_sys_exit.assert_called_once_with(0)
|
||||
|
||||
def test_no_workout_still_enforces(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Same fall-through with nothing logged: the lock goes ahead as before."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
with (
|
||||
patch.object(locker, "has_logged_today", return_value=False),
|
||||
patch("screen_locker._startup_checks.record_decision") as record,
|
||||
self._fall_through_the_ladder(locker),
|
||||
):
|
||||
locker._check_non_verify_exits()
|
||||
assert [call.args[0].reason for call in record.call_args_list] == ["enforced"]
|
||||
assert record.call_args.args[0].locked is True
|
||||
mock_sys_exit.assert_not_called()
|
||||
Loading…
Reference in New Issue
Block a user