diff --git a/screen_locker/_compliance_state.py b/screen_locker/_compliance_state.py index 769134d..c202318 100644 --- a/screen_locker/_compliance_state.py +++ b/screen_locker/_compliance_state.py @@ -38,6 +38,7 @@ from screen_locker._compliance_trace import ( describe_degraded_sources, ) from screen_locker._day import today_str as local_day +from screen_locker._hold import workout_hold_active if TYPE_CHECKING: from collections.abc import Sequence @@ -153,7 +154,6 @@ def _chain_flags( relaxed_day: bool, wake_skip: bool, evening_lockdown: bool, - hold: bool, local_dt: datetime, pending: bool, ) -> dict[str, bool]: @@ -167,7 +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, + "test_hold_active": workout_hold_active(local_dt), "relaxed_day_already_skipped": relaxed_day and is_relaxed_day_skipped_today(log_file, today=today_str), "relaxed_day": relaxed_day, @@ -204,7 +204,6 @@ 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: @@ -222,7 +221,6 @@ 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/_status_data.py b/screen_locker/_status_data.py index 2958878..82810aa 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -50,7 +50,6 @@ 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 @@ -171,7 +170,6 @@ 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/tests/_hold_fakes.py b/screen_locker/tests/_hold_fakes.py new file mode 100644 index 0000000..3ac86c3 --- /dev/null +++ b/screen_locker/tests/_hold_fakes.py @@ -0,0 +1,28 @@ +"""A ``holds`` stand-in for tests: ``covers`` returns a value or raises it.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from datetime import datetime + + +class FakeHolds: + """A ``holds`` stand-in: ``covers`` returns the value, or raises it.""" + + def __init__(self, covers: object) -> None: + self._covers = covers + self.calls: list[tuple[str, datetime | None]] = [] + + def covers(self, gate: str, now: datetime | None = None) -> bool: + """Record the call, then answer like ``holds.covers``.""" + self.calls.append((gate, now)) + if isinstance(self._covers, BaseException): + raise self._covers + return bool(self._covers) + + +def fake_holds_module(*, covers: object) -> FakeHolds: + """A ``holds`` stand-in to install with ``monkeypatch.setitem(sys.modules, ...)``.""" + return FakeHolds(covers) diff --git a/screen_locker/tests/test_hold_gate.py b/screen_locker/tests/test_hold_gate.py index 0b77d68..b5d8818 100644 --- a/screen_locker/tests/test_hold_gate.py +++ b/screen_locker/tests/test_hold_gate.py @@ -18,14 +18,12 @@ from unittest.mock import MagicMock, patch import pytest from screen_locker import _hold -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._hold_fakes import FakeHolds, fake_holds_module from screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -34,29 +32,10 @@ if TYPE_CHECKING: NOW = datetime(2026, 11, 5, 11, 5).astimezone() -class _FakeHolds: - """A ``holds`` stand-in: ``covers`` returns the value, or raises it.""" - - def __init__(self, covers: object) -> None: - self._covers = covers - self.calls: list[tuple[str, datetime | None]] = [] - - def covers(self, gate: str, now: datetime | None = None) -> bool: - """Record the call, then answer like ``holds.covers``.""" - self.calls.append((gate, now)) - if isinstance(self._covers, BaseException): - raise self._covers - return bool(self._covers) - - -def _fake_holds(*, covers: object) -> _FakeHolds: - return _FakeHolds(covers) - - @pytest.fixture(name="fake_holds") -def _fake_holds_fixture(monkeypatch: pytest.MonkeyPatch) -> _FakeHolds: +def _fake_holds_fixture(monkeypatch: pytest.MonkeyPatch) -> FakeHolds: """Install a covering ``holds`` for the duration of one test.""" - mod = _fake_holds(covers=True) + mod = fake_holds_module(covers=True) monkeypatch.setitem(sys.modules, "holds", mod) return mod @@ -65,7 +44,7 @@ class TestWorkoutHoldActive: """The wrapper around ``holds.covers("workout", now)``.""" def test_covered_is_true_and_asks_for_the_workout_gate( - self, fake_holds: _FakeHolds + self, fake_holds: FakeHolds ) -> None: """An active hold reads True, asked about the 'workout' gate and now.""" assert workout_hold_active(NOW) is True @@ -73,7 +52,7 @@ class TestWorkoutHoldActive: def test_not_covered_is_false(self, monkeypatch: pytest.MonkeyPatch) -> None: """No active hold reads False.""" - monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=False)) + monkeypatch.setitem(sys.modules, "holds", fake_holds_module(covers=False)) assert workout_hold_active(NOW) is False def test_import_error_fails_closed_and_says_so( @@ -90,14 +69,14 @@ class TestWorkoutHoldActive: self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture ) -> None: """A holds call that blows up means no hold, logged at error.""" - monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=OSError("boom"))) + monkeypatch.setitem( + sys.modules, "holds", fake_holds_module(covers=OSError("boom")) + ) with caplog.at_level(logging.ERROR, logger="screen_locker._hold"): assert workout_hold_active(NOW) is False assert "boom" in caplog.text - def test_default_now_is_passed_through_as_none( - self, fake_holds: _FakeHolds - ) -> None: + def test_default_now_is_passed_through_as_none(self, fake_holds: FakeHolds) -> None: """Without ``now`` the clock is holds' own (None).""" assert workout_hold_active() is True assert fake_holds.calls == [("workout", None)] @@ -110,12 +89,12 @@ class TestSkipDuringHold: self, monkeypatch: pytest.MonkeyPatch ) -> None: """Without a hold the rung records nothing.""" - monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=False)) + monkeypatch.setitem(sys.modules, "holds", fake_holds_module(covers=False)) record_skip = MagicMock() assert skip_during_hold(record_skip) is False record_skip.assert_not_called() - def test_hold_records_the_skip(self, fake_holds: _FakeHolds) -> None: + def test_hold_records_the_skip(self, fake_holds: FakeHolds) -> None: """An active hold records a test_hold_active skip.""" record_skip = MagicMock() assert skip_during_hold(record_skip) is True @@ -167,7 +146,7 @@ class TestLadder: # After construction: building the locker runs the ladder itself. locker = create_locker(mock_tk, tmp_path) mock_sys_exit.reset_mock() - monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=True)) + monkeypatch.setitem(sys.modules, "holds", fake_holds_module(covers=True)) with ( patch("screen_locker._startup_checks.record_decision") as record, patch("screen_locker._startup_checks.is_relaxed_day", return_value=relaxed), @@ -194,7 +173,7 @@ class TestLadder: relaxed: bool, ) -> None: """No hold: the relaxed prompt or the enforced lock still happen.""" - monkeypatch.setitem(sys.modules, "holds", _fake_holds(covers=False)) + monkeypatch.setitem(sys.modules, "holds", fake_holds_module(covers=False)) locker = create_locker(mock_tk, tmp_path) with ( patch("screen_locker._startup_checks.record_decision") as record, @@ -207,91 +186,3 @@ 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")) diff --git a/screen_locker/tests/test_hold_watch.py b/screen_locker/tests/test_hold_watch.py new file mode 100644 index 0000000..cf2e09c --- /dev/null +++ b/screen_locker/tests/test_hold_watch.py @@ -0,0 +1,98 @@ +"""An open workout window closes when a hold starts; the status page names it.""" + +from __future__ import annotations + +import logging +import sys +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from screen_locker._compliance_state import explain_lock_decision +from screen_locker._hold import watch_for_hold +from screen_locker.tests._hold_fakes import fake_holds_module +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +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_module(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_module(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, *, covers: bool) -> object: + sys.modules["holds"] = fake_holds_module(covers=covers) + 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, + ) + + @pytest.fixture(autouse=True) + def _restore_holds(self, monkeypatch: pytest.MonkeyPatch) -> None: + """Let the test's own ``sys.modules['holds']`` be undone afterwards.""" + monkeypatch.setitem(sys.modules, "holds", sys.modules.get("holds")) + + 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, covers=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, covers=False).stage == "would_lock" + + def test_other_conditions_name_the_hold( + self, mock_tk: MagicMock, tmp_path: Path, monkeypatch: pytest.MonkeyPatch + ) -> None: + """A decision made for another reason still reports the hold as also=.""" + locker = create_locker(mock_tk, tmp_path) + monkeypatch.setitem(sys.modules, "holds", fake_holds_module(covers=True)) + assert "test_hold_active" in str(locker._other_conditions("enforced"))