From 90d8461f1d8b3c49063edf9c4668f96afb62282f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 1 May 2026 19:07:34 +0200 Subject: [PATCH] feat(screen-locker): add early-bird workout checks and phone verification updates --- python_pkg/screen_locker/_constants.py | 3 + .../screen_locker/_phone_verification.py | 20 +- .../early-bird-workout-check.timer | 14 + python_pkg/screen_locker/install_systemd.sh | 14 + python_pkg/screen_locker/screen_lock.py | 187 +++++++- python_pkg/screen_locker/tests/conftest.py | 47 +- .../screen_locker/tests/test_adb_and_phone.py | 64 ++- .../screen_locker/tests/test_early_bird.py | 430 ++++++++++++++++++ .../screen_locker/tests/test_init_and_log.py | 168 +++++-- .../steam_backlog_enforcer/game_install.py | 1 + 10 files changed, 867 insertions(+), 81 deletions(-) create mode 100644 python_pkg/screen_locker/early-bird-workout-check.timer create mode 100644 python_pkg/screen_locker/tests/test_early_bird.py diff --git a/python_pkg/screen_locker/_constants.py b/python_pkg/screen_locker/_constants.py index 72ea665..774bf98 100644 --- a/python_pkg/screen_locker/_constants.py +++ b/python_pkg/screen_locker/_constants.py @@ -13,6 +13,9 @@ STRONGLIFTS_DB_REMOTE = ( ) MIN_WORKOUT_DURATION_MINUTES = 50 MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP +EARLY_BIRD_START_HOUR = 5 +EARLY_BIRD_END_HOUR = 8 +EARLY_BIRD_END_MINUTE = 30 SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") # HMAC key for signing workout log entries (root-owned, 0600) HMAC_KEY_FILE = Path("/etc/workout-locker/hmac.key") diff --git a/python_pkg/screen_locker/_phone_verification.py b/python_pkg/screen_locker/_phone_verification.py index 869be3f..1c18f49 100644 --- a/python_pkg/screen_locker/_phone_verification.py +++ b/python_pkg/screen_locker/_phone_verification.py @@ -255,19 +255,20 @@ class PhoneVerificationMixin: return 0 def _is_workout_finish_recent(self, db_path: Path) -> bool: - """Check if the latest workout's finish time is recent. + """Check if the latest workout's finish time is from today. - A fresh workout should have finished within the last 24 hours. - This prevents using an old pre-prepared database dump while - still accepting workouts done earlier the same day. + A fresh workout should have finished today (local time) and not in + the future. This prevents using an old pre-prepared database dump + while still allowing workouts done earlier in the day (e.g. a + morning workout being verified in the evening). Args: db_path: Path to the locally-pulled StrongLifts database. Returns: - True if the latest finish time is within 24 hours of now. + True if the latest finish time is today (local) and not in the + future. """ - max_age_seconds = 24 * 3600 # accept same-day workouts try: conn = sqlite3.connect(str(db_path)) try: @@ -275,13 +276,16 @@ class PhoneVerificationMixin: "SELECT MAX(finish) FROM workouts " "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime') " - "AND finish > start", + "AND finish > start " + "AND date(finish / 1000, 'unixepoch', 'localtime') " + "= date('now', 'localtime')", ) row = cursor.fetchone() if not row or row[0] is None: return False finish_epoch = int(row[0]) / 1000.0 - return (time.time() - finish_epoch) < max_age_seconds + # Reject future timestamps (clock-skew / tampering guard). + return finish_epoch <= time.time() finally: conn.close() except (sqlite3.Error, ValueError, TypeError): diff --git a/python_pkg/screen_locker/early-bird-workout-check.timer b/python_pkg/screen_locker/early-bird-workout-check.timer new file mode 100644 index 0000000..026cf95 --- /dev/null +++ b/python_pkg/screen_locker/early-bird-workout-check.timer @@ -0,0 +1,14 @@ +[Unit] +Description=Re-check workout after early bird grace period expires at 08:30 +After=graphical-session.target + +[Timer] +# Fires every day at 08:30 to verify workout if user logged in during 5–8:30 window +OnCalendar=*-*-* 08:30:00 +Unit=workout-locker.service +Persistent=false +AccuracySec=1s +RandomizedDelaySec=0 + +[Install] +WantedBy=timers.target diff --git a/python_pkg/screen_locker/install_systemd.sh b/python_pkg/screen_locker/install_systemd.sh index ff9a7db..aa07798 100755 --- a/python_pkg/screen_locker/install_systemd.sh +++ b/python_pkg/screen_locker/install_systemd.sh @@ -4,8 +4,10 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py" SERVICE_FILE="$SCRIPT_DIR/workout-locker.service" +EARLY_BIRD_TIMER_FILE="$SCRIPT_DIR/early-bird-workout-check.timer" USER_SERVICE_DIR="$HOME/.config/systemd/user" SERVICE_NAME="workout-locker.service" +EARLY_BIRD_TIMER_NAME="early-bird-workout-check.timer" # Check if service is already installed if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then @@ -33,6 +35,9 @@ rm -f "$USER_SERVICE_DIR/workout-locker.timer" # Copy service file to user systemd directory cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME" +# Copy early bird timer +cp "$EARLY_BIRD_TIMER_FILE" "$USER_SERVICE_DIR/$EARLY_BIRD_TIMER_NAME" + # Update paths in the service file to use absolute paths REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME" @@ -45,7 +50,11 @@ systemctl --user daemon-reload # Enable the service to start on login (one-shot, no periodic timer) systemctl --user enable "$SERVICE_NAME" +# Enable the early bird re-check timer +systemctl --user enable --now "$EARLY_BIRD_TIMER_NAME" + echo "✓ Workout locker service installed" +echo "✓ Early bird re-check timer installed (fires daily at 08:30)" echo "✓ Service will start automatically on next login" echo "" echo "To start now: systemctl --user start workout-locker" @@ -61,6 +70,11 @@ if systemctl --user is-enabled "$SERVICE_NAME" &>/dev/null; then else echo "✗ systemd service: NOT enabled" fi +if systemctl --user is-enabled "$EARLY_BIRD_TIMER_NAME" &>/dev/null; then + echo "✓ early bird timer: INSTALLED and enabled" +else + echo "✗ early bird timer: NOT enabled" +fi I3_CONFIG="$HOME/.config/i3/config" if [ -f "$I3_CONFIG" ] && grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index f25855c..97d5bce 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -16,6 +16,9 @@ import tkinter as tk from typing import TYPE_CHECKING from python_pkg.screen_locker._constants import ( + EARLY_BIRD_END_HOUR, + EARLY_BIRD_END_MINUTE, + EARLY_BIRD_START_HOUR, HMAC_KEY_FILE, MAX_CLOCK_SKEW_SECONDS, MIN_WORKOUT_DURATION_MINUTES, @@ -25,7 +28,6 @@ from python_pkg.screen_locker._constants import ( STRONGLIFTS_DB_REMOTE, ) from python_pkg.screen_locker._log_integrity import ( - _load_hmac_key, compute_entry_hmac, verify_entry_hmac, ) @@ -39,6 +41,9 @@ if TYPE_CHECKING: from concurrent.futures import Future __all__ = [ + "EARLY_BIRD_END_HOUR", + "EARLY_BIRD_END_MINUTE", + "EARLY_BIRD_START_HOUR", "HMAC_KEY_FILE", "MAX_CLOCK_SKEW_SECONDS", "MIN_WORKOUT_DURATION_MINUTES", @@ -85,18 +90,8 @@ class ScreenLocker( script_dir = Path(__file__).resolve().parent self.log_file = script_dir / "workout_log.json" self.verify_only = verify_only - if verify_only: - if not self._is_sick_day_log(): - _logger.info( - "No sick day logged today. Nothing to verify.", - ) - sys.exit(0) - elif self.has_logged_today(): - _logger.info("Workout already logged today. Skipping screen lock.") - sys.exit(0) - elif has_workout_skip_today(): - _logger.info("Wake alarm earned workout skip. Skipping screen lock.") - sys.exit(0) + self.workout_data: dict[str, str] = {} + self._check_early_exits(verify_only=verify_only) self.root = tk.Tk() title_suffix = ( " [VERIFY]" if verify_only else (" [DEMO MODE]" if demo_mode else "") @@ -104,7 +99,6 @@ class ScreenLocker( self.root.title("Workout Locker" + title_suffix) self.demo_mode = demo_mode self.lockout_time = 10 if demo_mode else 1800 - self.workout_data: dict[str, str] = {} if verify_only: self._setup_verify_window() else: @@ -151,6 +145,146 @@ class ScreenLocker( return False return entry.get("workout_data", {}).get("type") == "sick_day" + def _check_early_exits(self, *, verify_only: bool) -> None: + """Check startup conditions and exit early when appropriate.""" + if verify_only: + if not self._is_sick_day_log(): + _logger.info( + "No sick day logged today. Nothing to verify.", + ) + sys.exit(0) + else: + self._check_non_verify_exits() + + def _check_non_verify_exits(self) -> None: + """Check all normal (non-verify) startup early-exit conditions.""" + if self._is_early_bird_log() and not self._is_early_bird_time(): + if self._try_auto_upgrade_early_bird(): + _logger.info( + "Auto-upgraded early_bird entry to phone_verified.", + ) + sys.exit(0) + elif self._is_early_bird_log(): + _logger.info("Early bird window still active — skipping lock.") + sys.exit(0) + elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day(): + _logger.info( + "Auto-upgraded today's sick_day entry to phone_verified.", + ) + sys.exit(0) + elif self.has_logged_today(): + _logger.info("Workout already logged today. Skipping screen lock.") + sys.exit(0) + elif has_workout_skip_today(): + _logger.info( + "Wake alarm earned workout skip. Skipping screen lock.", + ) + sys.exit(0) + elif self._is_early_bird_time(): + self._save_early_bird_log() + _logger.info( + "Early bird time — skipping lock, will re-check at 08:30.", + ) + sys.exit(0) + + def _get_local_time_minutes(self) -> int: + """Return current local time as minutes from midnight.""" + now = datetime.now(tz=timezone.utc).astimezone() + return now.hour * 60 + now.minute + + def _is_early_bird_time(self) -> bool: + """Return True if current local time is in the early bird window. + + The early bird window is EARLY_BIRD_START_HOUR (5 AM) up to but not + including EARLY_BIRD_END_HOUR:EARLY_BIRD_END_MINUTE (8:30 AM). + """ + minutes = self._get_local_time_minutes() + start = EARLY_BIRD_START_HOUR * 60 + end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE + return start <= minutes < end + + def _is_early_bird_log(self) -> bool: + """Check if today's workout log entry is an early_bird provisional entry.""" + if not self.log_file.exists(): + return False + try: + with self.log_file.open() as f: + logs = json.load(f) + except (OSError, json.JSONDecodeError): + return False + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + entry = logs.get(today) + if entry is None: + return False + return entry.get("workout_data", {}).get("type") == "early_bird" + + def _save_early_bird_log(self) -> None: + """Save an early_bird provisional entry to the workout log.""" + self.workout_data = {"type": "early_bird"} + self.save_workout_log() + + def _try_auto_upgrade_early_bird(self) -> bool: + """Silently upgrade today's early_bird entry if phone shows a workout. + + Called at 8:30 AM when the early bird grace period expires. If the + phone shows a completed workout, upgrades the entry to phone_verified + and rewards with a later shutdown time. Otherwise returns False so the + caller can show the lock screen. + + Returns: + True if the entry was upgraded to phone_verified, False otherwise. + """ + try: + status, message = self._verify_phone_workout() + except (OSError, RuntimeError) as exc: + _logger.info("Early bird upgrade phone check failed: %s", exc) + return False + if status != "verified": + _logger.info( + "Early bird upgrade skipped (phone status=%s): %s", + status, + message, + ) + return False + self.workout_data["type"] = "phone_verified" + self.workout_data["source"] = message + self.workout_data["after_early_bird"] = "true" + self._adjust_shutdown_time_later() + self.save_workout_log() + return True + + def _try_auto_upgrade_sick_day(self) -> bool: + """Silently upgrade today's sick_day entry if phone shows a workout. + + Runs at startup without any UI so that a real workout logged on the + phone retroactively replaces an earlier sick_day entry (for example + when a previous bug forced the user into the sick path). + + Returns: + True if the entry was upgraded to phone_verified, False otherwise. + On False the caller should fall through to the normal startup + path (which will skip the lock because the sick_day entry still + satisfies ``has_logged_today``). + """ + try: + status, message = self._verify_phone_workout() + except (OSError, RuntimeError) as exc: + _logger.info("Auto-upgrade phone check failed: %s", exc) + return False + if status != "verified": + _logger.info( + "Auto-upgrade skipped (phone status=%s): %s", + status, + message, + ) + return False + self.workout_data["type"] = "phone_verified" + self.workout_data["source"] = message + self.workout_data["after_sick_day"] = "true" + self._adjust_shutdown_time_later() + self.save_workout_log() + return True + def _setup_demo_close_button(self) -> None: """Add close button for demo mode.""" close_btn = tk.Button( @@ -283,7 +417,12 @@ class ScreenLocker( self.root.after(1500, self.close) def has_logged_today(self) -> bool: - """Check if workout has been logged today with valid HMAC.""" + """Check if workout has been logged today. + + Signed entries are verified with HMAC. Older unsigned entries are + still accepted as a legacy fallback so the user-level service does not + forget workouts when the root-owned HMAC key is unavailable. + """ if not self.log_file.exists(): return False @@ -297,17 +436,15 @@ class ScreenLocker( entry = logs.get(today) if entry is None: return False - if verify_entry_hmac(entry): - return True - if _load_hmac_key() is None and "hmac" not in entry: - _logger.info( - "HMAC key unavailable — accepting unsigned entry", + if "hmac" not in entry: + _logger.warning( + "Today's log entry is unsigned; accepting legacy fallback" ) - return True - _logger.warning( - "HMAC verification failed for today's log entry", - ) - return False + return entry.get("workout_data", {}).get("type") != "early_bird" + if not verify_entry_hmac(entry): + _logger.warning("HMAC verification failed for today's log entry") + return False + return entry.get("workout_data", {}).get("type") != "early_bird" def _load_existing_logs(self) -> dict: """Load existing workout logs from file.""" diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py index 7c54947..e2faa42 100644 --- a/python_pkg/screen_locker/tests/conftest.py +++ b/python_pkg/screen_locker/tests/conftest.py @@ -21,6 +21,7 @@ from python_pkg.screen_locker.screen_lock import ScreenLocker if TYPE_CHECKING: from collections.abc import Generator, Iterator + from typing import Literal def _make_mock_tk() -> MagicMock: @@ -105,7 +106,7 @@ def create_locker( verify_only: bool = False, is_sick_day_log: bool = False, ) -> ScreenLocker: - """Create a ScreenLocker instance for testing.""" + """Create a ScreenLocker instance with early bird paths disabled.""" with ( patch.object(Path, "resolve", return_value=tmp_path), patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), @@ -114,6 +115,13 @@ def create_locker( "_is_sick_day_log", return_value=is_sick_day_log, ), + patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), + patch.object( + ScreenLocker, + "_try_auto_upgrade_early_bird", + return_value=False, + ), patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_verify_workout_check"), ): @@ -121,3 +129,40 @@ def create_locker( demo_mode=demo_mode, verify_only=verify_only, ) + + +def create_locker_early_bird( + _mock_tk: MagicMock, + tmp_path: Path, + *, + state: Literal["none", "log_active", "log_expired"] = "none", + has_logged: bool = False, + demo_mode: bool = True, +) -> ScreenLocker: + """Create a ScreenLocker configured for early bird path testing. + + Args: + state: One of: + - "none": outside early bird window, no early bird log. + - "log_active": early bird log exists, still in window. + - "log_expired": early bird log exists, past 8:30 AM. + has_logged: Return value for has_logged_today mock. + demo_mode: Passed to ScreenLocker constructor. + """ + is_early_bird_log = state in ("log_active", "log_expired") + is_early_bird_time = state == "log_active" + with ( + patch.object(Path, "resolve", return_value=tmp_path), + patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), + patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), + patch.object( + ScreenLocker, "_is_early_bird_log", return_value=is_early_bird_log + ), + patch.object( + ScreenLocker, "_is_early_bird_time", return_value=is_early_bird_time + ), + patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False), + patch.object(ScreenLocker, "_start_phone_check"), + patch.object(ScreenLocker, "_start_verify_workout_check"), + ): + return ScreenLocker(demo_mode=demo_mode) diff --git a/python_pkg/screen_locker/tests/test_adb_and_phone.py b/python_pkg/screen_locker/tests/test_adb_and_phone.py index 171adf5..1cb18c9 100644 --- a/python_pkg/screen_locker/tests/test_adb_and_phone.py +++ b/python_pkg/screen_locker/tests/test_adb_and_phone.py @@ -795,7 +795,7 @@ class TestIsWorkoutFinishRecent: mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Test returns False for workout that finished >24 hours ago.""" + """Test returns False for workout that finished on a previous day.""" locker = create_locker(mock_tk, tmp_path) db_file = tmp_path / "sl_test.db" conn = sqlite3.connect(str(db_file)) @@ -803,12 +803,66 @@ class TestIsWorkoutFinishRecent: "CREATE TABLE workouts " "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", ) - # Finished 25 hours ago (not "today" in local time either) - now_ms = int(time.time() * 1000) - old_finish = now_ms - 25 * 3600 * 1000 # beyond 24h window + # Start and finish are both yesterday (local time). + yesterday_ms = int((time.time() - 36 * 3600) * 1000) conn.execute( "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", old_finish - 3600000, old_finish), + ("w1", yesterday_ms - 3600000, yesterday_ms), + ) + conn.commit() + conn.close() + + assert locker._is_workout_finish_recent(db_file) is False + + def test_earlier_today_workout_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Workout that finished earlier today (>4h ago) is still accepted.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "sl_test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", + ) + # Start at today's local-midnight + 1s, finish = now. Both stay + # within today's local date regardless of when the test runs. + today_local_midnight = int( + time.mktime(time.strptime(time.strftime("%Y-%m-%d"), "%Y-%m-%d")), + ) + start_ms = (today_local_midnight + 1) * 1000 + finish_ms = int(time.time() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", start_ms, finish_ms), + ) + conn.commit() + conn.close() + + assert locker._is_workout_finish_recent(db_file) is True + + def test_future_finish_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Finish timestamp in the future is rejected (clock-skew guard).""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "sl_test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", + ) + now_ms = int(time.time() * 1000) + future_ms = now_ms + 2 * 3600 * 1000 + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", now_ms, future_ms), ) conn.commit() conn.close() diff --git a/python_pkg/screen_locker/tests/test_early_bird.py b/python_pkg/screen_locker/tests/test_early_bird.py new file mode 100644 index 0000000..c7444f0 --- /dev/null +++ b/python_pkg/screen_locker/tests/test_early_bird.py @@ -0,0 +1,430 @@ +"""Tests for early bird carrot feature in screen locker.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.screen_locker.screen_lock import ScreenLocker +from python_pkg.screen_locker.tests.conftest import ( + create_locker, + create_locker_early_bird, +) + + +class TestGetLocalTimeMinutes: + """Tests for _get_local_time_minutes helper.""" + + def test_returns_int_within_day_range( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns an integer between 0 and 1439 (minutes in a day).""" + locker = create_locker(mock_tk, tmp_path) + result = locker._get_local_time_minutes() + assert isinstance(result, int) + assert 0 <= result < 24 * 60 + + +class TestIsEarlyBirdTime: + """Tests for _is_early_bird_time based on local clock.""" + + def _locker( + self, + mock_tk: MagicMock, + tmp_path: Path, + minutes: int, + ) -> ScreenLocker: + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_get_local_time_minutes", + MagicMock(return_value=minutes), + ) + return locker + + def test_within_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """6:00 AM (360 min) is within the early bird window.""" + locker = self._locker(mock_tk, tmp_path, 360) + assert locker._is_early_bird_time() is True + + def test_at_start_of_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """5:00 AM (300 min) is the inclusive start of the window.""" + locker = self._locker(mock_tk, tmp_path, 300) + assert locker._is_early_bird_time() is True + + def test_just_before_start( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """4:59 AM (299 min) is before the window.""" + locker = self._locker(mock_tk, tmp_path, 299) + assert locker._is_early_bird_time() is False + + def test_just_before_end( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """8:29 AM (509 min) is still within the window.""" + locker = self._locker(mock_tk, tmp_path, 509) + assert locker._is_early_bird_time() is True + + def test_at_end_of_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """8:30 AM (510 min) is the exclusive end — not in window.""" + locker = self._locker(mock_tk, tmp_path, 510) + assert locker._is_early_bird_time() is False + + def test_after_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """9:00 AM (540 min) is past the window.""" + locker = self._locker(mock_tk, tmp_path, 540) + assert locker._is_early_bird_time() is False + + def test_midnight( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Midnight (0 min) is outside the window.""" + locker = self._locker(mock_tk, tmp_path, 0) + assert locker._is_early_bird_time() is False + + +class TestIsEarlyBirdLog: + """Tests for _is_early_bird_log method.""" + + def test_no_log_file( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when log file does not exist.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + assert locker._is_early_bird_log() is False + + def test_invalid_json( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when log file contains invalid JSON.""" + log_file = tmp_path / "workout_log.json" + log_file.write_text("{bad json}") + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_early_bird_log() is False + + def test_os_error_on_open( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when opening the log file raises OSError.""" + locker = create_locker(mock_tk, tmp_path) + mock_file = MagicMock() + mock_file.exists.return_value = True + mock_file.open.side_effect = OSError("permission denied") + locker.log_file = mock_file + assert locker._is_early_bird_log() is False + + def test_no_entry_today( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when no entry exists for today.""" + log_file = tmp_path / "workout_log.json" + log_file.write_text(json.dumps({"2020-01-01": {}})) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_early_bird_log() is False + + def test_today_is_phone_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return False when today's entry is phone_verified.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps({today: {"workout_data": {"type": "phone_verified"}}}) + ) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_early_bird_log() is False + + def test_today_is_early_bird( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Return True when today's entry type is early_bird.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps({today: {"workout_data": {"type": "early_bird"}}}) + ) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + assert locker._is_early_bird_log() is True + + +class TestSaveEarlyBirdLog: + """Tests for _save_early_bird_log method.""" + + def test_saves_early_bird_entry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Saves an entry with type early_bird to the log file.""" + log_file = tmp_path / "workout_log.json" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + with patch( + "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + return_value=None, + ): + locker._save_early_bird_log() + + assert log_file.exists() + with log_file.open() as f: + data: dict[str, Any] = json.load(f) + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + assert data[today]["workout_data"]["type"] == "early_bird" + + +class TestTryAutoUpgradeEarlyBird: + """Tests for _try_auto_upgrade_early_bird method.""" + + def test_upgrade_succeeds_when_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns True, saves phone_verified entry, adjusts shutdown.""" + log_file = tmp_path / "workout_log.json" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(return_value=("verified", "Workout verified! (67 min)")), + ) + object.__setattr__( + locker, + "_adjust_shutdown_time_later", + MagicMock(return_value=True), + ) + with patch( + "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + return_value=None, + ): + result = locker._try_auto_upgrade_early_bird() + + assert result is True + with log_file.open() as f: + data: dict[str, Any] = json.load(f) + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + assert data[today]["workout_data"]["type"] == "phone_verified" + assert data[today]["workout_data"]["after_early_bird"] == "true" + + def test_upgrade_fails_when_not_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when phone shows no workout.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(return_value=("no_phone", "No phone connected")), + ) + assert locker._try_auto_upgrade_early_bird() is False + + def test_upgrade_fails_on_os_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when _verify_phone_workout raises OSError.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(side_effect=OSError("adb fail")), + ) + assert locker._try_auto_upgrade_early_bird() is False + + def test_upgrade_fails_on_runtime_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when _verify_phone_workout raises RuntimeError.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(side_effect=RuntimeError("unexpected")), + ) + assert locker._try_auto_upgrade_early_bird() is False + + +class TestHasLoggedTodayEarlyBird: + """Tests that has_logged_today returns False for early_bird entries.""" + + def test_early_bird_entry_not_counted_as_logged( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """early_bird entries must not satisfy has_logged_today.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps({today: {"workout_data": {"type": "early_bird"}}}) + ) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + with patch( + "python_pkg.screen_locker.screen_lock.verify_entry_hmac", + return_value=True, + ): + assert locker.has_logged_today() is False + + +class TestInitEarlyBirdFlow: + """Integration tests for early bird branches in __init__.""" + + def test_init_saves_log_and_exits_during_early_bird_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """First login during 5-8:30 window: save early_bird log, exit.""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object(Path, "resolve", return_value=tmp_path), + patch.object(ScreenLocker, "has_logged_today", return_value=False), + patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_time", return_value=True), + patch.object( + ScreenLocker, + "_try_auto_upgrade_early_bird", + return_value=False, + ), + patch.object(ScreenLocker, "_save_early_bird_log") as mock_save, + patch.object(ScreenLocker, "_start_phone_check"), + patch.object(ScreenLocker, "_start_verify_workout_check"), + patch( + "python_pkg.screen_locker.screen_lock.has_workout_skip_today", + return_value=False, + ), + pytest.raises(SystemExit), + ): + ScreenLocker(demo_mode=True) + + mock_save.assert_called_once() + mock_sys_exit.assert_called_with(0) + + def test_init_exits_when_early_bird_log_still_in_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Early bird log exists and window still active: skip lock, exit.""" + mock_sys_exit.side_effect = SystemExit(0) + + with pytest.raises(SystemExit): + create_locker_early_bird(mock_tk, tmp_path, state="log_active") + + mock_sys_exit.assert_called_with(0) + + def test_init_exits_when_early_bird_log_upgrades_successfully( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Early bird log + past 8:30 + workout done: upgrade, exit.""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object(Path, "resolve", return_value=tmp_path), + patch.object(ScreenLocker, "has_logged_today", return_value=False), + patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_log", return_value=True), + patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), + patch.object( + ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True + ), + patch.object(ScreenLocker, "_start_phone_check"), + patch.object(ScreenLocker, "_start_verify_workout_check"), + pytest.raises(SystemExit), + ): + ScreenLocker(demo_mode=True) + + mock_sys_exit.assert_called_with(0) + + def test_init_shows_lock_when_early_bird_log_no_workout( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Early bird log + past 8:30 + no workout: show lock, no early exit.""" + locker = create_locker_early_bird(mock_tk, tmp_path, state="log_expired") + + # _try_auto_upgrade_early_bird returns False (default in create_locker) + # so __init__ falls through to show the lock without calling sys.exit + mock_sys_exit.assert_not_called() + assert locker.demo_mode is True diff --git a/python_pkg/screen_locker/tests/test_init_and_log.py b/python_pkg/screen_locker/tests/test_init_and_log.py index c08e8bb..42a71ba 100644 --- a/python_pkg/screen_locker/tests/test_init_and_log.py +++ b/python_pkg/screen_locker/tests/test_init_and_log.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest +from python_pkg.screen_locker.screen_lock import ScreenLocker, _assert_not_under_pytest from python_pkg.screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -154,59 +154,29 @@ class TestHasLoggedToday: ): assert locker.has_logged_today() is False - def test_today_unsigned_entry_no_hmac_key( + def test_today_logged_without_hmac_uses_legacy_fallback( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Accept unsigned entry when HMAC key is unavailable.""" + """Unsigned legacy entries still count as logged workouts.""" log_file = tmp_path / "workout_log.json" today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") log_file.write_text( - json.dumps({today: {"workout": "data"}}), + json.dumps( + { + today: { + "timestamp": "2026-05-01T14:46:32.206951+00:00", + "workout_data": {"type": "phone_verified"}, + } + } + ), ) locker = create_locker(mock_tk, tmp_path) locker.log_file = log_file - with ( - patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", - return_value=False, - ), - patch( - "python_pkg.screen_locker.screen_lock._load_hmac_key", - return_value=None, - ), - ): - assert locker.has_logged_today() is True - - def test_today_unsigned_entry_with_hmac_key( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Reject unsigned entry when HMAC key IS available.""" - log_file = tmp_path / "workout_log.json" - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - log_file.write_text( - json.dumps({today: {"workout": "data"}}), - ) - - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - with ( - patch( - "python_pkg.screen_locker.screen_lock.verify_entry_hmac", - return_value=False, - ), - patch( - "python_pkg.screen_locker.screen_lock._load_hmac_key", - return_value=b"secret-key", - ), - ): - assert locker.has_logged_today() is False + assert locker.has_logged_today() is True def test_other_day_logged( self, @@ -359,6 +329,120 @@ class TestRun: locker.root.mainloop.assert_called_once() +class TestAutoUpgradeSickDay: + """Tests for silent sick_day → phone_verified upgrade at startup.""" + + def test_upgrade_succeeds_when_phone_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Verified phone workout overwrites today's sick_day entry.""" + log_file = tmp_path / "workout_log.json" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + with ( + patch.object( + locker, + "_verify_phone_workout", + return_value=("verified", "Workout verified! (1 session)"), + ), + patch.object( + locker, + "_adjust_shutdown_time_later", + return_value=True, + ) as mock_adjust, + patch( + "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + return_value="sig", + ), + ): + assert locker._try_auto_upgrade_sick_day() is True + mock_adjust.assert_called_once() + + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + with log_file.open() as f: + data: dict[str, Any] = json.load(f) + assert data[today]["workout_data"]["type"] == "phone_verified" + assert data[today]["workout_data"]["after_sick_day"] == "true" + + def test_upgrade_skipped_when_not_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Non-verified statuses leave the sick_day entry untouched.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, + "_verify_phone_workout", + return_value=("no_phone", "No phone connected"), + ): + assert locker._try_auto_upgrade_sick_day() is False + assert locker.workout_data == {} + + def test_upgrade_skipped_on_exception( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Transient OSError/RuntimeError during check is non-fatal.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, + "_verify_phone_workout", + side_effect=OSError("transient"), + ): + assert locker._try_auto_upgrade_sick_day() is False + + def test_init_exits_when_sick_day_upgrade_succeeds( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Startup exits 0 after a successful silent upgrade.""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object( + ScreenLocker, + "_try_auto_upgrade_sick_day", + return_value=True, + ) as mock_upgrade, + pytest.raises(SystemExit), + ): + create_locker(mock_tk, tmp_path, is_sick_day_log=True) + mock_upgrade.assert_called_once() + mock_sys_exit.assert_called_once_with(0) + + def test_init_falls_through_when_sick_day_upgrade_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Failed upgrade still honours existing sick_day log (exit via has_logged).""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object( + ScreenLocker, + "_try_auto_upgrade_sick_day", + return_value=False, + ), + pytest.raises(SystemExit), + ): + create_locker( + mock_tk, + tmp_path, + is_sick_day_log=True, + has_logged=True, + ) + mock_sys_exit.assert_called_once_with(0) + + class TestMainEntry: """Tests for main entry point.""" diff --git a/python_pkg/steam_backlog_enforcer/game_install.py b/python_pkg/steam_backlog_enforcer/game_install.py index c288dca..b0712b6 100644 --- a/python_pkg/steam_backlog_enforcer/game_install.py +++ b/python_pkg/steam_backlog_enforcer/game_install.py @@ -80,6 +80,7 @@ PROTECTED_APP_IDS = { # Games allowed to be installed anytime 3949040, # RV There Yet? 2252570, + 220200, } STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser()