diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index fa31d40..892b887 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -46,3 +46,5 @@ SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json" # Persistent sick-day history (rate-limit, debt, commitments, justifications). # Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot. SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json" +# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped. +SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json" diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 65bd91e..89692ef 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -293,8 +293,10 @@ class ShutdownMixin: def _schedule_rtcwake() -> bool: """Set rtcwake to power on the PC after WAKE_AFTER_HOURS. - Uses ``rtcwake -m no`` so the system can shut down normally while - the RTC alarm remains set. + Uses ``rtcwake -m disk`` to hibernate immediately while programming + the RTC to restore power at wake_epoch. Hibernate is completely + silent and dark (state written to swap file), making it suitable + when the PC is in a bedroom. Returns: True if rtcwake was set successfully, False otherwise. @@ -304,7 +306,7 @@ class ShutdownMixin: "/usr/bin/sudo", RTCWAKE_BIN, "-m", - "no", + "disk", "-t", str(wake_epoch), ] diff --git a/screen_locker/scheduled_skips.json b/screen_locker/scheduled_skips.json new file mode 100644 index 0000000..4afbae5 --- /dev/null +++ b/screen_locker/scheduled_skips.json @@ -0,0 +1 @@ +["2026-05-19", "2026-05-20", "2026-05-21"] diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 4359e81..a395575 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -27,6 +27,7 @@ from python_pkg.screen_locker._constants import ( MIN_WORKOUT_DURATION_MINUTES, PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_PRODUCTION, + SCHEDULED_SKIPS_FILE, SICK_LOCKOUT_SECONDS, STRONGLIFTS_DB_REMOTE, ) @@ -54,6 +55,7 @@ __all__ = [ "MIN_WORKOUT_DURATION_MINUTES", "PHONE_PENALTY_DELAY_DEMO", "PHONE_PENALTY_DELAY_PRODUCTION", + "SCHEDULED_SKIPS_FILE", "SICK_LOCKOUT_SECONDS", "STRONGLIFTS_DB_REMOTE", "ScreenLocker", @@ -185,6 +187,9 @@ class ScreenLocker( def _check_non_verify_exits(self) -> None: """Check all normal (non-verify) startup early-exit conditions.""" + if self._is_scheduled_skip_today(): + _logger.info("Today is a scheduled skip day. Skipping screen lock.") + sys.exit(0) if self._is_early_bird_log() and not self._is_early_bird_time(): if self._try_auto_upgrade_early_bird(): _logger.info( @@ -484,6 +489,18 @@ class ScreenLocker( except (OSError, json.JSONDecodeError): return {} + def _is_scheduled_skip_today(self) -> bool: + """Return True if today's date is listed in the scheduled skips file.""" + if not SCHEDULED_SKIPS_FILE.exists(): + return False + try: + with SCHEDULED_SKIPS_FILE.open() as f: + skips = json.load(f) + except (OSError, json.JSONDecodeError): + return False + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + return today in skips + def save_workout_log(self) -> None: """Save workout data to log file with HMAC signature.""" logs = self._load_existing_logs() diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index c92b2e9..43818f9 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -95,6 +95,17 @@ def _isolate_sick_history(tmp_path: Path) -> Iterator[None]: yield +@pytest.fixture(autouse=True) +def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]: + """Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file.""" + target = tmp_path / "scheduled_skips.json" + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + target, + ): + yield + + @pytest.fixture def mock_tk() -> Generator[MagicMock]: """Mock tkinter module for testing without display.""" diff --git a/screen_locker/tests/test_scheduled_skip.py b/screen_locker/tests/test_scheduled_skip.py new file mode 100644 index 0000000..cc3b6a7 --- /dev/null +++ b/screen_locker/tests/test_scheduled_skip.py @@ -0,0 +1,195 @@ +"""Tests for scheduled skip date feature in screen_lock.py.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + from python_pkg.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_file_absent( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when scheduled_skips.json does not exist.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + def test_returns_true_when_today_listed( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns True when today's date is in the skips list.""" + locker = self._make_locker(mock_tk, tmp_path) + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text(json.dumps([today])) + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is True + + def test_returns_false_when_today_not_listed( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when today's date is not in the skips list.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text(json.dumps(["1999-01-01", "2000-06-15"])) + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + def test_returns_false_on_corrupt_json( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when the skips file contains invalid JSON.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text("{not valid json}") + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + assert locker._is_scheduled_skip_today() is False + + def test_returns_false_on_read_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when the skips file cannot be read (OSError).""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text("[]") + with ( + patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ), + patch("builtins.open", side_effect=OSError("permission denied")), + ): + assert locker._is_scheduled_skip_today() is False + + def test_empty_list_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False for an empty skips list.""" + locker = self._make_locker(mock_tk, tmp_path) + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text("[]") + with patch( + "python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE", + skip_file, + ): + 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: + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + skip_file = tmp_path / "scheduled_skips.json" + skip_file.write_text(json.dumps([today])) + + 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)