screen-locker/screen_locker/tests/test_scheduled_skip.py
Krzysztof kuhy Rudnicki d6e8c0973f Replace scheduled skips with the shared free-day pool
Fifth and last app onto freedays (~/utils/freedays). screen-locker is the
one that already had this idea: scheduled_skips.json was a bare list of
dates, checked first in the lock chain, that only this app could see.
That is exactly what the shared pool generalises, so the chain now asks
freedays and a day taken here also stands down diet-guard, wake-alarm,
leetcode-guard and home-guard.

Sick days are deliberately untouched. Their rolling windows (1/7d, 3/30d,
10/90d), the 120-character justification and the escalating countdown all
exist to make a sick day *hard* to take. Free days are the opposite --
declared ahead, no reason, consecutive runs allowed. Merging the two
policies would have had to ruin one of them, so only the storage of the
frictionless half moved.

This fixes a real bug in passing. is_scheduled_skip_today compared
against a UTC day while add_scheduled_skip.py wrote a local one, so a
skip added near midnight could name a date the chain never matched.
freedays owns "what is today" for both sides now, in local time, and the
disagreement cannot recur.

scripts/migrate_scheduled_skips.py moves the existing dates across; it is
idempotent and has a --dry-run. Already run here: the 8 real skips
(2026-05-19/20/21, 05-29, 05-30, 08-17, 08-22, 08-29) are in the pool and
count against 2026, leaving 27 of 35. The original file is gitignored and
was left on disk untouched.

add_scheduled_skip.py becomes a thin wrapper over freedays rather than
being deleted, because arm.sh calls it and the name is in muscle memory.
Left as-is it would have kept writing to a file nothing reads.

conftest.py went over the 250-line cap, so the two fixtures that keep the
suite off real user state moved to _real_state_fixtures.py. They belong
together and neither subsumes the other: _isolate_home covers paths
resolved from Path.home() at call time, while the freedays redirect
covers paths bound into module constants at import time, which
redirecting home afterwards cannot reach.

requirements.txt also takes pytest-randomly 5.0.0, ruff 0.16.6 and
types-requests 2.33.0.20260906, and the pylint hooks' gatelock/crdt-sync
pins move off v0.7.1/v0.9.0 -- pip refuses two direct URLs for one
distribution, so those stale pins would have made the hook environments
unresolvable rather than merely old.

1256 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 15:42:41 +02:00

162 lines
5.1 KiB
Python

"""Tests for scheduled skip date feature in screen_lock.py."""
from __future__ import annotations
from datetime import date
from typing import TYPE_CHECKING
import freedays
import pytest
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
from unittest.mock import MagicMock
from screen_locker.screen_lock import ScreenLocker
class TestIsScheduledSkipToday:
"""Tests for ScreenLocker._is_scheduled_skip_today."""
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_returns_false_when_pool_empty(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when nothing has ever been marked."""
locker = self._make_locker(mock_tk, tmp_path)
assert locker._is_scheduled_skip_today() is False
def test_returns_true_when_today_marked(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns True when today is in the shared free-day pool."""
locker = self._make_locker(mock_tk, tmp_path)
freedays.mark(
freedays.today(), paths=freedays.Paths.under(tmp_path / "freedays")
)
assert locker._is_scheduled_skip_today() is True
def test_returns_false_when_only_another_day_marked(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""A pool holding some other date does not free today."""
locker = self._make_locker(mock_tk, tmp_path)
freedays.mark(
date(2026, 12, 24),
paths=freedays.Paths.under(tmp_path / "freedays"),
)
assert locker._is_scheduled_skip_today() is False
def test_returns_false_on_corrupt_pool(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Fail closed: an unparsable pool locks normally rather than opening."""
locker = self._make_locker(mock_tk, tmp_path)
pool = tmp_path / "freedays"
pool.mkdir(exist_ok=True)
(pool / "free_days.json").write_text("{not valid json}")
assert locker._is_scheduled_skip_today() is False
def test_returns_false_when_the_day_was_released(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Giving a day back re-arms the lock for it."""
locker = self._make_locker(mock_tk, tmp_path)
paths = freedays.Paths.under(tmp_path / "freedays")
freedays.mark(freedays.today(), paths=paths)
assert locker._is_scheduled_skip_today() is True
freedays.release(freedays.today(), paths=paths)
assert locker._is_scheduled_skip_today() is False
class TestScheduledSkipEarlyExit:
"""Tests for _check_non_verify_exits behaviour with scheduled skips."""
@staticmethod
def _write_today_skip(tmp_path: Path) -> None:
"""Mark today free in the shared pool the conftest redirected here."""
freedays.mark(
freedays.today(), paths=freedays.Paths.under(tmp_path / "freedays")
)
def test_exits_on_scheduled_skip_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Screen locker calls sys.exit(0) when today is a scheduled skip."""
self._write_today_skip(tmp_path)
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(mock_tk, tmp_path)
mock_sys_exit.assert_called_once_with(0)
def test_does_not_exit_when_not_scheduled_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Screen locker proceeds normally when today is not a scheduled skip."""
# No file written — _is_scheduled_skip_today returns False
locker = create_locker(mock_tk, tmp_path)
mock_sys_exit.assert_not_called()
assert locker is not None
def test_scheduled_skip_takes_precedence_over_has_logged(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Scheduled skip exits before has_logged or other checks run."""
self._write_today_skip(tmp_path)
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_called_once_with(0)
def test_verify_only_mode_ignores_scheduled_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""verify_only mode does not consult scheduled skips."""
self._write_today_skip(tmp_path)
# verify_only exits because no sick day log, not because of scheduled skip
create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=False,
)
mock_sys_exit.assert_called_once_with(0)