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:
Krzysztof Rudnicki 2026-11-03 20:39:47 +01:00
parent fdf6251847
commit 6919ddea28
6 changed files with 138 additions and 3 deletions

View File

@ -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,
},
)

View File

@ -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,
)

View File

@ -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)

View File

@ -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,
)

View File

@ -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."""

View File

@ -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"))