mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Close open workout window when a hold starts
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm
This commit is contained in:
parent
fdf6251847
commit
6919ddea28
@ -24,6 +24,7 @@ import sys
|
||||
|
||||
from screen_locker._decision_log import LockDecision, record_decision
|
||||
from screen_locker._decision_reasons import reasons_extra
|
||||
from screen_locker._hold import workout_hold_active
|
||||
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
|
||||
@ -73,6 +74,7 @@ class _ReasonsMixin:
|
||||
"relaxed_day": is_relaxed_day,
|
||||
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
||||
"evening_lockdown_today": has_evening_lockdown_today,
|
||||
"test_hold_active": workout_hold_active,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@ -6,7 +6,6 @@ Single source of truth for the leaf checks walked by
|
||||
mixins delegate their read-only checks to the functions here; this module
|
||||
never touches ADB, sudo, subprocess, or the network, and never writes to
|
||||
disk.
|
||||
|
||||
``explain_lock_decision`` re-derives the same ordered chain for the status
|
||||
view. One branch is a deliberate simplification: when an early-bird pending
|
||||
marker has expired, the real code attempts a live phone/RunnerUp
|
||||
@ -114,6 +113,12 @@ _CHAIN: tuple[_Step, ...] = (
|
||||
"No morning-session skip in force.",
|
||||
"Morning session earned a skip — lock skipped.",
|
||||
),
|
||||
_Step(
|
||||
"test_hold_active",
|
||||
"A declared test hold is active.",
|
||||
"No test hold active.",
|
||||
"Test hold active — no workout window until it ends.",
|
||||
),
|
||||
_Step(
|
||||
"relaxed_day_already_skipped",
|
||||
"Relaxed day already dismissed via Skip — No Penalty today.",
|
||||
@ -148,6 +153,7 @@ def _chain_flags(
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool,
|
||||
evening_lockdown: bool,
|
||||
hold: bool,
|
||||
local_dt: datetime,
|
||||
pending: bool,
|
||||
) -> dict[str, bool]:
|
||||
@ -161,6 +167,7 @@ def _chain_flags(
|
||||
"sick_day": is_sick_day_today(local_dt.date()),
|
||||
"already_logged": has_logged_today(log_file, today=today_str),
|
||||
"wake_alarm_skip": wake_skip,
|
||||
"test_hold_active": hold,
|
||||
"relaxed_day_already_skipped": relaxed_day
|
||||
and is_relaxed_day_skipped_today(log_file, today=today_str),
|
||||
"relaxed_day": relaxed_day,
|
||||
@ -197,6 +204,7 @@ def explain_lock_decision(
|
||||
relaxed_day: bool,
|
||||
wake_skip: bool = False,
|
||||
evening_lockdown: bool = False,
|
||||
hold: bool = False,
|
||||
now: datetime | None = None,
|
||||
degraded_sources: Sequence[DegradedSource] = (),
|
||||
) -> LockExplanation:
|
||||
@ -214,6 +222,7 @@ def explain_lock_decision(
|
||||
relaxed_day=relaxed_day,
|
||||
wake_skip=wake_skip,
|
||||
evening_lockdown=evening_lockdown,
|
||||
hold=hold,
|
||||
local_dt=instant.astimezone(),
|
||||
pending=pending,
|
||||
)
|
||||
|
||||
@ -21,13 +21,16 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
from datetime import datetime
|
||||
from tkinter import Misc
|
||||
|
||||
__all__ = ["HOLD_REASON", "skip_during_hold", "workout_hold_active"]
|
||||
__all__ = ["HOLD_REASON", "skip_during_hold", "watch_for_hold", "workout_hold_active"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
HOLD_REASON = "test_hold_active"
|
||||
_GATE = "workout"
|
||||
# Well inside the 60 s a hold may take to lift an open window.
|
||||
POLL_MS = 30_000
|
||||
|
||||
|
||||
def workout_hold_active(now: datetime | None = None) -> bool:
|
||||
@ -59,3 +62,25 @@ def skip_during_hold(record_skip: Callable[..., None]) -> bool:
|
||||
"until it ends (credit and penalty bookkeeping already ran).",
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def watch_for_hold(root: Misc, close: Callable[[], None]) -> None:
|
||||
"""Close an already-open window within one poll of a hold starting.
|
||||
|
||||
``close`` is the locker's own ``close`` -- it tears the window down and
|
||||
exits 0 without writing a credit or a penalty, so a hold that interrupts
|
||||
an open prompt/lock changes nothing but the screen. Polls on the Tk loop
|
||||
(no thread) and stops re-arming once it fires.
|
||||
"""
|
||||
|
||||
def _tick() -> None:
|
||||
if workout_hold_active():
|
||||
_logger.warning(
|
||||
"A test hold started while the workout window was open — "
|
||||
"closing it without credit or penalty."
|
||||
)
|
||||
close()
|
||||
return
|
||||
root.after(POLL_MS, _tick)
|
||||
|
||||
root.after(POLL_MS, _tick)
|
||||
|
||||
@ -50,6 +50,7 @@ from screen_locker._extra_benefits import (
|
||||
has_extended_early_bird,
|
||||
week_rewards,
|
||||
)
|
||||
from screen_locker._hold import workout_hold_active
|
||||
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
|
||||
@ -170,6 +171,7 @@ def gather_status(
|
||||
relaxed_day=is_relaxed_day(today=today_local),
|
||||
wake_skip=has_workout_skip_today(),
|
||||
evening_lockdown=tonights_lockdown(instant) is not None,
|
||||
hold=workout_hold_active(instant),
|
||||
now=instant,
|
||||
)
|
||||
|
||||
|
||||
@ -37,6 +37,7 @@ from screen_locker._constants import (
|
||||
from screen_locker._decision_log import record_run_aborted
|
||||
from screen_locker._early_bird import EarlyBirdMixin
|
||||
from screen_locker._heat_skip import HeatSkipMixin
|
||||
from screen_locker._hold import watch_for_hold
|
||||
from screen_locker._log_mixin import LogMixin
|
||||
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
||||
from screen_locker._phone_verification import PhoneVerificationMixin
|
||||
@ -208,6 +209,7 @@ class ScreenLocker(
|
||||
# across two separate if/elif/else statements).
|
||||
if self._lock is not None: # pragma: no branch
|
||||
self._lock.grab_input()
|
||||
watch_for_hold(self.root, self.close)
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the application and exit."""
|
||||
|
||||
@ -18,7 +18,14 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from screen_locker import _hold
|
||||
from screen_locker._hold import HOLD_REASON, skip_during_hold, workout_hold_active
|
||||
from screen_locker._compliance_state import explain_lock_decision
|
||||
from screen_locker._hold import (
|
||||
HOLD_REASON,
|
||||
skip_during_hold,
|
||||
watch_for_hold,
|
||||
workout_hold_active,
|
||||
)
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -200,3 +207,91 @@ class TestLadder:
|
||||
expected = "relaxed_day" if relaxed else "enforced"
|
||||
assert [c.args[0].reason for c in record.call_args_list] == [expected]
|
||||
mock_sys_exit.assert_not_called()
|
||||
|
||||
|
||||
class TestWatchForHold:
|
||||
"""An already-open window closes within one poll of a hold starting."""
|
||||
|
||||
def test_arms_a_poll_no_longer_than_thirty_seconds(self) -> None:
|
||||
"""The first check is scheduled on the Tk loop, <= 30 s out."""
|
||||
root, close = MagicMock(), MagicMock()
|
||||
watch_for_hold(root, close)
|
||||
delay, _tick = root.after.call_args.args
|
||||
assert 0 < delay <= 30_000
|
||||
close.assert_not_called()
|
||||
|
||||
def test_no_hold_rearms_and_does_not_close(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Without a hold the tick only schedules the next one."""
|
||||
monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=False))
|
||||
root, close = MagicMock(), MagicMock()
|
||||
watch_for_hold(root, close)
|
||||
root.after.call_args.args[1]()
|
||||
assert root.after.call_count == 2
|
||||
close.assert_not_called()
|
||||
|
||||
def test_hold_closes_the_window_and_stops_polling(
|
||||
self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A hold that starts mid-window closes it, once, and says so."""
|
||||
monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=True))
|
||||
root, close = MagicMock(), MagicMock()
|
||||
watch_for_hold(root, close)
|
||||
with caplog.at_level(logging.WARNING, logger="screen_locker._hold"):
|
||||
root.after.call_args.args[1]()
|
||||
close.assert_called_once_with()
|
||||
assert root.after.call_count == 1
|
||||
assert "hold" in caplog.text
|
||||
|
||||
def test_construction_arms_the_watch(
|
||||
self, mock_tk: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A built locker has the watch armed on its own root and ``close``."""
|
||||
with patch("screen_locker.screen_lock.watch_for_hold") as watch:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
watch.assert_called_once_with(locker.root, locker.close)
|
||||
|
||||
|
||||
class TestStatusMirror:
|
||||
"""The status explainer names the hold instead of ``would_lock``."""
|
||||
|
||||
def _explain(self, tmp_path: Path, *, hold: 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,
|
||||
hold=hold,
|
||||
)
|
||||
|
||||
def test_an_active_hold_is_the_terminal_stage(self, tmp_path: Path) -> None:
|
||||
"""The explanation stops at test_hold_active without firing."""
|
||||
result = self._explain(tmp_path, hold=True)
|
||||
assert result.stage == "test_hold_active"
|
||||
assert result.fired is False
|
||||
|
||||
def test_without_it_the_chain_still_would_lock(self, tmp_path: Path) -> None:
|
||||
"""No hold: the explanation still reaches would_lock."""
|
||||
assert self._explain(tmp_path, hold=False).stage == "would_lock"
|
||||
|
||||
def test_snapshot_reads_the_hold(
|
||||
self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""``gather_status`` asks ``holds`` and the explanation follows it."""
|
||||
monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=True))
|
||||
snap = gather_status(
|
||||
log_file=tmp_path / "log.json",
|
||||
early_bird_pending_file=tmp_path / "early_bird_pending.json",
|
||||
extra_benefits_file=tmp_path / "extra.json",
|
||||
shutdown_config_file=tmp_path / "shutdown.conf",
|
||||
now=NOW,
|
||||
)
|
||||
assert snap.lock_explanation.stage == "test_hold_active"
|
||||
|
||||
def test_other_conditions_name_the_hold(
|
||||
self, mock_tk: MagicMock, tmp_path: Path, fake_holds: _FakeHolds
|
||||
) -> None:
|
||||
"""A decision made for another reason still reports the hold as also=."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
assert "test_hold_active" in str(locker._other_conditions("enforced"))
|
||||
|
||||
Loading…
Reference in New Issue
Block a user