screen-locker/screen_locker/tests/test_carrot_watch.py
Krzysztof kuhy Rudnicki 856f0c0774 Lift armed lock when carrot lands late
Poll morning_session every 15s (05:00-11:00) while the lock is
up; on a signed skip bank the early-bird marker, record
wake_alarm_skip_late and leave through the normal close().

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWZd3smuxDGmEgXkYP8
2026-11-07 08:11:39 +01:00

194 lines
7.7 KiB
Python

"""An armed lock takes itself down when wake-alarm's signed carrot lands late.
2026-11-07: the lock armed at 07:49 because today's morning_session.json had
not landed; it landed at 07:50 and nothing re-read it, so the morning session
ran under a fullscreen lock.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import json
import logging
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from gatelock.log_integrity import compute_entry_hmac
import pytest
from screen_locker import _carrot_watch, _morning_session
from screen_locker._carrot_watch import POLL_MS, watch_for_carrot
from screen_locker._morning_session import MorningSkip
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
_EIGHT = datetime(2026, 11, 7, 8, 0).astimezone()
@pytest.fixture(autouse=True)
def _signing_key(tmp_path: Path) -> Iterator[None]:
key = tmp_path / "hmac.key"
key.write_bytes(b"1" * 32)
with patch("gatelock.log_integrity.DEFAULT_HMAC_KEY_FILE", key):
yield
def _sign(date: str, until: datetime) -> None:
"""Write a signed session file into the conftest-redirected path."""
entry: dict[str, object] = {
"date": date,
"outcome": "in_progress",
"exempt_until": until.isoformat(),
}
entry["hmac"] = compute_entry_hmac(entry)
_morning_session.MORNING_SESSION_FILE.write_text(json.dumps(entry))
def _tick(now: datetime) -> tuple[MagicMock, MagicMock]:
"""Arm the watch, run its first poll at ``now``; return (root, on_carrot)."""
root, on_carrot = MagicMock(), MagicMock()
watch_for_carrot(root, on_carrot)
with patch.object(_carrot_watch, "_local_now", return_value=now):
root.after.call_args.args[1]()
return root, on_carrot
class TestWatchForCarrot:
"""The poll on the Tk loop."""
def test_local_now_is_timezone_aware_local_time(self) -> None:
"""The poll's clock is the real, aware local time."""
assert abs(_carrot_watch._local_now() - datetime.now().astimezone()) < (
timedelta(seconds=5)
)
def test_arms_a_fifteen_second_poll(self) -> None:
"""Arming schedules the first check 15 s out and releases nothing."""
root, on_carrot = MagicMock(), MagicMock()
watch_for_carrot(root, on_carrot)
assert POLL_MS == 15_000
assert root.after.call_args.args[0] == POLL_MS
on_carrot.assert_not_called()
def test_signed_todays_skip_releases_once_and_stops_polling(self) -> None:
"""A skip that landed after arming is handed over; no re-arm."""
until = _EIGHT + timedelta(hours=1, minutes=13)
_sign("2026-11-07", until)
root, on_carrot = _tick(_EIGHT)
on_carrot.assert_called_once_with(MorningSkip("in_progress", until))
assert root.after.call_count == 1
def test_no_file_keeps_the_lock_and_reschedules(self) -> None:
"""No file: the lock stays and the next poll is scheduled."""
root, on_carrot = _tick(_EIGHT)
on_carrot.assert_not_called()
assert root.after.call_count == 2
assert root.after.call_args.args[0] == POLL_MS
def test_expired_skip_keeps_the_lock(self) -> None:
"""A carrot whose exempt_until has passed is no skip."""
_sign("2026-11-07", _EIGHT - timedelta(minutes=1))
root, on_carrot = _tick(_EIGHT)
on_carrot.assert_not_called()
assert root.after.call_count == 2
def test_yesterdays_file_keeps_the_lock(self) -> None:
"""A file dated another day is no skip."""
_sign("2026-11-06", _EIGHT + timedelta(hours=3))
_root, on_carrot = _tick(_EIGHT)
on_carrot.assert_not_called()
@pytest.mark.parametrize(("hour", "minute"), [(4, 59), (11, 0), (23, 30)])
def test_outside_05_to_11_never_reads_or_releases(
self, hour: int, minute: int
) -> None:
"""Outside 05:00-11:00 local the poll neither reads nor releases."""
now = _EIGHT.replace(hour=hour, minute=minute)
_sign("2026-11-07", now + timedelta(hours=1))
with patch.object(_carrot_watch._morning_session, "morning_skip_today") as read:
root, on_carrot = _tick(now)
read.assert_not_called()
on_carrot.assert_not_called()
assert root.after.call_count == 2
def test_inside_window_reads_without_waiting(self) -> None:
"""The poll never waits: it must not block the Tk loop."""
with patch.object(
_carrot_watch._morning_session, "morning_skip_today", return_value=None
) as read:
_tick(_EIGHT)
read.assert_called_once_with(_EIGHT, wait=False)
def test_reader_failure_is_logged_and_the_poll_survives(
self, caplog: pytest.LogCaptureFixture
) -> None:
"""A read error warns and re-arms; one bad read must not end the watch."""
with (
patch.object(
_carrot_watch._morning_session,
"morning_skip_today",
side_effect=OSError("disk gone"),
),
caplog.at_level(logging.WARNING, logger="screen_locker._carrot_watch"),
):
root, on_carrot = _tick(_EIGHT)
on_carrot.assert_not_called()
assert root.after.call_count == 2
assert "disk gone" in caplog.text
class TestLockerStandsDown:
"""The locker side: marker, decision, warning, then the real close."""
def test_construction_arms_the_watch(
self, mock_tk: MagicMock, tmp_path: Path
) -> None:
"""A built locker watches on its own root and its own stand-down."""
with patch("screen_locker.screen_lock.watch_for_carrot") as watch:
locker = create_locker(mock_tk, tmp_path)
watch.assert_called_once_with(locker.root, locker._stand_down_for_carrot)
def test_stand_down_banks_marker_records_decision_then_closes(
self,
mock_tk: MagicMock,
tmp_path: Path,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Marker first, decision lock=no, warning, and only then close()."""
locker = create_locker(mock_tk, tmp_path)
until = _EIGHT + timedelta(hours=1)
order = MagicMock()
with (
patch.object(locker, "_save_early_bird_pending", order.save),
patch("screen_locker._auto_upgrade.record_decision", order.record),
patch.object(locker, "close", order.close),
caplog.at_level(logging.WARNING, logger="screen_locker._auto_upgrade"),
):
locker._stand_down_for_carrot(MorningSkip("in_progress", until))
assert [c[0] for c in order.mock_calls] == ["save", "record", "close"]
decision = order.record.call_args.args[0]
assert decision.locked is False
assert decision.reason == "wake_alarm_skip_late"
assert decision.extra["exempt_until"] == until.isoformat()
assert decision.extra["outcome"] == "in_progress"
assert "carrot" in caplog.text.lower()
def test_stand_down_goes_through_the_real_close_and_exits_zero(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""The real ``close``: lock window released, instance lock dropped, exit 0."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_save_early_bird_pending"),
patch("screen_locker._auto_upgrade.record_decision"),
patch.object(locker, "_lock") as lock,
patch.object(locker._instance_lock, "release") as release,
):
locker._stand_down_for_carrot(MorningSkip("completed", _EIGHT))
lock.close.assert_called_once_with()
release.assert_called_once_with()
mock_sys_exit.assert_called_once_with(0)