Fix lint: hold status mirror, split tests
Some checks failed
dependency freshness / dependencies on newest stable (push) Has been cancelled
md-naming / markdown naming convention (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Has been cancelled
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Has been cancelled
Tests / test (push) Has been cancelled
Tests / lock-popup-safety (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Has been cancelled
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Has been cancelled
Tests / web-coverage (push) Has been cancelled
repo-contract / repo contract (push) Has been cancelled
Tests / flutter-coverage (push) Has been cancelled

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:46:47 +01:00
parent 6919ddea28
commit 0987975bca
5 changed files with 141 additions and 128 deletions

View File

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

View File

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

View File

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

View File

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

View File

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