From 6919ddea2827f2a6f38acfa2495734bcea58da9f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 3 Nov 2026 20:39:47 +0100 Subject: [PATCH] Close open workout window when a hold starts Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01WzxdWVSoczW2NHQd887FUm --- screen_locker/_auto_upgrade.py | 2 + screen_locker/_compliance_state.py | 11 ++- screen_locker/_hold.py | 27 +++++++- screen_locker/_status_data.py | 2 + screen_locker/screen_lock.py | 2 + screen_locker/tests/test_hold_gate.py | 97 ++++++++++++++++++++++++++- 6 files changed, 138 insertions(+), 3 deletions(-) diff --git a/screen_locker/_auto_upgrade.py b/screen_locker/_auto_upgrade.py index 9f9bfde..7b20d8a 100644 --- a/screen_locker/_auto_upgrade.py +++ b/screen_locker/_auto_upgrade.py @@ -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, }, ) diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index ec3be98..769134d 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -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, ) diff --git a/screen_locker/_hold.py b/screen_locker/_hold.py index d69587f..e9f7d27 100644 --- a/screen_locker/_hold.py +++ b/screen_locker/_hold.py @@ -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) diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index 82810aa..2958878 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -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, ) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index d92b687..15ffc4a 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -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.""" diff --git a/screen_locker/tests/test_hold_gate.py b/screen_locker/tests/test_hold_gate.py index d986e51..0b77d68 100644 --- a/screen_locker/tests/test_hold_gate.py +++ b/screen_locker/tests/test_hold_gate.py @@ -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"))