feat(screen-locker): add early-bird workout checks and phone verification updates

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-01 19:07:34 +02:00
parent cd3a3e21cb
commit 721afe5f0e
9 changed files with 866 additions and 81 deletions

View File

@ -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")

View File

@ -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):

View File

@ -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 58:30 window
OnCalendar=*-*-* 08:30:00
Unit=workout-locker.service
Persistent=false
AccuracySec=1s
RandomizedDelaySec=0
[Install]
WantedBy=timers.target

View File

@ -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

View File

@ -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."""

View File

@ -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)

View File

@ -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()

View File

@ -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

View File

@ -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."""