mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:27:15 +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_log import LockDecision, record_decision
|
||||||
from screen_locker._decision_reasons import reasons_extra
|
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._morning_session import has_workout_skip_today
|
||||||
from screen_locker._night_lockdown import has_evening_lockdown_today
|
from screen_locker._night_lockdown import has_evening_lockdown_today
|
||||||
from screen_locker._sick import stands_down_workout
|
from screen_locker._sick import stands_down_workout
|
||||||
@ -73,6 +74,7 @@ class _ReasonsMixin:
|
|||||||
"relaxed_day": is_relaxed_day,
|
"relaxed_day": is_relaxed_day,
|
||||||
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
"weekly_minimum_met": lambda: has_weekly_minimum(self.log_file),
|
||||||
"evening_lockdown_today": has_evening_lockdown_today,
|
"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
|
mixins delegate their read-only checks to the functions here; this module
|
||||||
never touches ADB, sudo, subprocess, or the network, and never writes to
|
never touches ADB, sudo, subprocess, or the network, and never writes to
|
||||||
disk.
|
disk.
|
||||||
|
|
||||||
``explain_lock_decision`` re-derives the same ordered chain for the status
|
``explain_lock_decision`` re-derives the same ordered chain for the status
|
||||||
view. One branch is a deliberate simplification: when an early-bird pending
|
view. One branch is a deliberate simplification: when an early-bird pending
|
||||||
marker has expired, the real code attempts a live phone/RunnerUp
|
marker has expired, the real code attempts a live phone/RunnerUp
|
||||||
@ -114,6 +113,12 @@ _CHAIN: tuple[_Step, ...] = (
|
|||||||
"No morning-session skip in force.",
|
"No morning-session skip in force.",
|
||||||
"Morning session earned a skip — lock skipped.",
|
"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(
|
_Step(
|
||||||
"relaxed_day_already_skipped",
|
"relaxed_day_already_skipped",
|
||||||
"Relaxed day already dismissed via Skip — No Penalty today.",
|
"Relaxed day already dismissed via Skip — No Penalty today.",
|
||||||
@ -148,6 +153,7 @@ def _chain_flags(
|
|||||||
relaxed_day: bool,
|
relaxed_day: bool,
|
||||||
wake_skip: bool,
|
wake_skip: bool,
|
||||||
evening_lockdown: bool,
|
evening_lockdown: bool,
|
||||||
|
hold: bool,
|
||||||
local_dt: datetime,
|
local_dt: datetime,
|
||||||
pending: bool,
|
pending: bool,
|
||||||
) -> dict[str, bool]:
|
) -> dict[str, bool]:
|
||||||
@ -161,6 +167,7 @@ def _chain_flags(
|
|||||||
"sick_day": is_sick_day_today(local_dt.date()),
|
"sick_day": is_sick_day_today(local_dt.date()),
|
||||||
"already_logged": has_logged_today(log_file, today=today_str),
|
"already_logged": has_logged_today(log_file, today=today_str),
|
||||||
"wake_alarm_skip": wake_skip,
|
"wake_alarm_skip": wake_skip,
|
||||||
|
"test_hold_active": hold,
|
||||||
"relaxed_day_already_skipped": relaxed_day
|
"relaxed_day_already_skipped": relaxed_day
|
||||||
and is_relaxed_day_skipped_today(log_file, today=today_str),
|
and is_relaxed_day_skipped_today(log_file, today=today_str),
|
||||||
"relaxed_day": relaxed_day,
|
"relaxed_day": relaxed_day,
|
||||||
@ -197,6 +204,7 @@ def explain_lock_decision(
|
|||||||
relaxed_day: bool,
|
relaxed_day: bool,
|
||||||
wake_skip: bool = False,
|
wake_skip: bool = False,
|
||||||
evening_lockdown: bool = False,
|
evening_lockdown: bool = False,
|
||||||
|
hold: bool = False,
|
||||||
now: datetime | None = None,
|
now: datetime | None = None,
|
||||||
degraded_sources: Sequence[DegradedSource] = (),
|
degraded_sources: Sequence[DegradedSource] = (),
|
||||||
) -> LockExplanation:
|
) -> LockExplanation:
|
||||||
@ -214,6 +222,7 @@ def explain_lock_decision(
|
|||||||
relaxed_day=relaxed_day,
|
relaxed_day=relaxed_day,
|
||||||
wake_skip=wake_skip,
|
wake_skip=wake_skip,
|
||||||
evening_lockdown=evening_lockdown,
|
evening_lockdown=evening_lockdown,
|
||||||
|
hold=hold,
|
||||||
local_dt=instant.astimezone(),
|
local_dt=instant.astimezone(),
|
||||||
pending=pending,
|
pending=pending,
|
||||||
)
|
)
|
||||||
|
|||||||
@ -21,13 +21,16 @@ from typing import TYPE_CHECKING
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
from datetime import datetime
|
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__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
HOLD_REASON = "test_hold_active"
|
HOLD_REASON = "test_hold_active"
|
||||||
_GATE = "workout"
|
_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:
|
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).",
|
"until it ends (credit and penalty bookkeeping already ran).",
|
||||||
)
|
)
|
||||||
return True
|
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,
|
has_extended_early_bird,
|
||||||
week_rewards,
|
week_rewards,
|
||||||
)
|
)
|
||||||
|
from screen_locker._hold import workout_hold_active
|
||||||
from screen_locker._log_io import load_workout_log
|
from screen_locker._log_io import load_workout_log
|
||||||
from screen_locker._morning_session import has_workout_skip_today
|
from screen_locker._morning_session import has_workout_skip_today
|
||||||
from screen_locker._night_lockdown import tonights_lockdown
|
from screen_locker._night_lockdown import tonights_lockdown
|
||||||
@ -170,6 +171,7 @@ def gather_status(
|
|||||||
relaxed_day=is_relaxed_day(today=today_local),
|
relaxed_day=is_relaxed_day(today=today_local),
|
||||||
wake_skip=has_workout_skip_today(),
|
wake_skip=has_workout_skip_today(),
|
||||||
evening_lockdown=tonights_lockdown(instant) is not None,
|
evening_lockdown=tonights_lockdown(instant) is not None,
|
||||||
|
hold=workout_hold_active(instant),
|
||||||
now=instant,
|
now=instant,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@ -37,6 +37,7 @@ from screen_locker._constants import (
|
|||||||
from screen_locker._decision_log import record_run_aborted
|
from screen_locker._decision_log import record_run_aborted
|
||||||
from screen_locker._early_bird import EarlyBirdMixin
|
from screen_locker._early_bird import EarlyBirdMixin
|
||||||
from screen_locker._heat_skip import HeatSkipMixin
|
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._log_mixin import LogMixin
|
||||||
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin
|
||||||
from screen_locker._phone_verification import PhoneVerificationMixin
|
from screen_locker._phone_verification import PhoneVerificationMixin
|
||||||
@ -208,6 +209,7 @@ class ScreenLocker(
|
|||||||
# across two separate if/elif/else statements).
|
# across two separate if/elif/else statements).
|
||||||
if self._lock is not None: # pragma: no branch
|
if self._lock is not None: # pragma: no branch
|
||||||
self._lock.grab_input()
|
self._lock.grab_input()
|
||||||
|
watch_for_hold(self.root, self.close)
|
||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
"""Close the application and exit."""
|
"""Close the application and exit."""
|
||||||
|
|||||||
@ -18,7 +18,14 @@ from unittest.mock import MagicMock, patch
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from screen_locker import _hold
|
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
|
from screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -200,3 +207,91 @@ class TestLadder:
|
|||||||
expected = "relaxed_day" if relaxed else "enforced"
|
expected = "relaxed_day" if relaxed else "enforced"
|
||||||
assert [c.args[0].reason for c in record.call_args_list] == [expected]
|
assert [c.args[0].reason for c in record.call_args_list] == [expected]
|
||||||
mock_sys_exit.assert_not_called()
|
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