mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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
194 lines
7.7 KiB
Python
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)
|