mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 11:43:09 +02:00
feat(screen-locker): add early-bird workout checks and phone verification updates
This commit is contained in:
parent
cd3a3e21cb
commit
721afe5f0e
@ -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")
|
||||
|
||||
@ -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):
|
||||
|
||||
14
screen_locker/early-bird-workout-check.timer
Normal file
14
screen_locker/early-bird-workout-check.timer
Normal 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 5–8:30 window
|
||||
OnCalendar=*-*-* 08:30:00
|
||||
Unit=workout-locker.service
|
||||
Persistent=false
|
||||
AccuracySec=1s
|
||||
RandomizedDelaySec=0
|
||||
|
||||
[Install]
|
||||
WantedBy=timers.target
|
||||
@ -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
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
430
screen_locker/tests/test_early_bird.py
Normal file
430
screen_locker/tests/test_early_bird.py
Normal 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
|
||||
@ -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."""
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user