chore: remove screen_locker — extracted to own repo

Moved to https://github.com/kuhyx/screen-locker with full git history,
vendored dependencies (shared.log_integrity, wake_alarm constants +
has_workout_skip_today), 392 tests at 100% branch coverage.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-28 07:44:14 +02:00
parent acfb1c48a0
commit a29e9fb7bd
50 changed files with 31 additions and 10001 deletions

View File

@ -3,11 +3,12 @@
## Project Overview ## Project Overview
A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed
components span personal productivity tools: alarm/shutdown scheduling, screen locking, Linux system components span personal productivity tools: alarm/shutdown scheduling, Linux system configuration, and
configuration, and Android phone focus enforcement. Android phone focus enforcement.
Steam backlog enforcer has been extracted to its own repo: Extracted to their own repos:
[`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer). - [`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer)
- [`screen-locker`](https://github.com/kuhyx/screen-locker)
Archived / unmaintained projects live in the sibling repository Archived / unmaintained projects live in the sibling repository
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
@ -31,17 +32,6 @@ Archived / unmaintained projects live in the sibling repository
### Python Packages (`python_pkg/`) ### Python Packages (`python_pkg/`)
- **screen_locker/** — Tkinter/systemd screen locker with workout tracking and sick-day management
- `screen_lock.py` — main locker UI
- `_early_bird.py` — early-bird workout check
- `_sick_tracker.py` — sick-day tracker
- `_shutdown.py` — scheduled shutdown integration
- `_phone_verification.py` — phone check integration
- `_ui_flows.py` / `_window_setup.py` — UI helpers
- `_time_check.py` — time/schedule checks
- `_log_integrity.py` — tamper-evident workout logs
- `tests/` — 100% branch coverage enforced (300+ tests)
- **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control - **wake_alarm/** — Alarm + fan ramp + Tapo P110 smart plug control
- `_alarm.py` — alarm logic - `_alarm.py` — alarm logic
- `_smart_plug.py` — Tapo P110 control - `_smart_plug.py` — Tapo P110 control
@ -58,9 +48,6 @@ Archived / unmaintained projects live in the sibling repository
- `network_query.py` / `usb_query.py` — device discovery - `network_query.py` / `usb_query.py` — device discovery
- `tests/` — pytest tests - `tests/` — pytest tests
- **screen_locker** and **wake_alarm** share the `midnight_shutdown` integration:
on alarm nights the system hibernates instead of powering off.
- **shared/** — Shared utilities across python_pkg subpackages - **shared/** — Shared utilities across python_pkg subpackages
- **random_jpg/** — Random JPEG downloader utility - **random_jpg/** — Random JPEG downloader utility
- **geo_cache/** — Geographic coordinate cache helper - **geo_cache/** — Geographic coordinate cache helper
@ -148,7 +135,6 @@ before committing. The `ai-evidence-contract` hook will reject commits without i
python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100 python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100
# Run a single package # Run a single package
python -m pytest python_pkg/screen_locker/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100
python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100 python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100
python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100 python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100

View File

@ -0,0 +1,26 @@
{
"intent": "Remove screen_locker from monorepo after extracting it to its own GitHub repo.",
"scope": [
"python_pkg/screen_locker/ — deleted",
"CLAUDE.md — removed screen_locker sections, added link to new repo",
"No other packages affected"
],
"changes": [
"Deleted python_pkg/screen_locker/ (extracted to github.com/kuhyx/screen-locker with 43 commits)",
"Cross-package deps vendored: shared.log_integrity inlined, wake_alarm constants copied, has_workout_skip_today extracted to _wake_state.py",
"Updated CLAUDE.md: removed screen_locker section, removed its test command"
],
"verification": [
{
"command": "python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100 -q",
"result": "pass",
"evidence": "523 passed, 100% branch coverage across all remaining packages (wake_alarm, brother_printer, wake_alarm, shared)"
}
],
"risks": [
"screen_locker._wake_state reads wake_alarm's wake_state.json at a relative path — if wake_alarm moves, update WAKE_STATE_FILE in screen_locker/_constants.py"
],
"rollback": [
"Clone github.com/kuhyx/screen-locker, reverse the import rewrites (screen_locker.* → python_pkg.screen_locker.*), restore under python_pkg/"
]
}

View File

@ -1 +0,0 @@
"""Screen locker module."""

View File

@ -1,50 +0,0 @@
"""Constants for the screen locker module."""
from __future__ import annotations
from pathlib import Path
SICK_LOCKOUT_SECONDS = 120 # base 2 minutes wait when sick (escalates with usage)
PHONE_PENALTY_DELAY_DEMO = 10
PHONE_PENALTY_DELAY_PRODUCTION = 100
# Penalty added to phone-penalty timer when ADB / phone unavailable
# (so unplugging phone does not become an easy escape into sick mode).
NO_PHONE_EXTRA_LOCKOUT_SECONDS = 480 # extra 8 minutes on top of base
# Sick day rate-limiting (rolling windows). Once any window is exhausted
# the "I'm sick" button disappears entirely.
SICK_BUDGET_PER_7_DAYS = 1
SICK_BUDGET_PER_30_DAYS = 3
SICK_BUDGET_PER_90_DAYS = 10
# Each sick day in the trailing 30 days doubles the wait countdown.
SICK_LOCKOUT_MULTIPLIER_PER_RECENT = 2
# Minimum chars in the freeform sick justification.
SICK_JUSTIFICATION_MIN_CHARS = 120
# How many past sick justifications to show on the dialog (read-only).
SICK_HISTORY_REVIEW_COUNT = 10
# Forced read-only delay before SUBMIT enables when a commitment was made.
SICK_COMMITMENT_FORCED_READ_SECONDS = 5
# Breaking a commitment counts as this many sick budget days.
SICK_COMMITMENT_PENALTY_DAYS = 2
# How long the commitment prompt stays visible after a workout unlock.
COMMITMENT_PROMPT_TIMEOUT_SECONDS = 15
ADB_TIMEOUT = 15
STRONGLIFTS_DB_REMOTE = (
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
)
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")
# Helper script path (relative to this file)
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
# State file to track sick day usage and original config values
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
# Persistent sick-day history (rate-limit, debt, commitments, justifications).
# Distinct from SICK_DAY_STATE_FILE which is a one-day shutdown-config snapshot.
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"

View File

@ -1,72 +0,0 @@
"""Early bird window detection and log helpers for ScreenLocker."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
from python_pkg.screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR,
)
_logger = logging.getLogger(__name__)
class EarlyBirdMixin:
"""Mixin providing early-bird time window checks and log helpers."""
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."""
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."""
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

View File

@ -1,19 +0,0 @@
"""HMAC-based integrity checking — re-exports from shared package."""
from __future__ import annotations
from python_pkg.shared.log_integrity import (
HMAC_KEY_FILE,
_generate_hmac_key,
_load_hmac_key,
compute_entry_hmac,
verify_entry_hmac,
)
__all__ = [
"HMAC_KEY_FILE",
"_generate_hmac_key",
"_load_hmac_key",
"compute_entry_hmac",
"verify_entry_hmac",
]

View File

@ -1,355 +0,0 @@
"""Phone workout verification mixin using ADB and StrongLifts."""
from __future__ import annotations
from concurrent.futures import ( # pylint: disable=no-name-in-module
ThreadPoolExecutor,
as_completed,
)
import contextlib
import json
import logging
from pathlib import Path
import shutil
import socket
import sqlite3
import subprocess
import tempfile
import time
from python_pkg.screen_locker._constants import (
ADB_TIMEOUT,
MIN_WORKOUT_DURATION_MINUTES,
STRONGLIFTS_DB_REMOTE,
)
from python_pkg.screen_locker._time_check import check_clock_skew
_logger = logging.getLogger(__name__)
class PhoneVerificationMixin:
"""Mixin providing phone-based workout verification via ADB."""
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
"""Run an ADB command and return success flag and stdout."""
adb = shutil.which("adb") or "adb"
# When multiple devices are connected (e.g. USB + wireless), pin to
# the wireless device's serial to avoid "more than one device" errors.
_discovery_cmds = {"devices", "connect", "disconnect", "kill-server"}
serial = (
self._get_wireless_serial()
if args and args[0] not in _discovery_cmds
else None
)
serial_args = ["-s", serial] if serial else []
try:
result = subprocess.run(
[adb, *serial_args, *args],
capture_output=True,
text=True,
timeout=ADB_TIMEOUT,
check=False,
)
except (FileNotFoundError, OSError) as exc:
_logger.warning("ADB not available: %s", exc)
return False, ""
except subprocess.TimeoutExpired:
_logger.warning("ADB command timed out: %s", args)
return False, ""
return not result.returncode, result.stdout
def _adb_shell(
self,
command: str,
*,
root: bool = False,
) -> tuple[bool, str]:
"""Run a shell command on the connected Android device."""
if root:
return self._run_adb(["shell", "su", "-c", command])
return self._run_adb(["shell", command])
def _get_wireless_serial(self) -> str | None:
"""Return the serial (ip:port) of the first connected wireless ADB device.
Used to pin ADB commands to the wireless device when multiple devices
(e.g. USB cable + wireless debugging) are simultaneously connected.
"""
success, output = self._run_adb(["devices"])
if not success:
return None
for line in output.strip().split("\n")[1:]:
parts = line.split()
if parts and ":" in parts[0] and "device" in line and "offline" not in line:
return parts[0]
return None
def _has_adb_device(self) -> bool:
"""Return True if adb devices shows at least one connected device."""
success, output = self._run_adb(["devices"])
if not success:
return False
lines = output.strip().split("\n")[1:]
return any("device" in line and "offline" not in line for line in lines)
def _try_adb_connect(self, address: str) -> bool:
"""Run adb connect to address. Returns True on success."""
_, output = self._run_adb(["connect", address])
lower = output.lower()
return "connected" in lower and "unable" not in lower and "failed" not in lower
def _get_local_subnet_prefix(self) -> str | None:
"""Detect the local /24 network prefix (e.g. '192.168.1')."""
with (
contextlib.suppress(OSError),
socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock,
):
sock.connect(("8.8.8.8", 80))
return ".".join(sock.getsockname()[0].split(".")[:3])
return None
def _try_wireless_reconnect(self) -> bool:
"""Scan local /24 subnet on port 5555 and attempt ADB connect to phone."""
prefix = self._get_local_subnet_prefix()
if prefix is None:
_logger.info("Could not determine local subnet for wireless scan")
return False
def probe(i: int) -> bool:
ip = f"{prefix}.{i}"
with (
contextlib.suppress(OSError),
socket.create_connection((ip, 5555), timeout=0.5),
):
if self._try_adb_connect(f"{ip}:5555"):
return self._has_adb_device()
return False
_logger.info("Scanning %s.1-254:5555 for phone...", prefix)
with ThreadPoolExecutor(max_workers=64) as executor:
for future in as_completed(
executor.submit(probe, i) for i in range(1, 255)
):
if future.result():
return True
return False
def _is_phone_connected(self) -> bool:
"""Check if an Android device is connected via ADB.
If no device is visible, attempts wireless reconnection using the
stored phone IP/port config. USB-connected devices are detected
automatically by adb devices without any extra steps.
"""
if self._has_adb_device():
return True
_logger.info("No ADB device detected — attempting wireless reconnect...")
return self._try_wireless_reconnect()
def _pull_stronglifts_db(self) -> Path | None:
"""Pull StrongLifts database from phone to a local temp file.
Returns:
Path to the local copy, or None on failure.
"""
tmp = Path(tempfile.gettempdir()) / "stronglifts_check.db"
success, _ = self._adb_shell(
f"cat '{STRONGLIFTS_DB_REMOTE}' > /sdcard/_sl_tmp.db",
root=True,
)
if not success:
return None
ok, _ = self._run_adb(["pull", "/sdcard/_sl_tmp.db", str(tmp)])
if not ok:
return None
return tmp
def _count_today_workouts(self, db_path: Path) -> int:
"""Count today's workouts in a local copy of StrongLifts DB.
Args:
db_path: Path to the locally-pulled StrongLifts database.
Returns:
Number of workouts started today (local time).
"""
try:
conn = sqlite3.connect(str(db_path))
try:
cursor = conn.execute(
"SELECT COUNT(*) FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime')",
)
row = cursor.fetchone()
return int(row[0]) if row else 0
finally:
conn.close()
except (sqlite3.Error, ValueError, TypeError):
_logger.warning("Failed to query StrongLifts database")
return 0
def _get_today_workout_duration_minutes(self, db_path: Path) -> float:
"""Get the total duration in minutes of today's workouts.
Args:
db_path: Path to the locally-pulled StrongLifts database.
Returns:
Total duration in minutes of all workouts started today.
Returns 0.0 on any error or if no workouts found.
"""
try:
conn = sqlite3.connect(str(db_path))
try:
cursor = conn.execute(
"SELECT SUM((finish - start) / 1000.0 / 60.0) "
"FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime') "
"AND finish > start",
)
row = cursor.fetchone()
return float(row[0]) if row and row[0] is not None else 0.0
finally:
conn.close()
except (sqlite3.Error, ValueError, TypeError):
_logger.warning("Failed to query workout duration")
return 0.0
def _get_today_exercise_count(self, db_path: Path) -> int:
"""Count distinct exercises in today's workouts.
Parses the JSON ``exercises`` column in the ``workouts`` table.
Each workout row stores its exercises as a JSON array, not in a
separate relational table.
Args:
db_path: Path to the locally-pulled StrongLifts database.
Returns:
Number of distinct exercises across today's workouts.
Returns 0 on any error.
"""
try:
conn = sqlite3.connect(str(db_path))
try:
cursor = conn.execute(
"SELECT exercises FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime')",
)
exercise_ids: set[str] = set()
for (exercises_json,) in cursor:
if not exercises_json:
continue
for ex in json.loads(exercises_json):
ex_id = ex.get("id") or ex.get("name", "")
if ex_id:
exercise_ids.add(ex_id)
return len(exercise_ids)
finally:
conn.close()
except (sqlite3.Error, ValueError, TypeError, json.JSONDecodeError):
_logger.warning("Failed to query exercise count")
return 0
def _is_workout_finish_recent(self, db_path: Path) -> bool:
"""Check if the latest workout's finish time is recent.
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.
Args:
db_path: Path to the locally-pulled StrongLifts database.
Returns:
True if the latest finish time is within 24 hours of now.
"""
max_age_seconds = 24 * 3600 # accept same-day workouts
try:
conn = sqlite3.connect(str(db_path))
try:
cursor = conn.execute(
"SELECT MAX(finish) FROM workouts "
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
"= date('now', 'localtime') "
"AND finish > start",
)
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
finally:
conn.close()
except (sqlite3.Error, ValueError, TypeError):
_logger.warning("Failed to query workout finish time")
return False
def _validate_workout_db(
self,
local_db: Path,
) -> tuple[str, str] | None:
"""Validate workout database has a recent, real workout.
Returns:
A (status, message) tuple if validation fails, or None if OK.
"""
count = self._count_today_workouts(local_db)
if count <= 0:
return "not_verified", "No workout found on phone today"
if not self._is_workout_finish_recent(local_db):
return (
"stale",
"Workout finish time is too old. Did you actually work out today?",
)
exercise_count = self._get_today_exercise_count(local_db)
if exercise_count < 1:
return (
"no_exercises",
"No exercises found in today's workout. "
"Log actual exercises in StrongLifts!",
)
return None
def _verify_phone_workout(self) -> tuple[str, str]:
"""Verify workout was recorded in StrongLifts on the phone.
Returns:
Tuple of (status, message) where status is one of:
- "verified": Workout confirmed and >= minimum duration.
- "too_short": Workout found but shorter than minimum.
- "not_verified": Phone connected but no workout found.
- "no_phone": No phone connected via ADB.
- "error": Could not access StrongLifts database.
- "stale": Workout finish time is not recent.
- "no_exercises": Workout has no logged exercises.
- "clock_tampered": System clock skew exceeds threshold.
"""
clock_ok, clock_msg = check_clock_skew()
if not clock_ok:
return "clock_tampered", clock_msg
if not self._is_phone_connected():
return "no_phone", "No phone connected via ADB"
local_db = self._pull_stronglifts_db()
if local_db is None:
return "error", "StrongLifts database not found on phone"
db_error = self._validate_workout_db(local_db)
if db_error is not None:
return db_error
duration = self._get_today_workout_duration_minutes(local_db)
if duration < MIN_WORKOUT_DURATION_MINUTES:
return (
"too_short",
f"Workout too short! {duration:.0f} min logged, "
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
)
exercise_count = self._get_today_exercise_count(local_db)
return (
"verified",
f"Workout verified! ({self._count_today_workouts(local_db)}"
f" session(s), {duration:.0f} min, "
f"{exercise_count} exercise(s))",
)

View File

@ -1,340 +0,0 @@
"""Shutdown schedule adjustment mixin for the screen locker."""
from __future__ import annotations
import calendar
from datetime import datetime, timedelta, timezone
import json
import logging
import subprocess
from python_pkg.screen_locker._constants import (
ADJUST_SHUTDOWN_SCRIPT,
SHUTDOWN_CONFIG_FILE,
SICK_DAY_STATE_FILE,
)
from python_pkg.wake_alarm._constants import (
ALARM_DAYS,
RTCWAKE_BIN,
WAKE_AFTER_HOURS,
)
_logger = logging.getLogger(__name__)
class ShutdownMixin:
"""Mixin providing shutdown schedule adjustment functionality."""
def _apply_earlier_shutdown(self, today: str) -> bool:
"""Read config, save state, and write earlier shutdown hours."""
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
_logger.error("Failed to save state - aborting adjustment")
return False
new_mon_wed = max(18, mon_wed_hour - 1)
new_thu_sun = max(18, thu_sun_hour - 1)
return self._write_shutdown_config(
new_mon_wed,
new_thu_sun,
morning_end_hour,
)
def _adjust_shutdown_time_earlier(self) -> bool:
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
This can only be used once per day. Original values are saved and
automatically restored when checked the next day.
Returns True if successful, False otherwise.
"""
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
self._restore_original_config_if_needed()
if self._sick_mode_used_today():
_logger.warning("Sick mode already used today")
return False
try:
return self._apply_earlier_shutdown(today)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time: %s", e)
return False
def _adjust_shutdown_time_later(self) -> bool:
"""Adjust shutdown schedule 2 hours later as workout reward.
Returns True if successful, False otherwise.
"""
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
new_mon_wed = min(23, mon_wed_hour + 2)
new_thu_sun = min(23, thu_sun_hour + 2)
return self._write_shutdown_config(
new_mon_wed,
new_thu_sun,
morning_end_hour,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
def _sick_mode_used_today(self) -> bool:
"""Check if sick mode was already used today."""
if not SICK_DAY_STATE_FILE.exists():
return False
try:
with SICK_DAY_STATE_FILE.open() as f:
state = json.load(f)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
return state.get("date") == today
except (OSError, json.JSONDecodeError):
return False
def _save_sick_day_state(
self,
date: str,
orig_mon_wed: int,
orig_thu_sun: int,
) -> bool:
"""Save sick day state with original config values.
Returns True if saved successfully, False otherwise.
"""
state = {
"date": date,
"original_mon_wed_hour": orig_mon_wed,
"original_thu_sun_hour": orig_thu_sun,
}
try:
with SICK_DAY_STATE_FILE.open("w") as f:
json.dump(state, f, indent=2)
except OSError as e:
_logger.warning("Failed to save sick day state: %s", e)
return False
_logger.info("Saved sick day state for %s", date)
return True
def _load_sick_day_state(self) -> tuple[str, int, int] | None:
"""Load sick day state file.
Returns (date, orig_mon_wed_hour, orig_thu_sun_hour) or None.
"""
with SICK_DAY_STATE_FILE.open() as f:
state = json.load(f)
date = state.get("date")
orig_mw = state.get("original_mon_wed_hour")
orig_ts = state.get("original_thu_sun_hour")
if date is None or orig_mw is None or orig_ts is None:
return None
return (str(date), int(orig_mw), int(orig_ts))
def _write_restored_config(
self,
orig_mw: int,
orig_ts: int,
state_date: str,
) -> None:
"""Write restored config values and clean up state file."""
config_values = self._read_shutdown_config()
if config_values:
_, _, morning_end = config_values
_logger.info(
"Restoring original shutdown config from %s",
state_date,
)
self._write_shutdown_config(
orig_mw,
orig_ts,
morning_end,
restore=True,
)
SICK_DAY_STATE_FILE.unlink()
_logger.info("Removed stale sick day state from %s", state_date)
def _restore_original_config_if_needed(self) -> None:
"""Restore original config if sick day state is from a previous day."""
if not SICK_DAY_STATE_FILE.exists():
return
try:
loaded = self._load_sick_day_state()
if loaded is None:
return
state_date, orig_mw, orig_ts = loaded
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
if state_date != today:
self._write_restored_config(orig_mw, orig_ts, state_date)
except (OSError, json.JSONDecodeError) as e:
_logger.warning("Error checking sick day state: %s", e)
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
if not SHUTDOWN_CONFIG_FILE.exists():
_logger.warning("Config not found: %s", SHUTDOWN_CONFIG_FILE)
return None
parsed: dict[str, int] = {}
keys = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
with SHUTDOWN_CONFIG_FILE.open() as f:
for line in f:
stripped = line.strip()
for key in keys:
if stripped.startswith(f"{key}="):
parsed[key] = int(stripped.split("=")[1])
if len(parsed) < len(keys):
_logger.warning("Shutdown config missing required values")
return None
return (
parsed["MON_WED_HOUR"],
parsed["THU_SUN_HOUR"],
parsed["MORNING_END_HOUR"],
)
def _build_shutdown_cmd(
self,
mon_wed: int,
thu_sun: int,
morning: int,
*,
restore: bool,
) -> list[str]:
"""Build the shutdown adjustment command."""
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
if restore:
cmd.append("--restore")
cmd.extend([str(mon_wed), str(thu_sun), str(morning)])
return cmd
def _write_shutdown_config(
self,
mon_wed_hour: int,
thu_sun_hour: int,
morning_end_hour: int,
*,
restore: bool = False,
) -> bool:
"""Write new shutdown config values using helper script.
Args:
mon_wed_hour: Shutdown hour for Monday-Wednesday.
thu_sun_hour: Shutdown hour for Thursday-Sunday.
morning_end_hour: Morning end hour.
restore: If True, allows restoring to later times.
Returns True if successful, False otherwise.
"""
if not ADJUST_SHUTDOWN_SCRIPT.exists():
_logger.warning(
"Script not found: %s",
ADJUST_SHUTDOWN_SCRIPT,
)
return False
cmd = self._build_shutdown_cmd(
mon_wed_hour,
thu_sun_hour,
morning_end_hour,
restore=restore,
)
return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour)
def _run_shutdown_cmd(
self,
cmd: list[str],
mon_wed_hour: int,
thu_sun_hour: int,
) -> bool:
"""Execute the shutdown adjustment command."""
try:
result = subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
except subprocess.SubprocessError as e:
_logger.warning("Failed to adjust shutdown config: %s", e)
return False
_logger.info(
"Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s",
mon_wed_hour,
thu_sun_hour,
result.stdout.strip(),
)
return True
# ------------------------------------------------------------------
# rtcwake integration for weekend wake alarm
# ------------------------------------------------------------------
@staticmethod
def _is_tomorrow_alarm_day() -> bool:
"""Check if tomorrow is an alarm day."""
tomorrow = datetime.now(tz=timezone.utc) + timedelta(days=1)
return tomorrow.weekday() in ALARM_DAYS
@staticmethod
def _compute_wake_timestamp() -> int:
"""Compute the UTC epoch timestamp for the next wake alarm.
Returns:
Epoch seconds WAKE_AFTER_HOURS from now.
"""
wake_time = datetime.now(tz=timezone.utc) + timedelta(
hours=WAKE_AFTER_HOURS,
)
return calendar.timegm(wake_time.utctimetuple())
@staticmethod
def _schedule_rtcwake() -> bool:
"""Set rtcwake to power on the PC after WAKE_AFTER_HOURS.
Uses ``rtcwake -m disk`` to hibernate immediately while programming
the RTC to restore power at wake_epoch. Hibernate is completely
silent and dark (state written to swap file), making it suitable
when the PC is in a bedroom.
Returns:
True if rtcwake was set successfully, False otherwise.
"""
wake_epoch = ShutdownMixin._compute_wake_timestamp()
cmd = [
"/usr/bin/sudo",
RTCWAKE_BIN,
"-m",
"disk",
"-t",
str(wake_epoch),
]
try:
subprocess.run(
cmd,
check=True,
capture_output=True,
text=True,
)
except subprocess.SubprocessError as exc:
_logger.warning("Failed to set rtcwake: %s", exc)
return False
_logger.info(
"rtcwake set: PC will wake at epoch %d",
wake_epoch,
)
return True
def schedule_wake_if_needed(self) -> bool:
"""Schedule rtcwake if tomorrow is an alarm day.
Call this at shutdown time.
Returns:
True if wake was scheduled, False if not needed or failed.
"""
if not self._is_tomorrow_alarm_day():
_logger.info("Tomorrow is not an alarm day — skipping rtcwake")
return False
return self._schedule_rtcwake()

View File

@ -1,292 +0,0 @@
"""Sick-day justification + commitment dialog mixin for the screen locker."""
from __future__ import annotations
import contextlib
import logging
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.screen_locker import _sick_tracker
from python_pkg.screen_locker._constants import (
COMMITMENT_PROMPT_TIMEOUT_SECONDS,
SICK_COMMITMENT_FORCED_READ_SECONDS,
SICK_JUSTIFICATION_MIN_CHARS,
)
if TYPE_CHECKING:
from collections.abc import Callable
from python_pkg.screen_locker._sick_tracker import SickHistory
_logger = logging.getLogger(__name__)
def _disable_paste(widget: tk.Widget) -> None:
"""Disable paste in a Tk Entry/Text widget.
Friction-only: a determined user can still bypass via xdotool, but the
point is removing the trivial Ctrl+V shortcut so the user must
actually type their justification.
"""
for sequence in ("<<Paste>>", "<Control-v>", "<Control-V>", "<Button-2>"):
with contextlib.suppress(tk.TclError, AttributeError):
widget.bind(sequence, lambda _e: "break")
class SickDialogMixin:
"""Renders the sick-day justification screen and commitment prompts."""
# ------------------------------------------------------------------
# Sick-day justification dialog
# ------------------------------------------------------------------
def _show_sick_justification(self) -> None:
"""Render the structured sick-day justification screen."""
history = _sick_tracker.load_history()
self._sick_history_cache: SickHistory = history
self.clear_container()
self._label("Sick Day Request", color="#cc6600", pady=10)
self._text(_sick_tracker.budget_summary(history), color="#ffaa00")
recent = _sick_tracker.format_recent_justifications(history)
if recent:
self._text("Recent sick days:", font_size=14, color="#888888", pady=5)
self._text(recent, font_size=14, color="#cccccc", pady=5)
had_commitment = _sick_tracker.had_commitment_for_today(history)
if had_commitment:
self._text(
"⚠ Yesterday you committed to working out today.",
font_size=18,
color="#ff6666",
)
self._text(
"Breaking the commitment costs 2 sick-budget days.",
font_size=14,
color="#ff6666",
)
self._build_justification_form(had_commitment=had_commitment)
def _build_justification_form(self, *, had_commitment: bool) -> None:
"""Add justification form fields and submit button to the container."""
form = tk.Frame(self.container, bg="#1a1a1a")
form.pack(pady=10)
self._sick_symptom_var = tk.StringVar()
self._sick_onset_var = tk.StringVar()
self._sick_severity_var = tk.IntVar(value=5)
self._sick_text_widget = self._add_form_widgets(form)
self._sick_error_label = self._text("", color="#ff4444", pady=5)
button_row = self._button_row()
self._sick_submit_button = self._button(
button_row,
"SUBMIT",
bg="#666666",
command=self._submit_sick_justification,
width=12,
)
self._sick_submit_button.pack(side="left", padx=10)
self._button(
button_row,
"BACK",
bg="#aa0000",
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
if had_commitment:
self._sick_submit_button.config(state="disabled")
self._commitment_forced_remaining = SICK_COMMITMENT_FORCED_READ_SECONDS
self._update_commitment_forced_delay()
def _add_form_widgets(self, parent: tk.Widget) -> tk.Text:
"""Create symptom/onset/severity/text widgets. Returns the text widget."""
self._add_label_entry(
parent,
label="Symptom (e.g. fever, nausea):",
variable=self._sick_symptom_var,
)
self._add_label_entry(
parent,
label="When did it start? (e.g. last night):",
variable=self._sick_onset_var,
)
sev_row = tk.Frame(parent, bg="#1a1a1a")
sev_row.pack(pady=5)
tk.Label(
sev_row,
text="Severity (1-10):",
font=("Arial", 14),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=5)
tk.Spinbox(
sev_row,
from_=1,
to=10,
textvariable=self._sick_severity_var,
width=4,
font=("Arial", 14),
).pack(side="left", padx=5)
tk.Label(
parent,
text=(f"Describe how you feel (min {SICK_JUSTIFICATION_MIN_CHARS} chars):"),
font=("Arial", 14),
fg="white",
bg="#1a1a1a",
).pack(pady=5)
text_widget = tk.Text(
parent,
width=60,
height=6,
font=("Arial", 12),
bg="#2a2a2a",
fg="white",
insertbackground="white",
)
text_widget.pack(pady=5)
_disable_paste(text_widget)
return text_widget
def _add_label_entry(
self,
parent: tk.Widget,
*,
label: str,
variable: tk.StringVar,
) -> None:
"""Add a label + single-line entry pair, with paste disabled."""
row = tk.Frame(parent, bg="#1a1a1a")
row.pack(pady=5, fill="x")
tk.Label(
row,
text=label,
font=("Arial", 14),
fg="white",
bg="#1a1a1a",
anchor="w",
).pack(side="top", anchor="w")
entry = tk.Entry(
row,
textvariable=variable,
width=50,
font=("Arial", 14),
bg="#2a2a2a",
fg="white",
insertbackground="white",
)
entry.pack(side="top", anchor="w", pady=2)
_disable_paste(entry)
def _update_commitment_forced_delay(self) -> None:
"""Tick down the forced-read delay then enable the submit button."""
if self._commitment_forced_remaining > 0:
self._sick_submit_button.config(
text=f"WAIT {self._commitment_forced_remaining}s",
)
self._commitment_forced_remaining -= 1
self.root.after(1000, self._update_commitment_forced_delay)
else:
self._sick_submit_button.config(text="SUBMIT", state="normal")
def _submit_sick_justification(self) -> None:
"""Validate the form and either show an error or proceed to countdown."""
symptom = self._sick_symptom_var.get()
onset = self._sick_onset_var.get()
try:
severity = int(self._sick_severity_var.get())
except (tk.TclError, ValueError):
severity = 0
text = self._sick_text_widget.get("1.0", "end").strip()
draft = _sick_tracker.JustificationDraft(
symptom=symptom,
onset=onset,
severity=severity,
text=text,
)
error = _sick_tracker.validate_justification(draft)
if error is not None:
self._sick_error_label.config(text=error)
return
history = self._sick_history_cache
_sick_tracker.add_justification(history, draft)
if not _sick_tracker.save_history(history):
self._sick_error_label.config(
text="Could not persist sick history — try again",
)
return
self._proceed_to_sick_countdown()
# ------------------------------------------------------------------
# Commitment prompt (after a verified workout)
# ------------------------------------------------------------------
def _show_commitment_prompt(self, *, on_done: Callable[[], None]) -> None:
"""Ask the user to commit to working out tomorrow.
Calls ``on_done()`` once the user answers or the timeout elapses.
"""
self.clear_container()
self._label(
"Commit to working out tomorrow?",
font_size=32,
color="#ffaa00",
pady=20,
)
self._text(
"If you say YES and skip via 'I'm sick' tomorrow, "
"the sick day costs 2x normal.",
font_size=16,
)
self._commitment_done_fn = on_done
self._commitment_remaining = COMMITMENT_PROMPT_TIMEOUT_SECONDS
self._commitment_timer_label = self._text(
f"Auto-skipping in {COMMITMENT_PROMPT_TIMEOUT_SECONDS}s",
color="#888888",
)
row = self._button_row()
self._button(
row,
"YES",
bg="#00aa00",
command=lambda: self._answer_commitment(commit=True),
width=12,
).pack(side="left", padx=10)
self._button(
row,
"NO",
bg="#aa0000",
command=lambda: self._answer_commitment(commit=False),
width=12,
).pack(side="left", padx=10)
self._tick_commitment_timeout()
def _tick_commitment_timeout(self) -> None:
"""Advance commitment auto-skip timer; default to NO when it expires."""
if self._commitment_remaining <= 0:
self._answer_commitment(commit=False)
return
self._commitment_timer_label.config(
text=f"Auto-skipping in {self._commitment_remaining}s",
)
self._commitment_remaining -= 1
self.root.after(1000, self._tick_commitment_timeout)
def _answer_commitment(self, *, commit: bool) -> None:
"""Persist the commitment answer and call the completion callback."""
# Disable timer re-entry by zeroing remaining.
self._commitment_remaining = -1
if commit:
history = _sick_tracker.load_history()
_sick_tracker.record_commitment_for_tomorrow(history)
_sick_tracker.save_history(history)
done = getattr(self, "_commitment_done_fn", None)
if done is not None:
self._commitment_done_fn = None
done()

View File

@ -1,304 +0,0 @@
"""Sick-day rate-limiting, workout debt, commitment, and justification tracking.
Pure logic no Tk imports. The UI calls into these helpers and persists
state via :func:`save_history`.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import json
import logging
from typing import Any
from python_pkg.screen_locker._constants import (
SICK_BUDGET_PER_7_DAYS,
SICK_BUDGET_PER_30_DAYS,
SICK_BUDGET_PER_90_DAYS,
SICK_COMMITMENT_PENALTY_DAYS,
SICK_HISTORY_FILE,
SICK_HISTORY_REVIEW_COUNT,
SICK_JUSTIFICATION_MIN_CHARS,
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
SICK_LOCKOUT_SECONDS,
)
from python_pkg.shared.log_integrity import compute_entry_hmac
_logger = logging.getLogger(__name__)
@dataclass
class SickHistory:
"""Persistent sick-day bookkeeping."""
sick_days: list[str] = field(default_factory=list)
debt: int = 0
commitments: dict[str, bool] = field(default_factory=dict)
broken_commitments: list[str] = field(default_factory=list)
justifications: list[dict[str, Any]] = field(default_factory=list)
def _today_iso() -> str:
"""Return today's date as ``YYYY-MM-DD`` (UTC)."""
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
def _parse_iso(date_str: str) -> datetime | None:
"""Parse ``YYYY-MM-DD`` into a UTC datetime, or return None."""
try:
return datetime.strptime(date_str, "%Y-%m-%d").replace(tzinfo=timezone.utc)
except ValueError:
return None
def load_history() -> SickHistory:
"""Read the persistent sick-day history file.
Missing or unreadable files yield an empty :class:`SickHistory`.
"""
if not SICK_HISTORY_FILE.exists():
return SickHistory()
try:
with SICK_HISTORY_FILE.open() as f:
data = json.load(f)
except (OSError, json.JSONDecodeError):
_logger.warning("Could not read sick history; starting fresh")
return SickHistory()
return SickHistory(
sick_days=list(data.get("sick_days", [])),
debt=int(data.get("debt", 0)),
commitments=dict(data.get("commitments", {})),
broken_commitments=list(data.get("broken_commitments", [])),
justifications=list(data.get("justifications", [])),
)
def save_history(history: SickHistory) -> bool:
"""Persist ``history``. Returns True on success."""
payload = {
"sick_days": history.sick_days,
"debt": history.debt,
"commitments": history.commitments,
"broken_commitments": history.broken_commitments,
"justifications": history.justifications,
}
try:
with SICK_HISTORY_FILE.open("w") as f:
json.dump(payload, f, indent=2)
except OSError as exc:
_logger.warning("Failed to save sick history: %s", exc)
return False
return True
def count_in_window(
history: SickHistory,
days: int,
*,
today: str | None = None,
) -> int:
"""Return how many ``sick_days`` fall in the trailing ``days`` window."""
today_str = today or _today_iso()
today_dt = _parse_iso(today_str)
if today_dt is None:
return 0
cutoff = today_dt - timedelta(days=days)
count = 0
for entry in history.sick_days:
parsed = _parse_iso(entry)
if parsed is None:
continue
if cutoff < parsed <= today_dt:
count += 1
return count
def is_budget_exhausted(
history: SickHistory,
*,
today: str | None = None,
) -> bool:
"""Return True if any rolling window has reached its sick budget."""
return (
count_in_window(history, 7, today=today) >= SICK_BUDGET_PER_7_DAYS
or count_in_window(history, 30, today=today) >= SICK_BUDGET_PER_30_DAYS
or count_in_window(history, 90, today=today) >= SICK_BUDGET_PER_90_DAYS
)
def compute_lockout_seconds(
history: SickHistory,
*,
today: str | None = None,
) -> int:
"""Escalating sick countdown: ``base * 2 ** recent_count_in_30d``."""
recent = count_in_window(history, 30, today=today)
multiplier = SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent
return SICK_LOCKOUT_SECONDS * multiplier
def budget_summary(
history: SickHistory,
*,
today: str | None = None,
) -> str:
"""One-line UI summary string for budget + debt."""
week = count_in_window(history, 7, today=today)
month = count_in_window(history, 30, today=today)
quarter = count_in_window(history, 90, today=today)
return (
f"Sick: {week}/{SICK_BUDGET_PER_7_DAYS}w · "
f"{month}/{SICK_BUDGET_PER_30_DAYS}m · "
f"{quarter}/{SICK_BUDGET_PER_90_DAYS}q · "
f"Debt: {history.debt}"
)
def add_sick_day(history: SickHistory, *, today: str | None = None) -> int:
"""Append today's date and increment debt. Returns new debt.
If today appears in ``broken_commitments`` the debt grows by
:data:`SICK_COMMITMENT_PENALTY_DAYS` instead of 1.
"""
today_str = today or _today_iso()
if today_str not in history.sick_days:
history.sick_days.append(today_str)
increment = (
SICK_COMMITMENT_PENALTY_DAYS if today_str in history.broken_commitments else 1
)
history.debt += increment
return history.debt
def clear_one_debt(history: SickHistory) -> int:
"""Decrement debt by one (clamped at zero). Returns new debt."""
if history.debt > 0:
history.debt -= 1
return history.debt
def record_commitment_for_tomorrow(
history: SickHistory,
*,
today: str | None = None,
) -> str:
"""Record that the user committed to working out tomorrow.
Returns the ISO date for tomorrow.
"""
today_str = today or _today_iso()
today_dt = _parse_iso(today_str)
if today_dt is None:
return today_str
tomorrow = (today_dt + timedelta(days=1)).strftime("%Y-%m-%d")
history.commitments[tomorrow] = True
return tomorrow
def had_commitment_for_today(
history: SickHistory,
*,
today: str | None = None,
) -> bool:
"""Return True if a commitment exists for today."""
today_str = today or _today_iso()
return bool(history.commitments.get(today_str, False))
def mark_commitment_broken(
history: SickHistory,
*,
today: str | None = None,
) -> None:
"""Mark today's commitment as broken (idempotent)."""
today_str = today or _today_iso()
if today_str in history.commitments and today_str not in history.broken_commitments:
history.broken_commitments.append(today_str)
SICK_SEVERITY_MIN = 1
SICK_SEVERITY_MAX = 10
@dataclass
class JustificationDraft:
"""User-supplied justification fields for a sick-day request."""
symptom: str
onset: str
severity: int
text: str
def validate_justification(draft: JustificationDraft) -> str | None:
"""Return an error message if the justification is invalid, else None."""
if not draft.symptom.strip():
return "Symptom is required"
if not draft.onset.strip():
return "Onset time is required"
if not SICK_SEVERITY_MIN <= draft.severity <= SICK_SEVERITY_MAX:
return f"Severity must be between {SICK_SEVERITY_MIN} and {SICK_SEVERITY_MAX}"
if len(draft.text.strip()) < SICK_JUSTIFICATION_MIN_CHARS:
return (
f"Description must be at least "
f"{SICK_JUSTIFICATION_MIN_CHARS} characters "
f"(currently {len(draft.text.strip())})"
)
return None
def add_justification(
history: SickHistory,
draft: JustificationDraft,
*,
today: str | None = None,
) -> dict[str, Any]:
"""HMAC-sign and append a sick-day justification.
Returns the stored entry (with ``hmac`` field if a key was available).
"""
today_str = today or _today_iso()
entry: dict[str, Any] = {
"date": today_str,
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"symptom": draft.symptom.strip(),
"onset": draft.onset.strip(),
"severity": int(draft.severity),
"text": draft.text.strip(),
}
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
history.justifications.append(entry)
return entry
def recent_justifications(
history: SickHistory,
n: int = SICK_HISTORY_REVIEW_COUNT,
) -> list[dict[str, Any]]:
"""Return the last ``n`` justifications (oldest first)."""
if n <= 0:
return []
return list(history.justifications[-n:])
def format_recent_justifications(
history: SickHistory,
n: int = SICK_HISTORY_REVIEW_COUNT,
) -> str:
"""Human-readable multi-line summary of recent justifications.
Empty string when there are no past entries.
"""
entries = recent_justifications(history, n)
if not entries:
return ""
lines: list[str] = []
for entry in entries:
date_str = entry.get("date", "?")
symptom = entry.get("symptom", "?")
severity = entry.get("severity", "?")
lines.append(f"{date_str} sev {severity}/10 — {symptom}")
return "\n".join(lines)

View File

@ -1,79 +0,0 @@
"""System clock skew detection via NTP."""
from __future__ import annotations
import logging
import socket
import struct
import time
from python_pkg.screen_locker._constants import MAX_CLOCK_SKEW_SECONDS
_logger = logging.getLogger(__name__)
_NTP_EPOCH_OFFSET = 2208988800 # Seconds between 1900-01-01 and 1970-01-01
_NTP_PORT = 123
_NTP_TIMEOUT = 5
_NTP_MIN_PACKET_SIZE = 48
def _query_ntp_offset(server: str = "pool.ntp.org") -> float | None:
"""Query an NTP server and return the clock offset in seconds.
Uses a minimal SNTP (RFC 4330) client-mode request.
Returns:
Offset in seconds (positive = local clock is ahead), or None on error.
"""
# NTP v3, mode 3 (client), transmit timestamp at bytes 40-47
packet = b"\x1b" + b"\0" * 47
try:
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
sock.settimeout(_NTP_TIMEOUT)
t1 = time.time()
sock.sendto(packet, (server, _NTP_PORT))
data, _ = sock.recvfrom(1024)
t4 = time.time()
except OSError as exc:
_logger.info("NTP query to %s failed: %s", server, exc)
return None
if len(data) < _NTP_MIN_PACKET_SIZE:
return None
# Transmit timestamp from server (bytes 40-47)
tx_seconds = struct.unpack("!I", data[40:44])[0] - _NTP_EPOCH_OFFSET
tx_fraction = struct.unpack("!I", data[44:48])[0] / (2**32)
server_time = tx_seconds + tx_fraction
# Simplified offset: server_time should be close to (t1 + t4) / 2
local_mid = (t1 + t4) / 2
return server_time - local_mid
def check_clock_skew() -> tuple[bool, str]:
"""Check if system clock is within acceptable skew of NTP time.
Returns:
Tuple of (ok, message).
ok is True if clock is within MAX_CLOCK_SKEW_SECONDS or NTP is unreachable.
When NTP is unreachable, we allow through (fail-open for network issues).
"""
offset = _query_ntp_offset()
if offset is None:
_logger.info("NTP unreachable — allowing through")
return True, "NTP check skipped (server unreachable)"
abs_offset = abs(offset)
if abs_offset > MAX_CLOCK_SKEW_SECONDS:
direction = "ahead" if offset < 0 else "behind"
_logger.warning(
"Clock skew detected: %.0f seconds %s",
abs_offset,
direction,
)
return False, (
f"System clock is {abs_offset:.0f}s {direction} of NTP time. "
f"Max allowed skew: {MAX_CLOCK_SKEW_SECONDS}s."
)
return True, f"Clock OK (offset: {offset:+.1f}s)"

View File

@ -1,452 +0,0 @@
"""UI flow methods mixin for the screen locker."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
from typing import TYPE_CHECKING
from python_pkg.screen_locker import _sick_tracker
from python_pkg.screen_locker._constants import (
NO_PHONE_EXTRA_LOCKOUT_SECONDS,
PHONE_PENALTY_DELAY_DEMO,
PHONE_PENALTY_DELAY_PRODUCTION,
)
from python_pkg.screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
)
if TYPE_CHECKING:
from collections.abc import Callable
class UIFlowsMixin:
"""Mixin providing UI flow logic for the screen locker."""
def _start_phone_check(self) -> None:
"""Check phone for today's workout immediately at startup."""
self.clear_container()
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
self._poll_phone_check()
def _poll_phone_check(self) -> None:
"""Poll background phone check and route to result handler when done."""
if self._phone_future is not None and self._phone_future.done():
status, message = self._phone_future.result()
self._handle_startup_phone_result(status, message)
else:
self.root.after(500, self._poll_phone_check)
def _show_retry_and_sick(self, message: str) -> None:
"""Show TRY AGAIN and (if budget allows) I'm sick after a failed check."""
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(message, color="#ffaa00")
history = _sick_tracker.load_history()
self._text(_sick_tracker.budget_summary(history), color="#888888")
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
command=self._start_phone_check,
width=12,
).pack(side="left", padx=10)
if _sick_tracker.is_budget_exhausted(history):
self._text(
"Sick budget exhausted. No 'I'm sick' option available.",
color="#ff6666",
)
else:
self._button(
frame,
"I'm sick",
bg="#cc6600",
command=self.ask_if_sick,
width=12,
).pack(side="left", padx=10)
def _handle_startup_phone_result(self, status: str, message: str) -> None:
"""Route to appropriate screen based on startup phone check result."""
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.clear_container()
self._label("✓ Workout Verified!", font_size=42, color="#00cc44", pady=30)
self._text(message, font_size=20, color="#aaffaa")
self._text("Unlocking...", font_size=18, color="#888888")
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
elif status == "too_short":
self._show_retry_and_sick(
f"{message}\n\n"
"Your workout was too short!\n"
"Actually do the full workout, don't just\n"
"spam through the exercises.",
)
elif status in ("stale", "no_exercises"):
self._show_retry_and_sick(
f"{message}\n\nReason: {status}",
)
elif status == "clock_tampered":
self._show_retry_and_sick(
f"{message}\n\n"
"System clock appears to be manipulated.\n"
"Fix your system time and try again.",
)
elif status == "not_verified":
self._show_retry_and_sick(
f"{message}\n\n"
"StrongLifts shows no workout today.\n"
"Go do your workout first!",
)
else:
# no_phone or error — penalty timer, then retry+sick screen
self._show_phone_penalty(message)
def ask_if_sick(self) -> None:
"""Display the structured sick-day justification dialog."""
self._show_sick_justification()
def _get_sick_day_status(self) -> tuple[str, str]:
"""Determine sick day status text and color."""
if self._sick_mode_used_today():
return "Shutdown time already adjusted today", "#ffaa00"
if self._adjust_shutdown_time_earlier():
return (
"Shutdown time moved 1.5 hours earlier ✓\n(Will revert tomorrow)"
), "#00aa00"
return "Could not adjust shutdown time (check permissions)", "#ff4444"
def _proceed_to_sick_countdown(self) -> None:
"""Start the (escalated) sick day countdown after justification."""
history = getattr(
self,
"_sick_history_cache",
None,
)
if history is None:
history = _sick_tracker.load_history()
self._sick_history_cache = history
countdown = _sick_tracker.compute_lockout_seconds(history)
self.clear_container()
status_text, status_color = self._get_sick_day_status()
self._show_sick_day_ui(status_text, status_color, countdown)
self.sick_remaining_time = countdown
self._update_sick_countdown()
def _show_sick_day_ui(
self,
status_text: str,
status_color: str,
countdown: int,
) -> None:
"""Display sick day UI labels and countdown."""
self._label("Sick Day Mode", color="#cc6600", pady=20)
self._text(status_text, color=status_color)
minutes = countdown // 60
self._text(
f"Please wait ~{minutes} min before unlocking...",
font_size=24,
pady=20,
)
self.sick_countdown_label = self._label(
str(countdown),
font_size=80,
pady=30,
)
def _update_sick_countdown(self) -> None:
"""Update the sick day countdown timer."""
if self.sick_remaining_time > 0:
self.sick_countdown_label.config(text=str(self.sick_remaining_time))
self.sick_remaining_time -= 1
self.root.after(1000, self._update_sick_countdown)
else:
self._finalize_sick_day()
def _finalize_sick_day(self) -> None:
"""Persist sick-day history and unlock the screen."""
history = getattr(self, "_sick_history_cache", None)
if history is None:
history = _sick_tracker.load_history()
if _sick_tracker.had_commitment_for_today(history):
_sick_tracker.mark_commitment_broken(history)
self.workout_data["broke_commitment"] = "true"
new_debt = _sick_tracker.add_sick_day(history)
_sick_tracker.save_history(history)
self.workout_data["type"] = "sick_day"
self.workout_data["note"] = "Sick day - shutdown moved earlier"
self.workout_data["debt"] = str(new_debt)
self.unlock_screen()
# ------------------------------------------------------------------
# Lockout flow
# ------------------------------------------------------------------
def lockout(self) -> None:
"""Display lockout screen with countdown timer."""
self.clear_container()
self.lockout_label = self._label(
f"Go work out!\nLocked for {self.lockout_time} seconds",
font_size=48,
color="#ff4444",
pady=30,
)
self.countdown_label = self._label(
str(self.lockout_time),
font_size=120,
pady=30,
)
self.remaining_time = self.lockout_time
self.update_lockout_countdown()
def update_lockout_countdown(self) -> None:
"""Update the lockout countdown timer display."""
if self.remaining_time > 0:
self.countdown_label.config(text=str(self.remaining_time))
self.remaining_time -= 1
self.root.after(1000, self.update_lockout_countdown)
else:
self._start_phone_check()
# ------------------------------------------------------------------
# Phone penalty
# ------------------------------------------------------------------
def _show_phone_penalty(
self, message: str, *, on_done: Callable[[], None] | None = None
) -> None:
"""Show penalty countdown when phone verification is unavailable."""
self.clear_container()
self._phone_penalty_done_fn: Callable[[], None] = (
on_done
if on_done is not None
else lambda: self._show_retry_and_sick(message)
)
base_delay = (
PHONE_PENALTY_DELAY_DEMO
if self.demo_mode
else PHONE_PENALTY_DELAY_PRODUCTION
)
# Disconnecting the phone shouldn't be a fast path into sick mode.
delay = (
base_delay
if self.demo_mode
else base_delay + NO_PHONE_EXTRA_LOCKOUT_SECONDS
)
self._label(
"Cannot Verify Workout",
font_size=36,
color="#ff8800",
pady=20,
)
self._text(message, color="#ffaa00")
self._text(
"Connect phone via ADB to skip this wait,\n"
"or wait for the penalty timer.\n\n"
"Note: Phone must be rooted and StrongLifts installed.",
font_size=18,
)
self.phone_penalty_remaining = delay
self.phone_penalty_label = self._label(
str(delay),
font_size=80,
pady=20,
)
self._update_phone_penalty()
def _update_phone_penalty(self) -> None:
"""Update phone penalty countdown."""
if self.phone_penalty_remaining > 0:
self.phone_penalty_label.config(
text=str(self.phone_penalty_remaining),
)
self.phone_penalty_remaining -= 1
self.root.after(1000, self._update_phone_penalty)
else:
self._phone_penalty_done_fn()
# ------------------------------------------------------------------
# Verify-workout flow (post-sick-day)
# ------------------------------------------------------------------
def _start_verify_workout_check(self) -> None:
"""Start phone check for post-sick-day workout verification."""
self.clear_container()
self._label(
"Verifying Workout",
font_size=36,
color="#ffaa00",
pady=30,
)
self._text(
"Checking phone for today's workout...",
font_size=18,
)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
self._poll_verify_workout_check()
def _poll_verify_workout_check(self) -> None:
"""Poll background phone check for verify-workout mode."""
if self._phone_future is not None and self._phone_future.done():
status, message = self._phone_future.result()
self._handle_verify_workout_result(status, message)
else:
self.root.after(500, self._poll_verify_workout_check)
def _handle_verify_workout_result(
self,
status: str,
message: str,
) -> None:
"""Route phone check result in verify-workout mode."""
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.workout_data["after_sick_day"] = "true"
adjusted = self._adjust_shutdown_time_later()
self.save_workout_log()
self.clear_container()
self._label(
"✓ Workout Verified!",
font_size=42,
color="#00cc44",
pady=30,
)
self._text(message, font_size=20, color="#aaffaa")
if adjusted:
self._text(
"Shutdown time moved later!",
font_size=20,
color="#ffaa00",
)
self.root.after(2000, self.close)
else:
self._show_verify_retry(message)
def _show_verify_retry(self, message: str) -> None:
"""Show retry/close buttons when workout not found in verify mode."""
self.clear_container()
self._label(
"Workout Not Found",
font_size=36,
color="#ff4444",
pady=20,
)
self._text(message, color="#ffaa00")
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
command=self._start_verify_workout_check,
width=12,
).pack(side="left", padx=10)
self._button(
frame,
"Close",
bg="#aa0000",
command=self.close,
width=12,
).pack(side="left", padx=10)
# ------------------------------------------------------------------
# Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping)
# ------------------------------------------------------------------
def _start_relaxed_day_flow(self) -> None:
"""Show optional workout prompt for relaxed days (Tue-Thu).
The screen is not locked the user can skip freely or voluntarily
import a Stronglift workout that counts toward the weekly minimum.
"""
count = count_weekly_workouts(self.log_file)
self.clear_container()
self._label(
"Optional Day (Tue / Wed / Thu)",
font_size=30,
color="#ffaa00",
pady=20,
)
self._text(
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
"No penalty for skipping today.",
font_size=20,
color="#aaaaaa",
pady=10,
)
frame = self._button_row()
self._button(
frame,
"Skip — No Penalty",
bg="#006600",
command=self.close,
width=18,
).pack(side="left", padx=10)
self._button(
frame,
"Log Stronglift Workout",
bg="#0066cc",
command=self._start_relaxed_phone_check,
width=20,
).pack(side="left", padx=10)
def _start_relaxed_phone_check(self) -> None:
"""Run Stronglift check in relaxed mode (no screen grab, no sick option)."""
self.clear_container()
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
self._poll_relaxed_phone_check()
def _poll_relaxed_phone_check(self) -> None:
"""Poll background phone check in relaxed-day mode."""
if self._phone_future is not None and self._phone_future.done():
status, message = self._phone_future.result()
self._handle_relaxed_phone_result(status, message)
else:
self.root.after(500, self._poll_relaxed_phone_check)
def _handle_relaxed_phone_result(self, status: str, message: str) -> None:
"""Route phone check result in relaxed-day mode.
On success saves the workout (counts toward weekly total) then closes.
On failure shows retry and close no sick option since skipping is free.
"""
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
else:
self._show_relaxed_retry(message, status)
def _show_relaxed_retry(self, message: str, status: str) -> None:
"""Show retry and skip-close when workout not found in relaxed mode."""
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(f"{message}\n\nReason: {status}", color="#ffaa00")
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
command=self._start_relaxed_phone_check,
width=12,
).pack(side="left", padx=10)
self._button(
frame,
"Close (Skip)",
bg="#006600",
command=self.close,
width=14,
).pack(side="left", padx=10)

View File

@ -1,108 +0,0 @@
"""Weekly workout count and day-of-week mode detection for the screen locker.
On Tue/Wed/Thu (relaxed days) the lock is optional: the user can skip
without any penalty, or voluntarily import a Stronglift workout which
will count toward the weekly minimum.
On Fri/Sat/Sun/Mon (enforced days) the lock fires unless the user has
already logged at least WEEKLY_WORKOUT_MINIMUM verified workouts in the
current ISO week (Mon-Sun).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
WEEKLY_WORKOUT_MINIMUM: int = 4
# Python weekday(): Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
_RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
# Only phone-verified workouts count toward the weekly minimum.
_COUNTED_WORKOUT_TYPES: frozenset[str] = frozenset({"phone_verified"})
def is_relaxed_day(*, today: datetime | None = None) -> bool:
"""Return True if today is a relaxed day (Tue, Wed, or Thu).
Args:
today: Override for the current local datetime (for testing).
Returns:
True when the current weekday is Tuesday, Wednesday, or Thursday.
"""
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
return dt.weekday() in _RELAXED_WEEKDAYS
def count_weekly_workouts(
log_file: Path,
*,
today: datetime | None = None,
) -> int:
"""Count phone-verified workouts logged in the current ISO week (Mon-Sun).
Args:
log_file: Path to ``workout_log.json``.
today: Override for the current local datetime (for testing).
Returns:
Number of ``phone_verified`` entries whose date falls within the
current ISO week, up to and including today.
"""
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
week_start = (dt - timedelta(days=dt.weekday())).date()
today_date = dt.date()
if not log_file.exists():
return 0
try:
with log_file.open() as f:
logs: dict[str, Any] = json.load(f)
except (OSError, json.JSONDecodeError):
_logger.warning("Could not read workout log for weekly count")
return 0
count = 0
for date_str, entry in logs.items():
try:
entry_date = (
datetime.strptime(date_str, "%Y-%m-%d")
.replace(tzinfo=timezone.utc)
.date()
)
except ValueError:
continue
if not (week_start <= entry_date <= today_date):
continue
if not isinstance(entry, dict):
continue
wtype = entry.get("workout_data", {}).get("type", "")
if wtype in _COUNTED_WORKOUT_TYPES:
count += 1
return count
def has_weekly_minimum(
log_file: Path,
*,
today: datetime | None = None,
) -> bool:
"""Return True if the weekly workout minimum has already been reached.
Args:
log_file: Path to ``workout_log.json``.
today: Override for the current local datetime (for testing).
Returns:
True when ``count_weekly_workouts`` >= ``WEEKLY_WORKOUT_MINIMUM``.
"""
return count_weekly_workouts(log_file, today=today) >= WEEKLY_WORKOUT_MINIMUM

View File

@ -1,86 +0,0 @@
"""Window configuration and input-grab helpers for ScreenLocker."""
from __future__ import annotations
import contextlib
import logging
import shutil
import subprocess
import tkinter as tk
_logger = logging.getLogger(__name__)
class WindowSetupMixin:
"""Mixin providing window setup, VT switching control, and input-grab helpers."""
def _disable_vt_switching(self) -> None:
"""Disable VT switching in X11 while the lock is active.
Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn.
Best-effort: silently ignored if setxkbmap is unavailable.
"""
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is None:
_logger.warning("setxkbmap not found; VT switching will not be disabled")
return
subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False)
def _restore_vt_switching(self) -> None:
"""Restore VT switching after the lock is dismissed."""
setxkbmap = shutil.which("setxkbmap")
if setxkbmap is None:
return
subprocess.run([setxkbmap, "-option", ""], check=False)
def _setup_window(self) -> None:
"""Configure the window for fullscreen lock."""
screen_w = self.root.winfo_screenwidth()
screen_h = self.root.winfo_screenheight()
self.root.overrideredirect(boolean=True)
self.root.geometry(f"{screen_w}x{screen_h}+0+0")
self.root.attributes(fullscreen=True)
self.root.attributes(topmost=True)
self.root.configure(bg="#1a1a1a", cursor="arrow")
if not self.demo_mode:
self._disable_vt_switching()
def _setup_verify_window(self) -> None:
"""Configure window for post-sick-day workout verification."""
self.root.geometry("600x400")
self.root.configure(bg="#1a1a1a", cursor="arrow")
self.root.protocol("WM_DELETE_WINDOW", self.close)
def _setup_demo_close_button(self) -> None:
"""Add close button for demo mode."""
close_btn = tk.Button(
self.root,
text="✕ Close Demo",
font=("Arial", 12),
bg="#ff4444",
fg="white",
command=self.close,
cursor="hand2",
)
close_btn.place(x=10, y=10)
def _setup_relaxed_day_window(self) -> None:
"""Configure a small non-locking window for the optional Tue-Thu prompt."""
self.root.geometry("700x450")
self.root.configure(bg="#1a1a1a", cursor="arrow")
self.root.protocol("WM_DELETE_WINDOW", self.close)
def _grab_input(self) -> None:
"""Force input focus to the locker window."""
self.root.update_idletasks()
self.root.focus_force()
if self.demo_mode:
with contextlib.suppress(tk.TclError):
self.root.grab_set()
else:
try:
self.root.grab_set_global()
except tk.TclError:
_logger.warning("Global grab failed, falling back to local grab")
with contextlib.suppress(tk.TclError):
self.root.grab_set()

View File

@ -1,87 +0,0 @@
#!/bin/bash
# Helper script to adjust shutdown schedule
# This script should be allowed via sudoers for the workout locker
#
# Usage: sudo adjust_shutdown_schedule.sh [--restore] <mon_wed_hour> <thu_sun_hour> <morning_end_hour>
#
# --restore: Allow restoring to original (possibly later) times
# Without this flag, only stricter (earlier) times are allowed
#
# Add to /etc/sudoers.d/workout-locker:
# <username> ALL=(root) NOPASSWD: /home/kuhy/testsAndMisc/python_pkg/screen_locker/adjust_shutdown_schedule.sh
set -euo pipefail
CONFIG_FILE="/etc/shutdown-schedule.conf"
CANONICAL_FILE="/usr/local/share/locked-shutdown-schedule.conf"
# Check for --restore flag
RESTORE_MODE=false
if [[ "${1:-}" == "--restore" ]]; then
RESTORE_MODE=true
shift
fi
# Validate arguments
if [[ $# -ne 3 ]]; then
echo "Usage: $0 [--restore] <mon_wed_hour> <thu_sun_hour> <morning_end_hour>" >&2
exit 1
fi
MON_WED_HOUR="$1"
THU_SUN_HOUR="$2"
MORNING_END_HOUR="$3"
# Validate hours are integers between 0-23
for hour in "$MON_WED_HOUR" "$THU_SUN_HOUR" "$MORNING_END_HOUR"; do
if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -lt 0 ]] || [[ "$hour" -gt 24 ]]; then
echo "Error: Hours must be integers between 0 and 23" >&2
exit 1
fi
done
# Read current values to check if we're making schedule stricter
if [[ -f "$CONFIG_FILE" ]] && [[ "$RESTORE_MODE" == false ]]; then
# shellcheck source=/dev/null
source "$CONFIG_FILE" 2>/dev/null || true
OLD_MON_WED="${MON_WED_HOUR:-24}"
OLD_THU_SUN="${THU_SUN_HOUR:-24}"
# Reset variables to new values for comparison
# shellcheck disable=SC2034
MON_WED_HOUR_OLD="$OLD_MON_WED"
# shellcheck disable=SC2034
THU_SUN_HOUR_OLD="$OLD_THU_SUN"
# Only allow making schedule stricter (earlier shutdown) unless in restore mode
if [[ "$1" -gt "${MON_WED_HOUR_OLD:-24}" ]] || [[ "$2" -gt "${THU_SUN_HOUR_OLD:-24}" ]]; then
echo "Error: Can only make schedule stricter (earlier shutdown times)" >&2
echo "Use --restore flag to restore original times" >&2
exit 1
fi
fi
NEW_CONFIG="# Shutdown schedule configuration
# Modified by screen_locker sick day feature at $(date)
MON_WED_HOUR=$1
THU_SUN_HOUR=$2
MORNING_END_HOUR=$3
"
# Remove immutable attributes
chattr -i "$CONFIG_FILE" 2>/dev/null || true
chattr -i "$CANONICAL_FILE" 2>/dev/null || true
# Write canonical copy FIRST (before the watched config) to avoid
# a race with shutdown-schedule-guard.path which triggers on CONFIG_FILE
# changes and restores from CANONICAL_FILE.
echo "$NEW_CONFIG" > "$CANONICAL_FILE"
chmod 644 "$CANONICAL_FILE"
chattr +i "$CANONICAL_FILE" || echo "Warning: Could not set immutable on $CANONICAL_FILE" >&2
# Now write the watched config — guard will see content matches canonical
echo "$NEW_CONFIG" > "$CONFIG_FILE"
chmod 644 "$CONFIG_FILE"
chattr +i "$CONFIG_FILE" || echo "Warning: Could not set immutable on $CONFIG_FILE" >&2
echo "Shutdown schedule updated: Mon-Wed=${1}:00, Thu-Sun=${2}:00, Morning end=${3}:00"

View File

@ -1,14 +0,0 @@
[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

@ -1,73 +0,0 @@
#!/bin/bash
# Script to add screen locker to i3 autostart
# This will run the workout screen locker on system startup
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
I3_CONFIG="$HOME/.config/i3/config"
# Check if screen_lock.py exists
if [ ! -f "$SCREEN_LOCK_PATH" ]; then
echo "Error: screen_lock.py not found at $SCREEN_LOCK_PATH"
exit 1
fi
# Make sure screen_lock.py is executable
chmod +x "$SCREEN_LOCK_PATH"
# Check if i3 config exists
if [ ! -f "$I3_CONFIG" ]; then
echo "Error: i3 config not found at $I3_CONFIG"
echo "Please create i3 config first or specify correct path"
exit 1
fi
# Check if autostart line already exists
if grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
echo "Screen locker autostart already configured in i3 config"
echo "Current line:"
grep "exec.*screen_lock.py" "$I3_CONFIG"
read -p "Do you want to replace it? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Remove old line
sed -i '/exec.*screen_lock\.py/d' "$I3_CONFIG"
else
echo "Keeping existing configuration"
exit 0
fi
fi
# Add autostart line to i3 config
echo "" >>"$I3_CONFIG"
echo "# Workout screen locker on startup (production mode)" >>"$I3_CONFIG"
echo "exec --no-startup-id python3 $SCREEN_LOCK_PATH --production" >>"$I3_CONFIG"
echo "✓ Screen locker added to i3 autostart (production mode)"
echo "✓ Configuration added to: $I3_CONFIG"
echo ""
echo "The screen locker will run on next i3 restart/login"
echo ""
echo "To test now, run: i3-msg restart"
echo "To run in demo mode, remove --production flag from $I3_CONFIG"
# Check autostart installation status
echo ""
echo "=== Autostart Status ==="
if grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
echo "✓ i3 autostart: INSTALLED"
grep "exec.*screen_lock.py" "$I3_CONFIG"
else
echo "✗ i3 autostart: NOT INSTALLED"
fi
if systemctl --user is-enabled workout-locker.service &>/dev/null; then
echo "✓ systemd service: INSTALLED and enabled"
else
echo " systemd service: not installed"
fi
# Immediately check if today's workout is done; block if not
echo ""
echo "=== Checking today's workout status ==="
python3 "$SCREEN_LOCK_PATH" --production

View File

@ -1,92 +0,0 @@
#!/bin/bash
# Install workout locker as a systemd user service
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
echo "Screen locker systemd service is already installed."
echo "Current status:"
systemctl --user status "$SERVICE_NAME" --no-pager || true
echo ""
read -p "Do you want to reinstall/update it? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
echo "Keeping existing installation"
exit 0
fi
fi
# Create user systemd directory if it doesn't exist
mkdir -p "$USER_SERVICE_DIR"
# Remove old timer if it was previously installed
if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
systemctl --user disable --now "workout-locker.timer" 2>/dev/null || true
fi
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"
sed -i "s|Environment=PYTHONPATH=.*|Environment=PYTHONPATH=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Reload systemd daemon
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"
echo "To check status: systemctl --user status workout-locker"
echo "To stop: systemctl --user stop workout-locker"
echo "To disable autostart: systemctl --user disable workout-locker"
# Check autostart installation status
echo ""
echo "=== Autostart Status ==="
if systemctl --user is-enabled "$SERVICE_NAME" &>/dev/null; then
echo "✓ systemd service: INSTALLED and enabled"
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
echo "✓ i3 autostart: INSTALLED"
else
echo " i3 autostart: not installed"
echo ""
echo "To add i3 startup hook (recommended), add this line to $I3_CONFIG:"
echo " exec --no-startup-id /usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production"
fi
# Immediately check if today's workout is done; block if not
echo ""
echo "=== Checking today's workout status ==="
PYTHONPATH="$(cd "$SCRIPT_DIR/../.." && pwd)" python3 "$SCREEN_LOCK_PATH" --production

View File

@ -1,33 +0,0 @@
#!/bin/bash
# Script to remove screen locker from i3 autostart
I3_CONFIG="$HOME/.config/i3/config"
# Check if i3 config exists
if [ ! -f "$I3_CONFIG" ]; then
echo "Error: i3 config not found at $I3_CONFIG"
exit 1
fi
# Check if autostart line exists
if ! grep -q "exec.*screen_lock.py" "$I3_CONFIG"; then
echo "Screen locker autostart not found in i3 config"
exit 0
fi
# Show what will be removed
echo "Found screen locker configuration:"
grep -B1 "exec.*screen_lock.py" "$I3_CONFIG"
echo ""
read -p "Remove screen locker from autostart? (y/n) " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
# Remove the autostart lines
sed -i '/# Workout screen locker on startup/d' "$I3_CONFIG"
sed -i '/exec.*screen_lock\.py/d' "$I3_CONFIG"
echo "✓ Screen locker removed from i3 autostart"
echo "Changes will take effect on next i3 restart"
else
echo "Cancelled"
fi

View File

@ -1,23 +0,0 @@
#!/bin/bash
# Remove workout locker systemd service
SERVICE_NAME="workout-locker.service"
TIMER_NAME="workout-locker.timer"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
# Stop the service and timer if running
systemctl --user stop "$TIMER_NAME" 2>/dev/null
systemctl --user stop "$SERVICE_NAME" 2>/dev/null
# Disable the service and timer
systemctl --user disable "$TIMER_NAME" 2>/dev/null
systemctl --user disable "$SERVICE_NAME" 2>/dev/null
# Remove service and timer files
rm -f "$USER_SERVICE_DIR/$SERVICE_NAME"
rm -f "$USER_SERVICE_DIR/$TIMER_NAME"
# Reload systemd daemon
systemctl --user daemon-reload
echo "✓ Workout locker service and timer removed"

View File

@ -1,11 +0,0 @@
#!/usr/bin/env bash
set -e
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
VENV="$REPO_ROOT/.venv"
[[ ! -d "$VENV" ]] && python3 -m venv "$VENV"
# tkinter is from Python stdlib; install python-tk system package if missing:
# Arch: sudo pacman -S python-tk
# Debian: sudo apt-get install python3-tk
cd "$REPO_ROOT"
"$VENV/bin/python" -m python_pkg.screen_locker.screen_lock "$@"

View File

@ -1 +0,0 @@
["2026-05-19", "2026-05-20", "2026-05-21"]

View File

@ -1,454 +0,0 @@
#!/usr/bin/env python3
"""Screen locker with workout verification for Arch Linux / i3wm.
Requires user to log their workout to unlock the screen.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
import sys
import tkinter as tk
from typing import TYPE_CHECKING
from python_pkg.screen_locker import _sick_tracker
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,
PHONE_PENALTY_DELAY_DEMO,
PHONE_PENALTY_DELAY_PRODUCTION,
SCHEDULED_SKIPS_FILE,
SICK_LOCKOUT_SECONDS,
STRONGLIFTS_DB_REMOTE,
)
from python_pkg.screen_locker._early_bird import EarlyBirdMixin
from python_pkg.screen_locker._log_integrity import (
_load_hmac_key,
compute_entry_hmac,
verify_entry_hmac,
)
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
from python_pkg.screen_locker._shutdown import ShutdownMixin
from python_pkg.screen_locker._sick_dialog import SickDialogMixin
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
from python_pkg.screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
has_weekly_minimum,
is_relaxed_day,
)
from python_pkg.screen_locker._window_setup import WindowSetupMixin
from python_pkg.wake_alarm._state import has_workout_skip_today
if TYPE_CHECKING:
from collections.abc import Callable
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",
"PHONE_PENALTY_DELAY_DEMO",
"PHONE_PENALTY_DELAY_PRODUCTION",
"SCHEDULED_SKIPS_FILE",
"SICK_LOCKOUT_SECONDS",
"STRONGLIFTS_DB_REMOTE",
"WEEKLY_WORKOUT_MINIMUM",
"ScreenLocker",
]
_logger = logging.getLogger(__name__)
def _assert_not_under_pytest() -> None:
"""Raise if the screen locker is being created inside a pytest run.
Defence-in-depth: prevents a real fullscreen Tk window from locking
the user's screen when tests forget to mock ``tk.Tk``.
The check is cheap (one dict lookup) and only fires during testing.
"""
if "pytest" in sys.modules and getattr(tk, "__name__", "") == "tkinter":
msg = (
"SAFETY: ScreenLocker.__init__ called under pytest with "
"real tkinter — tk.Tk is not mocked"
)
raise RuntimeError(msg)
class ScreenLocker(
EarlyBirdMixin,
WindowSetupMixin,
ShutdownMixin,
PhoneVerificationMixin,
SickDialogMixin,
UIFlowsMixin,
):
"""Screen locker that requires workout logging to unlock."""
def __init__(
self,
*,
demo_mode: bool = True,
verify_only: bool = False,
) -> None:
"""Initialize screen locker with optional demo mode."""
_assert_not_under_pytest()
script_dir = Path(__file__).resolve().parent
self.log_file = script_dir / "workout_log.json"
self.verify_only = verify_only
self.workout_data: dict[str, str] = {}
self._relaxed_day_mode: bool = False
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 "")
)
self.root.title("Workout Locker" + title_suffix)
self.demo_mode = demo_mode
self.lockout_time = 10 if demo_mode else 1800
if verify_only:
self._setup_verify_window()
elif self._relaxed_day_mode:
self._setup_relaxed_day_window()
else:
self._setup_window()
if demo_mode:
self._setup_demo_close_button()
self.container = tk.Frame(self.root, bg="#1a1a1a")
self.container.place(relx=0.5, rely=0.5, anchor="center")
self._phone_future: Future[tuple[str, str]] | None = None
if verify_only:
self._start_verify_workout_check()
elif self._relaxed_day_mode:
self._start_relaxed_day_flow()
else:
self._start_phone_check()
self._grab_input()
def _is_sick_day_log(self) -> bool:
"""Check if today's workout log is a sick day (not yet verified)."""
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") == "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)
return
self._check_non_verify_exits()
def _check_today_state_exits(self) -> bool:
"""Handle early-bird and today's log states. Return True to stop startup."""
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)
return True
return False # Expired early bird, upgrade unavailable — full lock.
if self._is_early_bird_log():
_logger.info("Early bird window still active — skipping lock.")
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.")
elif self.has_logged_today():
_logger.info("Workout already logged today. Skipping screen lock.")
elif has_workout_skip_today():
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
elif self._is_early_bird_time():
self._save_early_bird_log()
_logger.info("Early bird time — skipping lock, will re-check at 08:30.")
else:
return False
sys.exit(0)
return True
def _check_non_verify_exits(self) -> None:
"""Check all normal (non-verify) startup early-exit conditions."""
if self._is_scheduled_skip_today():
_logger.info("Today is a scheduled skip day. Skipping screen lock.")
sys.exit(0)
return
if self._check_today_state_exits():
return
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
if is_relaxed_day():
_logger.info("Relaxed day (Tue-Thu) - showing optional workout prompt.")
self._relaxed_day_mode = True
return
# Fri-Mon: skip lock when weekly minimum is already met.
if has_weekly_minimum(self.log_file):
_logger.info(
"Weekly minimum of %d workouts met. Skipping screen lock.",
WEEKLY_WORKOUT_MINIMUM,
)
sys.exit(0)
return
def _try_auto_upgrade_sick_day(self) -> bool:
"""Silently upgrade today's sick_day entry if phone shows a workout."""
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 clear_container(self) -> None:
"""Remove all widgets from the main container."""
for widget in self.container.winfo_children():
widget.destroy()
# ------------------------------------------------------------------
# UI helper methods
# ------------------------------------------------------------------
def _label(
self,
text: str,
*,
font_size: int = 36,
color: str = "white",
pady: int = 20,
) -> tk.Label:
"""Create and pack a bold label in the container."""
label = tk.Label(
self.container,
text=text,
font=("Arial", font_size, "bold"),
fg=color,
bg="#1a1a1a",
)
label.pack(pady=pady)
return label
def _text(
self,
text: str,
*,
font_size: int = 18,
color: str = "white",
pady: int = 10,
) -> tk.Label:
"""Create and pack a non-bold text label in the container."""
label = tk.Label(
self.container,
text=text,
font=("Arial", font_size),
fg=color,
bg="#1a1a1a",
)
label.pack(pady=pady)
return label
def _button(
self,
parent: tk.Widget,
text: str,
*,
bg: str,
command: Callable[[], None],
width: int = 10,
) -> tk.Button:
"""Create a styled button (caller must pack)."""
return tk.Button(
parent,
text=text,
font=("Arial", 24, "bold"),
bg=bg,
fg="white",
width=width,
command=command,
cursor="hand2" if self.demo_mode else "",
)
def _button_row(self) -> tk.Frame:
"""Create and pack a horizontal button container."""
frame = tk.Frame(self.container, bg="#1a1a1a")
frame.pack(pady=20)
return frame
# ------------------------------------------------------------------
# Unlock, logging
# ------------------------------------------------------------------
def _try_adjust_shutdown_for_workout(self) -> bool:
"""Try to adjust shutdown time later for actual workouts."""
workout_type = self.workout_data.get("type", "")
if workout_type != "phone_verified":
return False
adjusted = self._adjust_shutdown_time_later()
if adjusted:
_logger.info("Shutdown time moved 1.5 hours later as workout reward")
return adjusted
def _clear_debt_on_verified_workout(self) -> int | None:
"""Decrement workout debt by one for a verified workout.
Returns the new debt count, or ``None`` when this wasn't a
phone-verified workout.
"""
if self.workout_data.get("type") != "phone_verified":
return None
history = _sick_tracker.load_history()
if history.debt <= 0:
return 0
new_debt = _sick_tracker.clear_one_debt(history)
_sick_tracker.save_history(history)
return new_debt
def unlock_screen(self) -> None:
"""Save workout log and display success message."""
self.save_workout_log()
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
new_debt = self._clear_debt_on_verified_workout()
self.clear_container()
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
if shutdown_adjusted:
self._text(
"Shutdown time +1.5h later! 🎁",
font_size=24,
color="#ffaa00",
)
if new_debt is not None:
self._text(
f"Workout debt: {new_debt}",
font_size=20,
color="#ffaa00" if new_debt > 0 else "#888888",
)
self._text("Screen Unlocked!", font_size=36, pady=20)
if self.workout_data.get("type") == "phone_verified":
self.root.after(
1500,
lambda: self._show_commitment_prompt(on_done=self.close),
)
else:
self.root.after(1500, self.close)
def has_logged_today(self) -> bool:
"""Check if workout has been logged today with valid HMAC."""
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
else:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry = logs.get(today)
if entry is None:
return False
if verify_entry_hmac(entry):
return entry.get("workout_data", {}).get("type") != "early_bird"
if _load_hmac_key() is None and "hmac" not in entry:
_logger.info(
"HMAC key unavailable — accepting unsigned entry",
)
return entry.get("workout_data", {}).get("type") != "early_bird"
_logger.warning(
"HMAC verification failed for today's log entry",
)
return False
def _load_existing_logs(self) -> dict:
"""Load existing workout logs from file."""
if not self.log_file.exists():
return {}
try:
with self.log_file.open() as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _is_scheduled_skip_today(self) -> bool:
"""Return True if today's date is listed in the scheduled skips file."""
if not SCHEDULED_SKIPS_FILE.exists():
return False
try:
with SCHEDULED_SKIPS_FILE.open() as f:
skips = json.load(f)
except (OSError, json.JSONDecodeError):
return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
return today in skips
def save_workout_log(self) -> None:
"""Save workout data to log file with HMAC signature."""
logs = self._load_existing_logs()
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry: dict[str, object] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"workout_data": self.workout_data,
}
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned entry")
logs[today] = entry
try:
with self.log_file.open("w") as f:
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)
def close(self) -> None:
"""Close the application and exit."""
if not self.demo_mode:
self._restore_vt_switching()
self.root.destroy()
sys.exit(0)
def run(self) -> None:
"""Start the Tkinter main event loop."""
self.root.mainloop()
if __name__ == "__main__":
# Check for --production flag
demo_mode = True # Default to demo mode for safety
verify_only = "--verify-workout" in sys.argv
if "--production" in sys.argv:
demo_mode = False
locker = ScreenLocker(
demo_mode=demo_mode,
verify_only=verify_only,
)
locker.run()

View File

@ -1 +0,0 @@
"""Tests for screen_locker module."""

View File

@ -1,273 +0,0 @@
"""Shared fixtures and helpers for screen_locker tests.
Safety:
``_block_real_tk_and_exit`` (autouse) replaces the **entire** ``tk``
module reference inside ``screen_lock`` with a MagicMock and stubs
``sys.exit``. This makes it physically impossible for any test to
create a real Tk root window, go fullscreen, or grab input even if
the test forgets to request the explicit ``mock_tk`` fixture.
"""
from __future__ import annotations
from pathlib import Path
import tkinter as tk
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
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:
"""Build a MagicMock that stands in for the ``tkinter`` module."""
mock = MagicMock()
mock_root = MagicMock()
mock_root.winfo_screenwidth.return_value = 1920
mock_root.winfo_screenheight.return_value = 1080
mock.Tk.return_value = mock_root
mock_frame = MagicMock()
mock_frame.winfo_children.return_value = []
mock.Frame.return_value = mock_frame
# Keep real TclError so ``except tk.TclError`` still works.
mock.TclError = tk.TclError
return mock
@pytest.fixture(autouse=True)
def _block_real_tk_and_exit() -> Iterator[None]:
"""Replace the whole ``tk`` module and ``sys.exit`` for every test.
Patching the entire module (not just ``tk.Tk``) ensures that
**nothing** in tkinter can touch the real display server.
"""
mock = _make_mock_tk()
with (
patch("python_pkg.screen_locker.screen_lock.tk", mock),
patch("python_pkg.screen_locker._sick_dialog.tk", mock),
patch("python_pkg.screen_locker.screen_lock.sys.exit"),
):
yield
@pytest.fixture(autouse=True)
def mock_subprocess_run() -> Generator[MagicMock]:
"""Block real subprocess calls (e.g. setxkbmap) for every test.
Also exposed as a named fixture so individual tests can assert
on the calls made (e.g. VT switching tests).
``shutil.which`` is mocked to return a stable fake path so tests work
regardless of whether setxkbmap is installed on the host machine.
"""
with (
patch(
"python_pkg.screen_locker._window_setup.shutil.which",
return_value="/usr/bin/setxkbmap",
),
patch("python_pkg.screen_locker._window_setup.subprocess.run") as mock,
):
yield mock
@pytest.fixture(autouse=True)
def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
"""Redirect SICK_HISTORY_FILE to tmp_path so tests cannot touch real state."""
target = tmp_path / "sick_history.json"
with (
patch(
"python_pkg.screen_locker._sick_tracker.SICK_HISTORY_FILE",
target,
),
patch(
"python_pkg.screen_locker._constants.SICK_HISTORY_FILE",
target,
),
):
yield
@pytest.fixture(autouse=True)
def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
"""Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file."""
target = tmp_path / "scheduled_skips.json"
with patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
target,
):
yield
@pytest.fixture(autouse=True)
def _mock_weekly_logic() -> Iterator[None]:
"""Default to Fri-Mon enforcement with weekly minimum not yet met.
Without this, tests that run on a Tue/Wed/Thu would hit the relaxed-day
branch instead of the full-lock path that existing tests expect.
Setting has_weekly_minimum=False ensures the full lock is shown
(weekly quota not reached enforce).
"""
with (
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=False,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
):
yield
@pytest.fixture
def mock_tk() -> Generator[MagicMock]:
"""Mock tkinter module for testing without display."""
with patch("python_pkg.screen_locker.screen_lock.tk") as mock:
# Set up Tk root mock
mock_root = MagicMock()
mock_root.winfo_screenwidth.return_value = 1920
mock_root.winfo_screenheight.return_value = 1080
mock.Tk.return_value = mock_root
# Set up Frame mock
mock_frame = MagicMock()
mock_frame.winfo_children.return_value = []
mock.Frame.return_value = mock_frame
# Set up TclError as actual exception class
mock.TclError = tk.TclError
yield mock
@pytest.fixture
def mock_sys_exit() -> Generator[MagicMock]:
"""Mock sys.exit to prevent test termination."""
with patch("python_pkg.screen_locker.screen_lock.sys.exit") as mock:
yield mock
@pytest.fixture
def _mock_sys_exit(mock_sys_exit: MagicMock) -> MagicMock:
"""Alias for mock_sys_exit when the return value is unused."""
return mock_sys_exit
@pytest.fixture
def temp_log_file(tmp_path: Path) -> Path:
"""Create a temporary log file path."""
return tmp_path / "workout_log.json"
def create_locker(
_mock_tk: MagicMock,
tmp_path: Path,
*,
demo_mode: bool = True,
has_logged: bool = False,
verify_only: bool = False,
is_sick_day_log: bool = False,
) -> ScreenLocker:
"""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),
patch.object(
ScreenLocker,
"_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_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
return ScreenLocker(
demo_mode=demo_mode,
verify_only=verify_only,
)
def create_locker_relaxed_day(
_mock_tk: MagicMock,
tmp_path: Path,
*,
demo_mode: bool = True,
has_logged: bool = False,
) -> ScreenLocker:
"""Create a ScreenLocker in relaxed-day mode (Tue/Wed/Thu).
``is_relaxed_day`` returns True so ``_relaxed_day_mode`` is set and
``_start_relaxed_day_flow`` is called instead of ``_start_phone_check``.
The autouse ``_mock_weekly_logic`` fixture is overridden here.
"""
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=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
patch("python_pkg.screen_locker.screen_lock.is_relaxed_day", return_value=True),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
return ScreenLocker(demo_mode=demo_mode)
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_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
return ScreenLocker(demo_mode=demo_mode)

View File

@ -1,476 +0,0 @@
"""Tests for ADB commands, phone connection, and database operations."""
# pylint: disable=protected-access,unused-argument
from __future__ import annotations
import sqlite3
import subprocess
import time
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.screen_lock import STRONGLIFTS_DB_REMOTE
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestRunAdb:
"""Tests for _run_adb ADB command execution."""
def test_run_adb_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful ADB command."""
locker = create_locker(mock_tk, tmp_path)
mock_result = MagicMock(returncode=0, stdout="ok\n")
with patch(
"python_pkg.screen_locker._phone_verification.subprocess.run",
return_value=mock_result,
) as mock_run:
success, output = locker._run_adb(["devices"])
assert success is True
assert output == "ok\n"
mock_run.assert_called_once()
def test_run_adb_failure(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test failed ADB command."""
locker = create_locker(mock_tk, tmp_path)
mock_result = MagicMock(returncode=1, stdout="")
with patch(
"python_pkg.screen_locker._phone_verification.subprocess.run",
return_value=mock_result,
):
success, _output = locker._run_adb(["devices"])
assert success is False
def test_run_adb_not_found(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB binary not found."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._phone_verification.subprocess.run",
side_effect=FileNotFoundError("adb not found"),
):
success, output = locker._run_adb(["devices"])
assert success is False
assert not output
def test_run_adb_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB OSError."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._phone_verification.subprocess.run",
side_effect=OSError("permission denied"),
):
success, output = locker._run_adb(["devices"])
assert success is False
assert not output
def test_run_adb_timeout(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB command timeout."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._phone_verification.subprocess.run",
side_effect=subprocess.TimeoutExpired("adb", 15),
):
success, output = locker._run_adb(["devices"])
assert success is False
assert not output
class TestAdbShell:
"""Tests for _adb_shell method."""
def test_adb_shell_no_root(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB shell without root."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, "output"),
),
)
success, output = locker._adb_shell("ls /sdcard")
locker._run_adb.assert_called_once_with(["shell", "ls /sdcard"])
assert success is True
assert output == "output"
def test_adb_shell_with_root(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB shell with root."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, "output"),
),
)
success, _output = locker._adb_shell("ls /data", root=True)
locker._run_adb.assert_called_once_with(
["shell", "su", "-c", "ls /data"],
)
assert success is True
class TestIsPhoneConnected:
"""Tests for _is_phone_connected method."""
def test_phone_connected(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone detected as connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(
True,
"List of devices attached\nABC123\tdevice\n\n",
),
),
)
assert locker._is_phone_connected() is True
def test_phone_not_connected(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no phone connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, "List of devices attached\n\n"),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
def test_phone_offline(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone connected but offline."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(
True,
"List of devices attached\nABC123\toffline\n\n",
),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
def test_adb_command_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ADB command failure."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(False, ""),
),
)
object.__setattr__(
locker,
"_try_wireless_reconnect",
MagicMock(
return_value=False,
),
)
assert locker._is_phone_connected() is False
class TestFindHealthConnectDb:
"""Tests for _pull_stronglifts_db method."""
def test_db_pulled_successfully(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test StrongLifts DB pulled from device."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_adb_shell",
MagicMock(
return_value=(True, ""),
),
)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, ""),
),
)
result = locker._pull_stronglifts_db()
assert result is not None
locker._adb_shell.assert_called_once()
locker._run_adb.assert_called_once()
call_args = locker._run_adb.call_args[0][0]
assert call_args[0] == "pull"
def test_db_cat_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when cat command fails."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_adb_shell",
MagicMock(
return_value=(False, ""),
),
)
assert locker._pull_stronglifts_db() is None
def test_db_pull_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when adb pull fails."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_adb_shell",
MagicMock(
return_value=(True, ""),
),
)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(False, ""),
),
)
assert locker._pull_stronglifts_db() is None
def test_db_uses_correct_remote_path(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test uses the correct StrongLifts DB remote path."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_adb_shell",
MagicMock(
return_value=(True, ""),
),
)
object.__setattr__(
locker,
"_run_adb",
MagicMock(
return_value=(True, ""),
),
)
locker._pull_stronglifts_db()
shell_cmd = locker._adb_shell.call_args[0][0]
assert STRONGLIFTS_DB_REMOTE in shell_cmd
class TestCountTodayWorkouts:
"""Tests for _count_today_workouts method."""
def test_workouts_found_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test workouts found today."""
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)",
)
# Insert a workout with today's timestamp (ms)
now_ms = int(time.time() * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, now_ms + 3600000),
)
conn.commit()
conn.close()
assert locker._count_today_workouts(db_file) == 1
def test_no_workouts_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no workouts today."""
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)",
)
# Insert a workout from yesterday (24h+ ago)
yesterday_ms = int((time.time() - 200000) * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", yesterday_ms, yesterday_ms + 3600000),
)
conn.commit()
conn.close()
assert not locker._count_today_workouts(db_file)
def test_invalid_db_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 for invalid database file."""
locker = create_locker(mock_tk, tmp_path)
bad_file = tmp_path / "not_a_db.db"
bad_file.write_text("not a database")
assert not locker._count_today_workouts(bad_file)
def test_missing_table_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 when workouts table doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "empty.db"
conn = sqlite3.connect(str(db_file))
conn.execute("CREATE TABLE other (id TEXT)")
conn.commit()
conn.close()
assert not locker._count_today_workouts(db_file)
def test_multiple_workouts_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test counts multiple workouts today correctly."""
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)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, now_ms + 3600000),
)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w2", now_ms + 100000, now_ms + 3700000),
)
conn.commit()
conn.close()
assert locker._count_today_workouts(db_file) == 2

View File

@ -1,394 +0,0 @@
"""Tests for ADB commands, phone connection, and database operations."""
# pylint: disable=protected-access,unused-argument
from __future__ import annotations
import datetime
import json
import sqlite3
import time
from typing import TYPE_CHECKING
import pytest
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
from unittest.mock import MagicMock
class TestGetTodayWorkoutDurationMinutes:
"""Tests for _get_today_workout_duration_minutes method."""
def test_returns_duration_for_today_workout(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns correct duration for a 60-minute workout."""
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)
duration_ms = 60 * 60 * 1000 # 60 minutes
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, now_ms + duration_ms),
)
conn.commit()
conn.close()
result = locker._get_today_workout_duration_minutes(db_file)
assert result == pytest.approx(60.0, abs=1.0)
def test_returns_zero_for_no_workouts(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0.0 when no workouts today."""
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)",
)
yesterday_ms = int((time.time() - 200000) * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", yesterday_ms, yesterday_ms + 3600000),
)
conn.commit()
conn.close()
assert not locker._get_today_workout_duration_minutes(db_file)
def test_sums_multiple_workouts(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test sums durations of multiple workouts today."""
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)
# 30 min + 25 min = 55 min total
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, now_ms + 30 * 60 * 1000),
)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w2", now_ms + 31 * 60 * 1000, now_ms + 56 * 60 * 1000),
)
conn.commit()
conn.close()
result = locker._get_today_workout_duration_minutes(db_file)
assert result == pytest.approx(55.0, abs=1.0)
def test_ignores_invalid_finish(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test ignores workouts where finish <= start."""
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)
# finish == start (zero duration - should be excluded by WHERE)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, now_ms),
)
conn.commit()
conn.close()
assert not locker._get_today_workout_duration_minutes(db_file)
def test_invalid_db_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0.0 for invalid database file."""
locker = create_locker(mock_tk, tmp_path)
bad_file = tmp_path / "not_a_db.db"
bad_file.write_text("not a database")
assert not locker._get_today_workout_duration_minutes(bad_file)
def test_missing_table_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0.0 when workouts table doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "empty.db"
conn = sqlite3.connect(str(db_file))
conn.execute("CREATE TABLE other (id TEXT)")
conn.commit()
conn.close()
assert not locker._get_today_workout_duration_minutes(db_file)
class TestGetTodayExerciseCount:
"""Tests for _get_today_exercise_count method."""
def test_counts_exercises(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test counts distinct exercises in today's workouts."""
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, exercises TEXT)",
)
now_ms = int(time.time() * 1000)
exercises_json = json.dumps(
[
{"id": "squat", "name": "Squat"},
{"id": "bench_press", "name": "Bench Press"},
{"id": "squat", "name": "Squat"},
{"category": "WARMUP"},
]
)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
("w1", now_ms, now_ms + 3600000, exercises_json),
)
conn.commit()
conn.close()
assert locker._get_today_exercise_count(db_file) == 2
def test_no_exercises_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 when no exercises exist."""
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, exercises TEXT)",
)
now_ms = int(time.time() * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
("w1", now_ms, now_ms + 3600000, "[]"),
)
conn.commit()
conn.close()
assert not locker._get_today_exercise_count(db_file)
def test_invalid_db_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 for invalid database file."""
locker = create_locker(mock_tk, tmp_path)
bad_file = tmp_path / "bad.db"
bad_file.write_text("not a db")
assert not locker._get_today_exercise_count(bad_file)
def test_missing_exercises_column_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 when workouts table has no exercises column."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "empty.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)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", now_ms, now_ms + 3600000),
)
conn.commit()
conn.close()
assert not locker._get_today_exercise_count(db_file)
def test_null_exercises_json_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 when exercises JSON is NULL."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "null_ex.db"
conn = sqlite3.connect(str(db_file))
conn.execute(
"CREATE TABLE workouts "
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)",
)
now_ms = int(time.time() * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
("w1", now_ms, now_ms + 3600000, None),
)
conn.commit()
conn.close()
assert not locker._get_today_exercise_count(db_file)
def test_malformed_exercises_json_returns_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns 0 when exercises JSON is malformed."""
locker = create_locker(mock_tk, tmp_path)
db_file = tmp_path / "bad_json.db"
conn = sqlite3.connect(str(db_file))
conn.execute(
"CREATE TABLE workouts "
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)",
)
now_ms = int(time.time() * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?, ?)",
("w1", now_ms, now_ms + 3600000, "not valid json"),
)
conn.commit()
conn.close()
assert not locker._get_today_exercise_count(db_file)
class TestIsWorkoutFinishRecent:
"""Tests for _is_workout_finish_recent method."""
def test_recent_workout_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True for workout that finished recently."""
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)",
)
# Anchor to local noon to avoid midnight boundary issues: the SQL
# date() filter requires start and now to share the same local date.
local_noon = (
datetime.datetime.now(tz=datetime.timezone.utc)
.astimezone()
.replace(hour=12, minute=0, second=0, microsecond=0)
)
local_noon_ms = int(local_noon.timestamp() * 1000)
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", local_noon_ms, local_noon_ms + 3_600_000),
)
conn.commit()
conn.close()
assert locker._is_workout_finish_recent(db_file) is True
def test_old_workout_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False for workout that finished >24 hours ago."""
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)",
)
# 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
conn.execute(
"INSERT INTO workouts VALUES (?, ?, ?)",
("w1", old_finish - 3600000, old_finish),
)
conn.commit()
conn.close()
assert locker._is_workout_finish_recent(db_file) is False
def test_no_workouts_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when no workouts exist."""
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)",
)
conn.commit()
conn.close()
assert locker._is_workout_finish_recent(db_file) is False
def test_invalid_db_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False for invalid database file."""
locker = create_locker(mock_tk, tmp_path)
bad_file = tmp_path / "bad.db"
bad_file.write_text("not a db")
assert locker._is_workout_finish_recent(bad_file) is False

View File

@ -1,430 +0,0 @@
"""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

@ -1,342 +0,0 @@
"""Tests for screen_locker initialization, logging, and basic operations."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import tkinter as tk
from typing import TYPE_CHECKING, Any
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.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestAssertNotUnderPytest:
"""Tests for the _assert_not_under_pytest runtime guard."""
def test_raises_when_tk_is_real(self) -> None:
"""Guard fires if tk.Tk is the real tkinter class under pytest."""
with (
patch("python_pkg.screen_locker.screen_lock.tk", tk),
pytest.raises(RuntimeError, match="SAFETY"),
):
_assert_not_under_pytest()
def test_silent_when_tk_is_mocked(self) -> None:
"""Guard stays silent when tk is already mocked (normal test run)."""
_assert_not_under_pytest()
class TestScreenLockerInit:
"""Tests for ScreenLocker initialization."""
def test_init_demo_mode(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test initialization in demo mode."""
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
assert locker.demo_mode is True
assert locker.lockout_time == 10
mock_sys_exit.assert_not_called()
def test_init_production_mode(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test initialization in production mode."""
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
assert locker.demo_mode is False
assert locker.lockout_time == 1800
mock_sys_exit.assert_not_called()
def test_init_exits_if_logged_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test that init exits early if workout logged today."""
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(mock_tk, tmp_path, has_logged=True)
mock_sys_exit.assert_called_once_with(0)
class TestHasLoggedToday:
"""Tests for has_logged_today method."""
def test_no_log_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test when log file doesn't exist."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker.has_logged_today() is False
def test_empty_log_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test when log file is empty/invalid JSON."""
log_file = tmp_path / "workout_log.json"
log_file.write_text("")
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker.has_logged_today() is False
def test_invalid_json(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test when log file contains invalid JSON."""
log_file = tmp_path / "workout_log.json"
log_file.write_text("{invalid json}")
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker.has_logged_today() is False
def test_today_logged(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test when today's workout is logged with valid HMAC."""
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", "hmac": "valid"}}),
)
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 True
def test_today_logged_invalid_hmac(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test rejects entry when HMAC verification fails."""
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", "hmac": "tampered"}}),
)
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,
):
assert locker.has_logged_today() is False
def test_today_unsigned_entry_no_hmac_key(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Accept unsigned entry when HMAC key is unavailable."""
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=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
def test_other_day_logged(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test when only other days are logged."""
log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps({"2020-01-01": {"workout": "data"}}))
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker.has_logged_today() is False
class TestSaveWorkoutLog:
"""Tests for save_workout_log method."""
def test_save_to_new_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test saving to a new log file includes HMAC."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
return_value="abc123",
):
locker.save_workout_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 today in data
assert data[today]["workout_data"]["type"] == "running"
assert data[today]["hmac"] == "abc123"
def test_save_to_new_file_no_hmac_key(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test saving without HMAC key produces unsigned entry."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
return_value=None,
):
locker.save_workout_log()
with log_file.open() as f:
data: dict[str, Any] = json.load(f)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
assert "hmac" not in data[today]
def test_save_to_existing_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test saving appends to existing log file."""
log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps({"2020-01-01": {"old": "data"}}))
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "strength"}
with patch(
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
return_value="sig",
):
locker.save_workout_log()
with log_file.open() as f:
data: dict[str, Any] = json.load(f)
assert "2020-01-01" in data
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
assert today in data
def test_save_with_corrupted_existing_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test saving when existing file is corrupted."""
log_file = tmp_path / "workout_log.json"
log_file.write_text("not valid json")
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
return_value="sig",
):
locker.save_workout_log()
with log_file.open() as f:
data: dict[str, Any] = json.load(f)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
assert today in data
def test_save_with_write_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test saving handles write errors gracefully."""
log_file = tmp_path / "nonexistent_dir" / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"python_pkg.screen_locker.screen_lock.compute_entry_hmac",
return_value="sig",
):
# Should not raise, just log warning
locker.save_workout_log()

View File

@ -1,241 +0,0 @@
"""Tests for screen_locker initialization, logging, and basic operations."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import tkinter as tk
from typing import TYPE_CHECKING, 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
if TYPE_CHECKING:
from pathlib import Path
class TestRun:
"""Tests for run method."""
def test_run_starts_mainloop(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test run starts the tkinter mainloop."""
locker = create_locker(mock_tk, tmp_path)
locker.run()
locker.root.mainloop.assert_called_once()
class TestAutoUpgradeSickDay:
"""Tests for sick_day → phone_verified silent upgrade helpers."""
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 sick_day 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)
class TestMainEntry:
"""Tests for main entry point."""
def test_main_demo_mode_default(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test main defaults to demo mode."""
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
assert locker.demo_mode is True
def test_main_production_mode_flag(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test main with --production flag."""
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
assert locker.demo_mode is False
class TestAdjustShutdownTimeLater:
"""Tests for _adjust_shutdown_time_later method."""
def test_adjust_shutdown_time_later_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _adjust_shutdown_time_later adds hours successfully."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 22, 8))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
result = locker._adjust_shutdown_time_later()
assert result is True
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
def test_adjust_shutdown_time_later_caps_at_23(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _adjust_shutdown_time_later caps hours at 23."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(22, 23, 8))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
result = locker._adjust_shutdown_time_later()
assert result is True
# 22+2=24 capped to 23, 23+2=25 capped to 23
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
def test_adjust_shutdown_time_later_no_config(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _adjust_shutdown_time_later returns False if config missing."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
result = locker._adjust_shutdown_time_later()
assert result is False
def test_adjust_shutdown_time_later_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _adjust_shutdown_time_later handles OSError."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=OSError("permission denied")),
)
result = locker._adjust_shutdown_time_later()
assert result is False
class TestGrabInput:
"""Tests for _grab_input method."""
def test_production_global_grab_tcl_error(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test production mode falls back when global grab fails."""
mock_tk.Tk.return_value.grab_set_global.side_effect = tk.TclError("grab failed")
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
assert locker.demo_mode is False

View File

@ -1,154 +0,0 @@
"""Tests for _log_integrity HMAC signing and verification."""
from __future__ import annotations
import hashlib
import hmac
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from python_pkg.screen_locker._log_integrity import (
_generate_hmac_key,
_load_hmac_key,
compute_entry_hmac,
verify_entry_hmac,
)
_HMAC_KEY_FILE_PATH = "python_pkg.shared.log_integrity.HMAC_KEY_FILE"
if TYPE_CHECKING:
from pathlib import Path
class TestLoadHmacKey:
"""Tests for _load_hmac_key."""
def test_loads_key_from_file(self, tmp_path: Path) -> None:
"""Test loading HMAC key from existing file."""
key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"secret_key_bytes")
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = _load_hmac_key()
assert result == b"secret_key_bytes"
def test_returns_none_on_missing_file(self, tmp_path: Path) -> None:
"""Test returns None when key file doesn't exist."""
key_file = tmp_path / "nonexistent.key"
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = _load_hmac_key()
assert result is None
class TestGenerateHmacKey:
"""Tests for _generate_hmac_key."""
def test_generates_and_writes_key(self, tmp_path: Path) -> None:
"""Test key generation creates file with 32-byte key."""
key_file = tmp_path / "subdir" / "hmac.key"
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = _generate_hmac_key()
assert result is not None
assert len(result) == 32
assert key_file.read_bytes() == result
def test_returns_none_on_write_failure(self) -> None:
"""Test returns None when file cannot be written."""
with patch(
_HMAC_KEY_FILE_PATH,
) as mock_path:
mock_path.parent.mkdir.side_effect = OSError("permission denied")
result = _generate_hmac_key()
assert result is None
class TestComputeEntryHmac:
"""Tests for compute_entry_hmac."""
def test_computes_hmac_for_entry(self, tmp_path: Path) -> None:
"""Test HMAC computation produces valid hex string."""
key_file = tmp_path / "hmac.key"
key = b"test_key_12345"
key_file.write_bytes(key)
entry = {"timestamp": "2025-01-01T00:00:00", "workout_data": {"type": "test"}}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = compute_entry_hmac(entry)
assert result is not None
# Verify manually
payload = json.dumps(entry, sort_keys=True, separators=(",", ":"))
expected = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
assert result == expected
def test_returns_none_when_no_key(self, tmp_path: Path) -> None:
"""Test returns None when key file is missing."""
key_file = tmp_path / "nonexistent.key"
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
result = compute_entry_hmac({"data": "test"})
assert result is None
class TestVerifyEntryHmac:
"""Tests for verify_entry_hmac."""
def test_valid_hmac(self, tmp_path: Path) -> None:
"""Test verification passes with correct HMAC."""
key_file = tmp_path / "hmac.key"
key = b"verification_key"
key_file.write_bytes(key)
entry_data = {"timestamp": "2025-01-01", "workout_data": {"type": "test"}}
payload = json.dumps(entry_data, sort_keys=True, separators=(",", ":"))
correct_hmac = hmac.new(key, payload.encode(), hashlib.sha256).hexdigest()
entry = {**entry_data, "hmac": correct_hmac}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
assert verify_entry_hmac(entry) is True
def test_invalid_hmac(self, tmp_path: Path) -> None:
"""Test verification fails with wrong HMAC."""
key_file = tmp_path / "hmac.key"
key_file.write_bytes(b"verification_key")
entry = {"timestamp": "2025-01-01", "hmac": "wrong_hmac_value"}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
assert verify_entry_hmac(entry) is False
def test_missing_hmac_field(self) -> None:
"""Test verification fails when entry has no hmac field."""
entry: dict[str, object] = {"timestamp": "2025-01-01"}
assert verify_entry_hmac(entry) is False
def test_non_string_hmac_field(self) -> None:
"""Test verification fails when hmac field is not a string."""
entry: dict[str, object] = {"timestamp": "2025-01-01", "hmac": 12345}
assert verify_entry_hmac(entry) is False
def test_missing_key_file(self, tmp_path: Path) -> None:
"""Test verification fails when key file doesn't exist."""
key_file = tmp_path / "nonexistent.key"
entry = {"timestamp": "2025-01-01", "hmac": "some_hmac"}
with patch(
_HMAC_KEY_FILE_PATH,
key_file,
):
assert verify_entry_hmac(entry) is False

View File

@ -1,488 +0,0 @@
"""Tests for phone workout verification, phone check, and unlock operations."""
# pylint: disable=protected-access,unused-argument
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestVerifyPhoneWorkout:
"""Tests for _verify_phone_workout method."""
def test_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test workout verified on phone with sufficient duration."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_pull_stronglifts_db",
MagicMock(return_value=tmp_path / "sl.db"),
)
object.__setattr__(
locker,
"_count_today_workouts",
MagicMock(return_value=2),
)
object.__setattr__(
locker,
"_is_workout_finish_recent",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_get_today_exercise_count",
MagicMock(return_value=3),
)
object.__setattr__(
locker,
"_get_today_workout_duration_minutes",
MagicMock(return_value=65.0),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, message = locker._verify_phone_workout()
assert status == "verified"
assert "2 session" in message
assert "65 min" in message
assert "3 exercise" in message
def test_not_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no workout found on phone."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_pull_stronglifts_db",
MagicMock(return_value=tmp_path / "sl.db"),
)
object.__setattr__(
locker,
"_count_today_workouts",
MagicMock(return_value=0),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, message = locker._verify_phone_workout()
assert status == "not_verified"
assert "No workout" in message
def test_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test workout found but too short."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_pull_stronglifts_db",
MagicMock(return_value=tmp_path / "sl.db"),
)
object.__setattr__(
locker,
"_count_today_workouts",
MagicMock(return_value=1),
)
object.__setattr__(
locker,
"_is_workout_finish_recent",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_get_today_exercise_count",
MagicMock(return_value=3),
)
object.__setattr__(
locker,
"_get_today_workout_duration_minutes",
MagicMock(return_value=25.0),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, message = locker._verify_phone_workout()
assert status == "too_short"
assert "25 min" in message
assert "50 min" in message
def test_no_phone(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no phone connected."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=False),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, _ = locker._verify_phone_workout()
assert status == "no_phone"
def test_error_no_db(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test error when StrongLifts DB cannot be pulled."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_pull_stronglifts_db",
MagicMock(return_value=None),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, message = locker._verify_phone_workout()
assert status == "error"
assert "database" in message.lower()
def test_clock_tampered(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test clock_tampered when NTP check fails."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(False, "System clock is 600s ahead"),
):
status, message = locker._verify_phone_workout()
assert status == "clock_tampered"
assert "600s" in message
def test_stale_workout(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test stale status when workout finish is not recent."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_pull_stronglifts_db",
MagicMock(return_value=tmp_path / "sl.db"),
)
object.__setattr__(
locker,
"_count_today_workouts",
MagicMock(return_value=1),
)
object.__setattr__(
locker,
"_is_workout_finish_recent",
MagicMock(return_value=False),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, message = locker._verify_phone_workout()
assert status == "stale"
assert "old" in message.lower()
def test_no_exercises(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no_exercises when workout has no exercise data."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_is_phone_connected",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_pull_stronglifts_db",
MagicMock(return_value=tmp_path / "sl.db"),
)
object.__setattr__(
locker,
"_count_today_workouts",
MagicMock(return_value=1),
)
object.__setattr__(
locker,
"_is_workout_finish_recent",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_get_today_exercise_count",
MagicMock(return_value=0),
)
with patch(
"python_pkg.screen_locker._phone_verification.check_clock_skew",
return_value=(True, "Clock OK"),
):
status, message = locker._verify_phone_workout()
assert status == "no_exercises"
assert "exercise" in message.lower()
class TestStartPhoneCheck:
"""Tests for _start_phone_check and _handle_startup_phone_result."""
def test_start_phone_check_shows_checking_screen(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _start_phone_check shows checking message and starts check."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(
return_value=("no_phone", "No phone"),
),
)
object.__setattr__(locker, "_poll_phone_check", MagicMock())
locker._start_phone_check()
locker.clear_container.assert_called()
locker._poll_phone_check.assert_called_once()
assert locker._phone_future is not None
def test_handle_startup_verified_unlocks_directly(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test verified result shows success screen then unlocks via after()."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "unlock_screen", MagicMock())
object.__setattr__(locker.root, "after", MagicMock())
locker._handle_startup_phone_result("verified", "Workout verified! (1 session)")
# unlock_screen is deferred via root.after, not called directly
locker.unlock_screen.assert_not_called()
assert locker.workout_data["type"] == "phone_verified"
locker.root.after.assert_called_once_with(1500, locker.unlock_screen)
def test_handle_startup_not_verified_shows_retry_and_sick(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test not_verified result shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._handle_startup_phone_result(
"not_verified", "No workout found on phone today"
)
locker._show_retry_and_sick.assert_called_once()
def test_handle_startup_too_short_shows_retry_and_sick(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test too_short result shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._handle_startup_phone_result(
"too_short", "Workout too short! 25 min logged, need at least 50 min."
)
locker._show_retry_and_sick.assert_called_once()
call_args = locker._show_retry_and_sick.call_args[0][0]
assert "too short" in call_args.lower()
def test_handle_startup_stale_shows_retry_and_sick(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test stale result shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._handle_startup_phone_result("stale", "Workout too old")
locker._show_retry_and_sick.assert_called_once()
call_args = locker._show_retry_and_sick.call_args[0][0]
assert "reason: stale" in call_args.lower()
def test_handle_startup_no_exercises_shows_retry_and_sick(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no_exercises result shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._handle_startup_phone_result("no_exercises", "No exercises found")
locker._show_retry_and_sick.assert_called_once()
call_args = locker._show_retry_and_sick.call_args[0][0]
assert "reason: no_exercises" in call_args.lower()
def test_handle_startup_clock_tampered_shows_retry_and_sick(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test clock_tampered result shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._handle_startup_phone_result(
"clock_tampered",
"System clock is 600s ahead",
)
locker._show_retry_and_sick.assert_called_once()
call_args = locker._show_retry_and_sick.call_args[0][0]
assert "clock" in call_args.lower()
def test_handle_startup_no_phone_shows_penalty(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no_phone result triggers penalty with default retry+sick callback."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
locker._handle_startup_phone_result("no_phone", "No phone")
locker._show_phone_penalty.assert_called_once_with("No phone")
def test_handle_startup_error_shows_penalty(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test error result triggers penalty with default retry+sick callback."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
locker._handle_startup_phone_result("error", "DB not found")
locker._show_phone_penalty.assert_called_once_with("DB not found")
def test_poll_phone_check_schedules_retry_when_pending(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _poll_phone_check reschedules itself when future is not done."""
locker = create_locker(mock_tk, tmp_path)
mock_future: MagicMock = MagicMock()
mock_future.done.return_value = False
object.__setattr__(locker, "_phone_future", mock_future)
object.__setattr__(locker.root, "after", MagicMock())
locker._poll_phone_check()
locker.root.after.assert_called_once_with(500, locker._poll_phone_check)
def test_poll_phone_check_routes_when_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test _poll_phone_check calls result handler when future is done."""
locker = create_locker(mock_tk, tmp_path)
mock_future: MagicMock = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("no_phone", "No phone")
object.__setattr__(locker, "_phone_future", mock_future)
object.__setattr__(locker, "_handle_startup_phone_result", MagicMock())
locker._poll_phone_check()
locker._handle_startup_phone_result.assert_called_once_with(
"no_phone", "No phone"
)

View File

@ -1,180 +0,0 @@
"""Tests for phone workout verification, phone check, and unlock operations."""
# pylint: disable=protected-access,unused-argument
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from python_pkg.screen_locker._constants import NO_PHONE_EXTRA_LOCKOUT_SECONDS
from python_pkg.screen_locker.screen_lock import (
PHONE_PENALTY_DELAY_DEMO,
PHONE_PENALTY_DELAY_PRODUCTION,
)
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestShowPhonePenalty:
"""Tests for _show_phone_penalty and _update_phone_penalty methods."""
def test_show_phone_penalty_demo_delay(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test demo mode uses short penalty delay."""
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
object.__setattr__(locker, "clear_container", MagicMock())
locker._show_phone_penalty("test message")
# _update_phone_penalty is called once, decrementing by 1
assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_DEMO - 1
def test_show_phone_penalty_production_delay(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test production mode uses long penalty delay (base + no-phone bump)."""
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
object.__setattr__(locker, "clear_container", MagicMock())
locker._show_phone_penalty("test message")
expected = PHONE_PENALTY_DELAY_PRODUCTION + NO_PHONE_EXTRA_LOCKOUT_SECONDS - 1
assert locker.phone_penalty_remaining == expected
def test_update_phone_penalty_countdown(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone penalty countdown decrements."""
locker = create_locker(mock_tk, tmp_path)
locker.phone_penalty_remaining = 5
locker.phone_penalty_label = MagicMock()
locker._update_phone_penalty()
assert locker.phone_penalty_remaining == 4
locker.phone_penalty_label.config.assert_called_once_with(text="5")
locker.root.after.assert_called()
def test_update_phone_penalty_at_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone penalty calls done function when timer reaches zero."""
locker = create_locker(mock_tk, tmp_path)
locker.phone_penalty_remaining = 0
locker.phone_penalty_label = MagicMock()
mock_done = MagicMock()
locker._phone_penalty_done_fn = mock_done
locker._update_phone_penalty()
mock_done.assert_called_once()
def test_show_phone_penalty_default_callback_shows_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test default phone penalty callback shows retry+sick screen."""
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._show_phone_penalty("No phone connected")
# Simulate timer reaching zero by calling the done function
locker._phone_penalty_done_fn()
locker._show_retry_and_sick.assert_called_once_with("No phone connected")
class TestUnlockScreenShutdownAdjustment:
"""Tests for unlock_screen shutdown time adjustment."""
def test_unlock_screen_adjusts_for_phone_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test unlock_screen adjusts shutdown for phone-verified workout."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
locker.workout_data = {"type": "phone_verified"}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
locker.unlock_screen()
locker._adjust_shutdown_time_later.assert_called_once()
def test_unlock_screen_skips_adjustment_for_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test unlock_screen does not adjust for sick day."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
locker.workout_data = {"type": "sick_day"}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
locker.unlock_screen()
locker._adjust_shutdown_time_later.assert_not_called()
def test_unlock_screen_skips_adjustment_no_type(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test unlock_screen does not adjust when no workout type."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
locker.workout_data = {}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
locker.unlock_screen()
locker._adjust_shutdown_time_later.assert_not_called()
def test_unlock_screen_handles_adjustment_failure(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test unlock_screen continues when adjustment fails."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
locker.workout_data = {"type": "phone_verified"}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=False)
)
# Should not raise, should continue with unlock
locker.unlock_screen()
locker._adjust_shutdown_time_later.assert_called_once()
locker.root.after.assert_called()

View File

@ -1,268 +0,0 @@
"""Tests for phone verification coverage gaps (part 2)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestGetWirelessSerial:
"""Tests for _get_wireless_serial method."""
def test_returns_wireless_serial(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns ip:port serial for a wireless device."""
locker = create_locker(mock_tk, tmp_path)
output = "List of devices attached\n192.168.1.42:5555\tdevice\n"
with patch.object(locker, "_run_adb", return_value=(True, output)):
result = locker._get_wireless_serial()
assert result == "192.168.1.42:5555"
def test_returns_none_when_adb_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when adb devices fails."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_run_adb", return_value=(False, "")):
result = locker._get_wireless_serial()
assert result is None
def test_returns_none_when_no_wireless_device(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when only USB devices are connected."""
locker = create_locker(mock_tk, tmp_path)
output = "List of devices attached\nABC123DEF456\tdevice\n"
with patch.object(locker, "_run_adb", return_value=(True, output)):
result = locker._get_wireless_serial()
assert result is None
def test_skips_offline_wireless_device(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test skips offline wireless devices."""
locker = create_locker(mock_tk, tmp_path)
output = "List of devices attached\n192.168.1.42:5555\toffline\n"
with patch.object(locker, "_run_adb", return_value=(True, output)):
result = locker._get_wireless_serial()
assert result is None
class TestTryAdbConnect:
"""Tests for _try_adb_connect method."""
def test_successful_connect(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful ADB connect."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(
locker, "_run_adb", return_value=(True, "connected to 192.168.1.42:5555")
):
result = locker._try_adb_connect("192.168.1.42:5555")
assert result is True
def test_failed_connect_unable(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test connect failure with 'unable' in output."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(
locker, "_run_adb", return_value=(False, "unable to connect")
):
result = locker._try_adb_connect("192.168.1.42:5555")
assert result is False
def test_failed_connect_with_failed(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test connect failure with 'failed' in output."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(
locker,
"_run_adb",
return_value=(False, "connected but failed to authenticate"),
):
result = locker._try_adb_connect("192.168.1.42:5555")
assert result is False
def test_no_connected_in_output(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test connect failure when 'connected' not in output."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(
locker, "_run_adb", return_value=(False, "some random output")
):
result = locker._try_adb_connect("192.168.1.42:5555")
assert result is False
class TestGetLocalSubnetPrefix:
"""Tests for _get_local_subnet_prefix method."""
def test_returns_prefix(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns first three octets of local IP."""
locker = create_locker(mock_tk, tmp_path)
mock_sock = MagicMock()
mock_sock.getsockname.return_value = ("192.168.1.100", 12345)
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
with patch(
"python_pkg.screen_locker._phone_verification.socket.socket",
return_value=mock_sock,
):
result = locker._get_local_subnet_prefix()
assert result == "192.168.1"
def test_returns_none_on_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when socket raises OSError."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._phone_verification.socket.socket",
side_effect=OSError("no network"),
):
result = locker._get_local_subnet_prefix()
assert result is None
class TestTryWirelessReconnect:
"""Tests for _try_wireless_reconnect method."""
def test_returns_false_when_no_prefix(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when subnet prefix can't be determined."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_get_local_subnet_prefix", return_value=None):
result = locker._try_wireless_reconnect()
assert result is False
def test_returns_true_when_probe_succeeds(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True when a probe finds the phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=True),
patch.object(locker, "_has_adb_device", return_value=True),
patch(
"python_pkg.screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is True
def test_returns_false_when_no_probe_succeeds(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when no probe finds the phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch(
"python_pkg.screen_locker._phone_verification.socket.create_connection",
side_effect=OSError("refused"),
),
):
result = locker._try_wireless_reconnect()
assert result is False
def test_probe_connect_succeeds_but_no_device(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test probe passes socket but adb_connect succeeds without device."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=True),
patch.object(locker, "_has_adb_device", return_value=False),
patch(
"python_pkg.screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is False
def test_probe_adb_connect_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test probe where socket connects but adb connect fails."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
patch.object(locker, "_try_adb_connect", return_value=False),
patch(
"python_pkg.screen_locker._phone_verification.socket.create_connection",
) as mock_conn,
):
mock_sock = MagicMock()
mock_sock.__enter__ = MagicMock(return_value=mock_sock)
mock_sock.__exit__ = MagicMock(return_value=False)
mock_conn.return_value = mock_sock
result = locker._try_wireless_reconnect()
assert result is False

View File

@ -1,195 +0,0 @@
"""Tests for scheduled skip date feature in screen_lock.py."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
from python_pkg.screen_locker.screen_lock import ScreenLocker
class TestIsScheduledSkipToday:
"""Tests for ScreenLocker._is_scheduled_skip_today."""
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_returns_false_when_file_absent(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when scheduled_skips.json does not exist."""
locker = self._make_locker(mock_tk, tmp_path)
skip_file = tmp_path / "scheduled_skips.json"
with patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
def test_returns_true_when_today_listed(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns True when today's date is in the skips list."""
locker = self._make_locker(mock_tk, tmp_path)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text(json.dumps([today]))
with patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is True
def test_returns_false_when_today_not_listed(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when today's date is not in the skips list."""
locker = self._make_locker(mock_tk, tmp_path)
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text(json.dumps(["1999-01-01", "2000-06-15"]))
with patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
def test_returns_false_on_corrupt_json(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when the skips file contains invalid JSON."""
locker = self._make_locker(mock_tk, tmp_path)
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text("{not valid json}")
with patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
def test_returns_false_on_read_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when the skips file cannot be read (OSError)."""
locker = self._make_locker(mock_tk, tmp_path)
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text("[]")
with (
patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
skip_file,
),
patch("builtins.open", side_effect=OSError("permission denied")),
):
assert locker._is_scheduled_skip_today() is False
def test_empty_list_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False for an empty skips list."""
locker = self._make_locker(mock_tk, tmp_path)
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text("[]")
with patch(
"python_pkg.screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
class TestScheduledSkipEarlyExit:
"""Tests for _check_non_verify_exits behaviour with scheduled skips."""
@staticmethod
def _write_today_skip(tmp_path: Path) -> None:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text(json.dumps([today]))
def test_exits_on_scheduled_skip_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Screen locker calls sys.exit(0) when today is a scheduled skip."""
self._write_today_skip(tmp_path)
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(mock_tk, tmp_path)
mock_sys_exit.assert_called_once_with(0)
def test_does_not_exit_when_not_scheduled_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Screen locker proceeds normally when today is not a scheduled skip."""
# No file written — _is_scheduled_skip_today returns False
locker = create_locker(mock_tk, tmp_path)
mock_sys_exit.assert_not_called()
assert locker is not None
def test_scheduled_skip_takes_precedence_over_has_logged(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Scheduled skip exits before has_logged or other checks run."""
self._write_today_skip(tmp_path)
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_called_once_with(0)
def test_verify_only_mode_ignores_scheduled_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""verify_only mode does not consult scheduled skips."""
self._write_today_skip(tmp_path)
# verify_only exits because no sick day log, not because of scheduled skip
create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=False,
)
mock_sys_exit.assert_called_once_with(0)

View File

@ -1,420 +0,0 @@
"""Tests for shutdown schedule adjustment coverage gaps (part 2)."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestApplyEarlierShutdown:
"""Tests for _apply_earlier_shutdown method."""
def test_returns_false_when_no_config(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when config can't be read."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_read_shutdown_config", return_value=None):
assert locker._apply_earlier_shutdown("2026-03-21") is False
def test_returns_false_when_save_state_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when saving state fails."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)),
patch.object(locker, "_save_sick_day_state", return_value=False),
):
assert locker._apply_earlier_shutdown("2026-03-21") is False
def test_success_applies_earlier_hours(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful application of earlier shutdown hours."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)),
patch.object(locker, "_save_sick_day_state", return_value=True),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
result = locker._apply_earlier_shutdown("2026-03-21")
assert result is True
mock_write.assert_called_once_with(20, 19, 8)
def test_clamps_to_minimum_18(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test hours are clamped to minimum of 18."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(18, 18, 8)),
patch.object(locker, "_save_sick_day_state", return_value=True),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
locker._apply_earlier_shutdown("2026-03-21")
mock_write.assert_called_once_with(18, 18, 8)
class TestAdjustShutdownTimeEarlier:
"""Tests for _adjust_shutdown_time_earlier method."""
def test_returns_false_when_sick_mode_used_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when sick mode already used today."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_restore_original_config_if_needed"),
patch.object(locker, "_sick_mode_used_today", return_value=True),
):
assert locker._adjust_shutdown_time_earlier() is False
def test_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful adjustment."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_restore_original_config_if_needed"),
patch.object(locker, "_sick_mode_used_today", return_value=False),
patch.object(locker, "_apply_earlier_shutdown", return_value=True),
):
assert locker._adjust_shutdown_time_earlier() is True
def test_handles_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test handles OSError during apply."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_restore_original_config_if_needed"),
patch.object(locker, "_sick_mode_used_today", return_value=False),
patch.object(
locker,
"_apply_earlier_shutdown",
side_effect=OSError("fail"),
),
):
assert locker._adjust_shutdown_time_earlier() is False
def test_handles_value_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test handles ValueError during apply."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_restore_original_config_if_needed"),
patch.object(locker, "_sick_mode_used_today", return_value=False),
patch.object(
locker,
"_apply_earlier_shutdown",
side_effect=ValueError("bad"),
),
):
assert locker._adjust_shutdown_time_earlier() is False
class TestAdjustShutdownTimeLater:
"""Tests for _adjust_shutdown_time_later method."""
def test_returns_false_when_no_config(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when config is missing."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_read_shutdown_config", return_value=None):
assert locker._adjust_shutdown_time_later() is False
def test_success_applies_later_hours(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful later adjustment with restore flag."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
result = locker._adjust_shutdown_time_later()
assert result is True
mock_write.assert_called_once_with(22, 21, 8, restore=True)
def test_clamps_to_max_23(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test hours are clamped to maximum of 23."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(22, 23, 8)),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
locker._adjust_shutdown_time_later()
mock_write.assert_called_once_with(23, 23, 8, restore=True)
def test_handles_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test handles OSError."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(
locker,
"_read_shutdown_config",
side_effect=OSError("fail"),
):
assert locker._adjust_shutdown_time_later() is False
class TestSickModeUsedToday:
"""Tests for _sick_mode_used_today method."""
def test_returns_false_when_no_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when state file doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.exists.return_value = False
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
mock_file,
):
assert locker._sick_mode_used_today() is False
def test_returns_true_when_used_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns True when state matches today."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
state_file.write_text(json.dumps({"date": today}))
assert locker._sick_mode_used_today() is True
def test_returns_false_when_different_date(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when state is from different date."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
state_file.write_text(json.dumps({"date": "2020-01-01"}))
assert locker._sick_mode_used_today() is False
def test_returns_false_on_json_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False on JSONDecodeError."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
state_file.write_text("not json{{{")
assert locker._sick_mode_used_today() is False
class TestSaveSickDayState:
"""Tests for _save_sick_day_state method."""
def test_saves_state_successfully(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test saves state file with correct content."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
result = locker._save_sick_day_state("2026-03-21", 21, 20)
assert result is True
data = json.loads(state_file.read_text())
assert data["date"] == "2026-03-21"
assert data["original_mon_wed_hour"] == 21
assert data["original_thu_sun_hour"] == 20
def test_returns_false_on_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when write fails."""
locker = create_locker(mock_tk, tmp_path)
mock_path = MagicMock()
mock_path.open.side_effect = OSError("permission denied")
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
mock_path,
):
result = locker._save_sick_day_state("2026-03-21", 21, 20)
assert result is False
class TestLoadSickDayState:
"""Tests for _load_sick_day_state method."""
def test_loads_valid_state(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test loads state with all fields present."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
{
"date": "2026-03-20",
"original_mon_wed_hour": 21,
"original_thu_sun_hour": 20,
}
)
)
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
result = locker._load_sick_day_state()
assert result == ("2026-03-20", 21, 20)
def test_returns_none_when_fields_missing(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when required fields are missing."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text(json.dumps({"date": "2026-03-20"}))
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
result = locker._load_sick_day_state()
assert result is None
class TestWriteRestoredConfig:
"""Tests for _write_restored_config method."""
def test_restores_config_and_removes_state(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test restores config values and deletes state file."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text("{}")
with (
patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
),
):
locker._write_restored_config(21, 20, "2026-03-20")
mock_write.assert_called_once_with(21, 20, 8, restore=True)
assert not state_file.exists()
def test_still_removes_state_when_config_read_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test removes state file even when config read returns None."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text("{}")
with (
patch.object(locker, "_read_shutdown_config", return_value=None),
patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
),
):
locker._write_restored_config(21, 20, "2026-03-20")
assert not state_file.exists()

View File

@ -1,316 +0,0 @@
"""Tests for shutdown schedule adjustment coverage gaps (part 3)."""
from __future__ import annotations
import json
import subprocess
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker._constants import ADJUST_SHUTDOWN_SCRIPT
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestRestoreOriginalConfigIfNeeded:
"""Tests for _restore_original_config_if_needed method."""
def test_no_state_file_does_nothing(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test does nothing when no state file exists."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.exists.return_value = False
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
mock_file,
):
locker._restore_original_config_if_needed()
def test_restores_when_state_from_previous_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test restores config when state date differs from today."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
{
"date": "2020-01-01",
"original_mon_wed_hour": 21,
"original_thu_sun_hour": 20,
}
)
)
with (
patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
),
patch.object(locker, "_write_restored_config") as mock_restore,
):
locker._restore_original_config_if_needed()
mock_restore.assert_called_once_with(21, 20, "2020-01-01")
def test_does_not_restore_when_state_from_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test does not restore when state date matches today."""
locker = create_locker(mock_tk, tmp_path)
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
{
"date": today,
"original_mon_wed_hour": 21,
"original_thu_sun_hour": 20,
}
)
)
with (
patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
),
patch.object(locker, "_write_restored_config") as mock_restore,
):
locker._restore_original_config_if_needed()
mock_restore.assert_not_called()
def test_returns_when_loaded_state_is_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns early when loaded state is None."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text(json.dumps({"date": "2020-01-01"}))
with (
patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
),
patch.object(locker, "_write_restored_config") as mock_restore,
):
locker._restore_original_config_if_needed()
mock_restore.assert_not_called()
def test_handles_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test handles OSError when loading state."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.exists.return_value = True
mock_file.open.side_effect = OSError("fail")
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
mock_file,
):
locker._restore_original_config_if_needed()
def test_handles_json_decode_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test handles JSONDecodeError when loading state."""
locker = create_locker(mock_tk, tmp_path)
state_file = tmp_path / "state.json"
state_file.write_text("not valid json{{{")
with patch(
"python_pkg.screen_locker._shutdown.SICK_DAY_STATE_FILE",
state_file,
):
locker._restore_original_config_if_needed()
class TestReadShutdownConfig:
"""Tests for _read_shutdown_config method."""
def test_returns_none_when_file_missing(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when config file doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.exists.return_value = False
with patch(
"python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
mock_file,
):
assert locker._read_shutdown_config() is None
def test_reads_valid_config(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test reads all three config values from file."""
locker = create_locker(mock_tk, tmp_path)
config_file = tmp_path / "shutdown.conf"
config_file.write_text("MON_WED_HOUR=21\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=8\n")
with patch(
"python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
config_file,
):
result = locker._read_shutdown_config()
assert result == (21, 20, 8)
def test_returns_none_when_values_missing(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns None when config has missing keys."""
locker = create_locker(mock_tk, tmp_path)
config_file = tmp_path / "shutdown.conf"
config_file.write_text("MON_WED_HOUR=21\n")
with patch(
"python_pkg.screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
config_file,
):
result = locker._read_shutdown_config()
assert result is None
class TestBuildShutdownCmd:
"""Tests for _build_shutdown_cmd method."""
def test_without_restore(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test command without restore flag."""
locker = create_locker(mock_tk, tmp_path)
cmd = locker._build_shutdown_cmd(21, 20, 8, restore=False)
assert cmd == [
"/usr/bin/sudo",
str(ADJUST_SHUTDOWN_SCRIPT),
"21",
"20",
"8",
]
def test_with_restore(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test command with restore flag."""
locker = create_locker(mock_tk, tmp_path)
cmd = locker._build_shutdown_cmd(21, 20, 8, restore=True)
assert cmd == [
"/usr/bin/sudo",
str(ADJUST_SHUTDOWN_SCRIPT),
"--restore",
"21",
"20",
"8",
]
class TestWriteShutdownConfig:
"""Tests for _write_shutdown_config method."""
def test_returns_false_when_script_missing(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False when adjust script doesn't exist."""
locker = create_locker(mock_tk, tmp_path)
mock_script = MagicMock()
mock_script.exists.return_value = False
with patch(
"python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT",
mock_script,
):
result = locker._write_shutdown_config(21, 20, 8)
assert result is False
def test_success_calls_run_shutdown_cmd(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful config write delegates to _run_shutdown_cmd."""
locker = create_locker(mock_tk, tmp_path)
mock_script = MagicMock()
mock_script.exists.return_value = True
with (
patch(
"python_pkg.screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT",
mock_script,
),
patch.object(locker, "_run_shutdown_cmd", return_value=True) as mock_run,
):
result = locker._write_shutdown_config(21, 20, 8)
assert result is True
mock_run.assert_called_once()
class TestRunShutdownCmd:
"""Tests for _run_shutdown_cmd method."""
def test_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test successful command execution."""
locker = create_locker(mock_tk, tmp_path)
mock_result = MagicMock(stdout="OK\n")
with patch(
"python_pkg.screen_locker._shutdown.subprocess.run",
return_value=mock_result,
):
result = locker._run_shutdown_cmd(["cmd"], 21, 20)
assert result is True
def test_returns_false_on_subprocess_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test returns False on SubprocessError."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._shutdown.subprocess.run",
side_effect=subprocess.CalledProcessError(1, "cmd"),
):
result = locker._run_shutdown_cmd(["cmd"], 21, 20)
assert result is False

View File

@ -1,449 +0,0 @@
"""Tests for sick-budget UI integration, finalize, debt-clear, and dialogs."""
# pylint: disable=protected-access
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker import _sick_tracker
from python_pkg.screen_locker._sick_tracker import SickHistory
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# _ui_flows.py — branches added for sick budget + finalize
# ---------------------------------------------------------------------------
class TestShowRetryAndSickBudget:
"""Tests for budget-aware _show_retry_and_sick."""
def test_shows_sick_button_when_budget_available(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
locker._show_retry_and_sick("nope")
button_texts = {
call.args[1] for call in mock_tk.Button.call_args_list if len(call.args) > 1
}
# Buttons are created via the helper which sets text via kwarg "text".
button_texts |= {
call.kwargs.get("text") for call in mock_tk.Button.call_args_list
}
assert "I'm sick" in button_texts
def test_hides_sick_button_when_budget_exhausted(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
full = SickHistory(sick_days=["2026-05-09"] * 99)
with (
patch.object(_sick_tracker, "load_history", return_value=full),
patch.object(_sick_tracker, "is_budget_exhausted", return_value=True),
):
locker._show_retry_and_sick("nope")
button_texts: set[str] = set()
for call in mock_tk.Button.call_args_list:
button_texts.add(call.kwargs.get("text", ""))
assert "I'm sick" not in button_texts
class TestProceedToSickCountdownLoadsHistory:
"""Covers the no-cache branch of _proceed_to_sick_countdown."""
def test_loads_history_when_cache_missing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=False)
)
object.__setattr__(
locker,
"_adjust_shutdown_time_earlier",
MagicMock(return_value=True),
)
with patch.object(
_sick_tracker, "load_history", return_value=SickHistory()
) as mock_load:
locker._proceed_to_sick_countdown()
mock_load.assert_called_once()
assert hasattr(locker, "_sick_history_cache")
class TestFinalizeSickDay:
"""Covers _finalize_sick_day branches including commitment penalty."""
def test_marks_commitment_broken_and_writes_debt(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {}
history = SickHistory(commitments={"2026-05-10": True})
locker._sick_history_cache = history
object.__setattr__(locker, "unlock_screen", MagicMock())
with (
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
patch.object(_sick_tracker, "save_history", return_value=True),
):
locker._finalize_sick_day()
assert locker.workout_data["broke_commitment"] == "true"
assert locker.workout_data["type"] == "sick_day"
assert "debt" in locker.workout_data
locker.unlock_screen.assert_called_once()
def test_loads_history_when_cache_missing(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {}
object.__setattr__(locker, "unlock_screen", MagicMock())
with (
patch.object(
_sick_tracker, "load_history", return_value=SickHistory()
) as mock_load,
patch.object(_sick_tracker, "save_history", return_value=True),
):
locker._finalize_sick_day()
mock_load.assert_called_once()
locker.unlock_screen.assert_called_once()
# ---------------------------------------------------------------------------
# screen_lock.py — _clear_debt_on_verified_workout branches
# ---------------------------------------------------------------------------
class TestClearDebtOnVerifiedWorkout:
"""Tests for _clear_debt_on_verified_workout."""
def test_returns_none_when_not_phone_verified(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
assert locker._clear_debt_on_verified_workout() is None
def test_returns_zero_when_no_debt(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
with patch.object(
_sick_tracker, "load_history", return_value=SickHistory(debt=0)
):
assert locker._clear_debt_on_verified_workout() == 0
def test_decrements_when_debt_positive(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
history = SickHistory(debt=2)
with (
patch.object(_sick_tracker, "load_history", return_value=history),
patch.object(_sick_tracker, "save_history", return_value=True) as mock_save,
):
assert locker._clear_debt_on_verified_workout() == 1
mock_save.assert_called_once()
class TestUnlockScreenCommitmentPrompt:
"""Tests for unlock_screen branches around commitment prompt + debt label."""
def test_phone_verified_schedules_commitment_prompt(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
locker.log_file = tmp_path / "log.json"
object.__setattr__(locker, "save_workout_log", MagicMock())
object.__setattr__(
locker,
"_try_adjust_shutdown_for_workout",
MagicMock(return_value=False),
)
object.__setattr__(
locker,
"_clear_debt_on_verified_workout",
MagicMock(return_value=0),
)
locker.unlock_screen()
# The last after() call schedules the commitment prompt closure.
last_call = locker.root.after.call_args_list[-1]
assert last_call.args[0] == 1500
def test_non_verified_schedules_close_directly(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "sick_day"}
locker.log_file = tmp_path / "log.json"
object.__setattr__(locker, "save_workout_log", MagicMock())
object.__setattr__(
locker,
"_try_adjust_shutdown_for_workout",
MagicMock(return_value=False),
)
object.__setattr__(
locker,
"_clear_debt_on_verified_workout",
MagicMock(return_value=None),
)
locker.unlock_screen()
# close() goes through root.after directly.
locker.root.after.assert_called_with(1500, locker.close)
def test_renders_debt_label_when_positive(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
locker.log_file = tmp_path / "log.json"
object.__setattr__(locker, "save_workout_log", MagicMock())
object.__setattr__(
locker,
"_try_adjust_shutdown_for_workout",
MagicMock(return_value=True),
)
object.__setattr__(
locker,
"_clear_debt_on_verified_workout",
MagicMock(return_value=2),
)
locker.unlock_screen()
# _text was called via mock_tk.Label; just assert a Label call mentions debt.
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
assert any("Workout debt: 2" in t for t in labels)
# ---------------------------------------------------------------------------
# _sick_dialog.py — UI mixin
# ---------------------------------------------------------------------------
class TestShowSickJustification:
"""Tests for the structured sick justification dialog."""
def test_renders_form_without_commitment(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "load_history", return_value=SickHistory()):
locker._show_sick_justification()
assert locker._sick_history_cache.sick_days == []
assert hasattr(locker, "_sick_submit_button")
# Submit button starts enabled (no commitment).
# config(state="disabled") only called for commitment path.
for call in locker._sick_submit_button.config.call_args_list:
assert call.kwargs.get("state") != "disabled"
def test_renders_form_with_commitment_disables_submit(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
history = SickHistory(commitments={"2026-05-10": True})
with (
patch.object(_sick_tracker, "load_history", return_value=history),
patch.object(_sick_tracker, "had_commitment_for_today", return_value=True),
):
locker._show_sick_justification()
# Submit button was disabled and forced-delay started.
states = [
call.kwargs.get("state")
for call in locker._sick_submit_button.config.call_args_list
]
assert "disabled" in states
def test_renders_recent_history_when_present(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
history = SickHistory(
justifications=[
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
],
)
with patch.object(_sick_tracker, "load_history", return_value=history):
locker._show_sick_justification()
labels = [call.kwargs.get("text", "") for call in mock_tk.Label.call_args_list]
assert any("Recent sick days" in t for t in labels)
class TestUpdateCommitmentForcedDelay:
"""Tests for _update_commitment_forced_delay."""
def test_ticks_down(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._sick_submit_button = MagicMock()
locker._commitment_forced_remaining = 3
locker._update_commitment_forced_delay()
assert locker._commitment_forced_remaining == 2
locker.root.after.assert_called()
def test_enables_when_done(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._sick_submit_button = MagicMock()
locker._commitment_forced_remaining = 0
locker._update_commitment_forced_delay()
locker._sick_submit_button.config.assert_called_with(
text="SUBMIT", state="normal"
)
class TestSubmitSickJustification:
"""Tests for _submit_sick_justification validation + persistence."""
def _setup_locker(
self,
mock_tk: MagicMock,
tmp_path: Path,
*,
fields: dict[str, object] | None = None,
) -> object:
defaults: dict[str, object] = {
"symptom": "fever",
"onset": "last night",
"severity": 7,
"text": "x" * 200,
}
if fields:
defaults.update(fields)
locker = create_locker(mock_tk, tmp_path)
locker._sick_history_cache = SickHistory()
locker._sick_symptom_var = MagicMock()
locker._sick_symptom_var.get.return_value = defaults["symptom"]
locker._sick_onset_var = MagicMock()
locker._sick_onset_var.get.return_value = defaults["onset"]
locker._sick_severity_var = MagicMock()
locker._sick_severity_var.get.return_value = defaults["severity"]
locker._sick_text_widget = MagicMock()
locker._sick_text_widget.get.return_value = defaults["text"]
locker._sick_error_label = MagicMock()
object.__setattr__(locker, "_proceed_to_sick_countdown", MagicMock())
return locker
def test_validation_failure_displays_error(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = self._setup_locker(mock_tk, tmp_path, fields={"symptom": ""})
locker._submit_sick_justification()
locker._sick_error_label.config.assert_called_once()
locker._proceed_to_sick_countdown.assert_not_called()
def test_severity_tcl_error_treated_as_invalid(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = self._setup_locker(mock_tk, tmp_path)
locker._sick_severity_var.get.side_effect = ValueError("bad")
locker._submit_sick_justification()
locker._sick_error_label.config.assert_called_once()
def test_save_failure_displays_error(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = self._setup_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "save_history", return_value=False):
locker._submit_sick_justification()
locker._sick_error_label.config.assert_called_once()
locker._proceed_to_sick_countdown.assert_not_called()
def test_success_proceeds_to_countdown(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = self._setup_locker(mock_tk, tmp_path)
with patch.object(_sick_tracker, "save_history", return_value=True):
locker._submit_sick_justification()
locker._proceed_to_sick_countdown.assert_called_once()
class TestCommitmentPrompt:
"""Tests for _show_commitment_prompt + _tick_commitment_timeout + answer."""
def test_show_prompt_renders_buttons(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
on_done = MagicMock()
locker._show_commitment_prompt(on_done=on_done)
assert locker._commitment_done_fn is on_done
assert locker._commitment_remaining > 0
def test_tick_decrements(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
locker._commitment_remaining = 2
locker._commitment_timer_label = MagicMock()
locker._tick_commitment_timeout()
assert locker._commitment_remaining == 1
locker.root.after.assert_called()
def test_tick_zero_auto_answers_no(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
on_done = MagicMock()
locker._commitment_done_fn = on_done
locker._commitment_remaining = 0
locker._commitment_timer_label = MagicMock()
locker._tick_commitment_timeout()
on_done.assert_called_once()
def test_answer_yes_persists_commitment(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
on_done = MagicMock()
locker._commitment_done_fn = on_done
history = SickHistory()
with (
patch.object(_sick_tracker, "load_history", return_value=history),
patch.object(_sick_tracker, "save_history", return_value=True) as mock_save,
):
locker._answer_commitment(commit=True)
mock_save.assert_called_once()
on_done.assert_called_once()
assert locker._commitment_done_fn is None
def test_answer_no_skips_persistence(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
on_done = MagicMock()
locker._commitment_done_fn = on_done
with patch.object(_sick_tracker, "save_history") as mock_save:
locker._answer_commitment(commit=False)
mock_save.assert_not_called()
on_done.assert_called_once()
def test_answer_with_no_done_fn_is_safe(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
locker = create_locker(mock_tk, tmp_path)
# No _commitment_done_fn attribute set.
locker._answer_commitment(commit=False)
class TestDisablePaste:
"""Tests for the _disable_paste helper."""
def test_swallows_tcl_error(self) -> None:
from python_pkg.screen_locker._sick_dialog import _disable_paste
widget = MagicMock()
import tkinter as tk
widget.bind.side_effect = tk.TclError("nope")
# Should not raise.
_disable_paste(widget)

View File

@ -1,386 +0,0 @@
"""Tests for the sick-day tracker pure-logic module."""
# pylint: disable=protected-access
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from python_pkg.screen_locker import _sick_tracker
from python_pkg.screen_locker._constants import (
SICK_BUDGET_PER_7_DAYS,
SICK_BUDGET_PER_30_DAYS,
SICK_BUDGET_PER_90_DAYS,
SICK_COMMITMENT_PENALTY_DAYS,
SICK_HISTORY_REVIEW_COUNT,
SICK_JUSTIFICATION_MIN_CHARS,
SICK_LOCKOUT_MULTIPLIER_PER_RECENT,
SICK_LOCKOUT_SECONDS,
)
from python_pkg.screen_locker._sick_tracker import (
JustificationDraft,
SickHistory,
add_justification,
add_sick_day,
budget_summary,
clear_one_debt,
compute_lockout_seconds,
count_in_window,
format_recent_justifications,
had_commitment_for_today,
is_budget_exhausted,
load_history,
mark_commitment_broken,
recent_justifications,
record_commitment_for_tomorrow,
save_history,
validate_justification,
)
if TYPE_CHECKING:
from pathlib import Path
_TODAY = "2026-05-10"
class TestLoadHistory:
"""Tests for load_history."""
def test_returns_empty_when_file_missing(self) -> None:
history = load_history()
assert history == SickHistory()
def test_reads_existing_file(self, tmp_path: Path) -> None:
target = tmp_path / "sick_history.json"
target.write_text(
'{"sick_days": ["2026-05-01"], "debt": 2,'
' "commitments": {"2026-05-10": true},'
' "broken_commitments": ["2026-05-09"],'
' "justifications": [{"date": "2026-05-01"}]}'
)
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
history = load_history()
assert history.sick_days == ["2026-05-01"]
assert history.debt == 2
assert history.commitments == {"2026-05-10": True}
assert history.broken_commitments == ["2026-05-09"]
assert history.justifications == [{"date": "2026-05-01"}]
def test_returns_empty_on_corrupt_json(self, tmp_path: Path) -> None:
target = tmp_path / "sick_history.json"
target.write_text("not json")
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
assert load_history() == SickHistory()
def test_returns_empty_on_oserror(self, tmp_path: Path) -> None:
target = tmp_path / "sick_history.json"
target.write_text("{}")
with (
patch.object(_sick_tracker, "SICK_HISTORY_FILE", target),
patch.object(type(target), "open", side_effect=OSError("boom")),
):
assert load_history() == SickHistory()
class TestSaveHistory:
"""Tests for save_history."""
def test_persists_history(self, tmp_path: Path) -> None:
target = tmp_path / "sick_history.json"
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
history = SickHistory(sick_days=["2026-05-01"], debt=1)
assert save_history(history) is True
reloaded = load_history()
assert reloaded == history
def test_returns_false_on_oserror(self, tmp_path: Path) -> None:
target = tmp_path / "missing_dir" / "sick_history.json"
with patch.object(_sick_tracker, "SICK_HISTORY_FILE", target):
assert save_history(SickHistory()) is False
class TestCountInWindow:
"""Tests for count_in_window."""
def test_counts_only_within_window(self) -> None:
history = SickHistory(
sick_days=[
"2026-05-09", # 1 day ago: in 7d, 30d, 90d
"2026-05-03", # 7 days ago: NOT in 7d (cutoff exclusive)
"2026-04-25", # 15 days ago: NOT in 7d, in 30d, 90d
"2026-01-01", # ~130 days ago: outside 90d
],
)
assert count_in_window(history, 7, today=_TODAY) == 1
assert count_in_window(history, 30, today=_TODAY) == 3
assert count_in_window(history, 90, today=_TODAY) == 3
def test_skips_invalid_date_strings(self) -> None:
history = SickHistory(sick_days=["bad-date", "2026-05-09"])
assert count_in_window(history, 7, today=_TODAY) == 1
def test_returns_zero_when_today_invalid(self) -> None:
history = SickHistory(sick_days=["2026-05-09"])
assert count_in_window(history, 7, today="bogus") == 0
def test_uses_today_default_when_none(self) -> None:
history = SickHistory(sick_days=[])
assert count_in_window(history, 7) == 0
class TestIsBudgetExhausted:
"""Tests for is_budget_exhausted."""
def test_false_when_under_budget(self) -> None:
assert is_budget_exhausted(SickHistory(), today=_TODAY) is False
def test_true_when_weekly_exhausted(self) -> None:
history = SickHistory(
sick_days=["2026-05-09"] * SICK_BUDGET_PER_7_DAYS,
)
assert is_budget_exhausted(history, today=_TODAY) is True
def test_true_when_monthly_exhausted(self) -> None:
# Spread far enough apart to all be in 30d but not 7d.
history = SickHistory(
sick_days=[
"2026-05-08",
"2026-04-28",
"2026-04-18",
][:SICK_BUDGET_PER_30_DAYS],
)
assert is_budget_exhausted(history, today=_TODAY) is True
def test_true_when_quarterly_exhausted(self) -> None:
# All in 90d but only 1 in 30d.
days = [
"2026-05-09",
"2026-04-01",
"2026-03-15",
"2026-03-10",
"2026-03-05",
"2026-03-01",
"2026-02-28",
"2026-02-25",
"2026-02-20",
"2026-02-15",
]
history = SickHistory(sick_days=days[:SICK_BUDGET_PER_90_DAYS])
assert is_budget_exhausted(history, today=_TODAY) is True
class TestComputeLockoutSeconds:
"""Tests for compute_lockout_seconds."""
def test_base_when_no_recent(self) -> None:
assert (
compute_lockout_seconds(SickHistory(), today=_TODAY) == SICK_LOCKOUT_SECONDS
)
def test_doubles_per_recent(self) -> None:
history = SickHistory(sick_days=["2026-05-09", "2026-04-20"])
recent = 2 # both within 30d
expected = SICK_LOCKOUT_SECONDS * (SICK_LOCKOUT_MULTIPLIER_PER_RECENT**recent)
assert compute_lockout_seconds(history, today=_TODAY) == expected
class TestBudgetSummary:
"""Tests for budget_summary."""
def test_renders_all_windows_and_debt(self) -> None:
history = SickHistory(sick_days=["2026-05-09"], debt=3)
summary = budget_summary(history, today=_TODAY)
assert "Sick:" in summary
assert "1/" in summary
assert "Debt: 3" in summary
class TestAddSickDay:
"""Tests for add_sick_day."""
def test_adds_today_and_increments_debt(self) -> None:
history = SickHistory()
new_debt = add_sick_day(history, today=_TODAY)
assert history.sick_days == [_TODAY]
assert new_debt == 1
def test_idempotent_on_same_day(self) -> None:
history = SickHistory(sick_days=[_TODAY], debt=0)
new_debt = add_sick_day(history, today=_TODAY)
assert history.sick_days == [_TODAY]
# Debt still increments by 1 even if the date is already present.
assert new_debt == 1
def test_double_penalty_when_commitment_broken(self) -> None:
history = SickHistory(broken_commitments=[_TODAY])
new_debt = add_sick_day(history, today=_TODAY)
assert new_debt == SICK_COMMITMENT_PENALTY_DAYS
class TestClearOneDebt:
"""Tests for clear_one_debt."""
def test_decrements_when_positive(self) -> None:
history = SickHistory(debt=2)
assert clear_one_debt(history) == 1
assert history.debt == 1
def test_clamped_at_zero(self) -> None:
history = SickHistory(debt=0)
assert clear_one_debt(history) == 0
class TestRecordCommitment:
"""Tests for record_commitment_for_tomorrow + had_commitment_for_today."""
def test_records_for_tomorrow(self) -> None:
history = SickHistory()
result = record_commitment_for_tomorrow(history, today=_TODAY)
assert result == "2026-05-11"
assert history.commitments["2026-05-11"] is True
def test_returns_today_when_today_invalid(self) -> None:
history = SickHistory()
result = record_commitment_for_tomorrow(history, today="bogus")
assert result == "bogus"
assert history.commitments == {}
def test_had_commitment_returns_true(self) -> None:
history = SickHistory(commitments={_TODAY: True})
assert had_commitment_for_today(history, today=_TODAY) is True
def test_had_commitment_returns_false(self) -> None:
assert had_commitment_for_today(SickHistory(), today=_TODAY) is False
class TestMarkCommitmentBroken:
"""Tests for mark_commitment_broken."""
def test_appends_when_committed(self) -> None:
history = SickHistory(commitments={_TODAY: True})
mark_commitment_broken(history, today=_TODAY)
assert history.broken_commitments == [_TODAY]
def test_idempotent(self) -> None:
history = SickHistory(commitments={_TODAY: True}, broken_commitments=[_TODAY])
mark_commitment_broken(history, today=_TODAY)
assert history.broken_commitments == [_TODAY]
def test_noop_when_no_commitment(self) -> None:
history = SickHistory()
mark_commitment_broken(history, today=_TODAY)
assert history.broken_commitments == []
class TestValidateJustification:
"""Tests for validate_justification."""
def _good_text(self) -> str:
return "x" * SICK_JUSTIFICATION_MIN_CHARS
def _draft(
self,
*,
symptom: str | None = None,
onset: str | None = None,
severity: int | None = None,
text: str | None = None,
) -> JustificationDraft:
return JustificationDraft(
symptom="fever" if symptom is None else symptom,
onset="last night" if onset is None else onset,
severity=7 if severity is None else severity,
text=self._good_text() if text is None else text,
)
def test_returns_none_when_valid(self) -> None:
assert validate_justification(self._draft()) is None
def test_rejects_blank_symptom(self) -> None:
assert validate_justification(self._draft(symptom=" ")) is not None
def test_rejects_blank_onset(self) -> None:
assert validate_justification(self._draft(onset="")) is not None
@pytest.mark.parametrize("severity", [0, 11, -1])
def test_rejects_severity_out_of_range(self, severity: int) -> None:
assert validate_justification(self._draft(severity=severity)) is not None
def test_rejects_short_text(self) -> None:
assert validate_justification(self._draft(text="too short")) is not None
class TestAddJustification:
"""Tests for add_justification."""
def _draft(self, text: str = " full description text ") -> JustificationDraft:
return JustificationDraft(
symptom="fever",
onset="last night",
severity=7,
text=text,
)
def test_appends_entry_with_hmac_when_key_present(self) -> None:
history = SickHistory()
with patch.object(_sick_tracker, "compute_entry_hmac", return_value="deadbeef"):
entry = add_justification(history, self._draft(), today=_TODAY)
assert history.justifications == [entry]
assert entry["hmac"] == "deadbeef"
assert entry["text"] == "full description text"
assert entry["symptom"] == "fever"
assert entry["severity"] == 7
assert entry["date"] == _TODAY
def test_omits_hmac_when_key_unavailable(self) -> None:
history = SickHistory()
with patch.object(_sick_tracker, "compute_entry_hmac", return_value=None):
entry = add_justification(
history,
self._draft(text="full description"),
today=_TODAY,
)
assert "hmac" not in entry
class TestRecentJustifications:
"""Tests for recent_justifications + format_recent_justifications."""
def test_returns_last_n(self) -> None:
history = SickHistory(
justifications=[{"i": i} for i in range(5)],
)
assert recent_justifications(history, 2) == [{"i": 3}, {"i": 4}]
def test_returns_empty_list_when_n_zero(self) -> None:
history = SickHistory(justifications=[{"i": 0}])
assert recent_justifications(history, 0) == []
def test_default_n_is_review_count(self) -> None:
history = SickHistory(
justifications=[{"i": i} for i in range(SICK_HISTORY_REVIEW_COUNT + 5)],
)
assert len(recent_justifications(history)) == SICK_HISTORY_REVIEW_COUNT
def test_format_returns_empty_when_no_history(self) -> None:
assert format_recent_justifications(SickHistory()) == ""
def test_format_renders_lines(self) -> None:
history = SickHistory(
justifications=[
{"date": "2026-05-01", "symptom": "fever", "severity": 7},
{"date": "2026-04-15", "symptom": "headache", "severity": 4},
],
)
out = format_recent_justifications(history)
assert "2026-05-01" in out
assert "fever" in out
assert "headache" in out
def test_format_handles_missing_fields(self) -> None:
history = SickHistory(justifications=[{}])
out = format_recent_justifications(history)
assert "?" in out

View File

@ -1,108 +0,0 @@
"""Tests for _time_check NTP clock skew detection."""
from __future__ import annotations
import struct
import time
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker._time_check import (
_NTP_EPOCH_OFFSET,
_query_ntp_offset,
check_clock_skew,
)
class TestQueryNtpOffset:
"""Tests for _query_ntp_offset."""
def test_returns_offset_on_success(self) -> None:
"""Test returns float offset when NTP server responds."""
now = time.time()
# Build a fake NTP response with server time close to now
server_ntp = int(now + _NTP_EPOCH_OFFSET)
fraction = 0
response = b"\x00" * 40 + struct.pack("!II", server_ntp, fraction)
mock_socket = MagicMock()
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
mock_socket.__exit__ = MagicMock(return_value=False)
mock_socket.recvfrom.return_value = (response, ("pool.ntp.org", 123))
with patch("socket.socket", return_value=mock_socket):
offset = _query_ntp_offset()
assert offset is not None
assert abs(offset) < 5 # Should be very close to zero
def test_returns_none_on_oserror(self) -> None:
"""Test returns None when socket fails."""
mock_socket = MagicMock()
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
mock_socket.__exit__ = MagicMock(return_value=False)
mock_socket.sendto.side_effect = OSError("network unreachable")
with patch("socket.socket", return_value=mock_socket):
offset = _query_ntp_offset()
assert offset is None
def test_returns_none_on_short_response(self) -> None:
"""Test returns None when NTP response is too short."""
mock_socket = MagicMock()
mock_socket.__enter__ = MagicMock(return_value=mock_socket)
mock_socket.__exit__ = MagicMock(return_value=False)
mock_socket.recvfrom.return_value = (b"\x00" * 10, ("pool.ntp.org", 123))
with patch("socket.socket", return_value=mock_socket):
offset = _query_ntp_offset()
assert offset is None
class TestCheckClockSkew:
"""Tests for check_clock_skew."""
def test_ok_within_threshold(self) -> None:
"""Test returns ok when clock offset is small."""
with patch(
"python_pkg.screen_locker._time_check._query_ntp_offset",
return_value=2.5,
):
ok, message = check_clock_skew()
assert ok is True
assert "OK" in message
def test_fails_when_skew_exceeds_threshold(self) -> None:
"""Test returns failure when clock offset exceeds max."""
with patch(
"python_pkg.screen_locker._time_check._query_ntp_offset",
return_value=600.0,
):
ok, message = check_clock_skew()
assert ok is False
assert "600" in message
def test_ntp_unreachable_passes(self) -> None:
"""Test returns ok when NTP server is unreachable (fail-open)."""
with patch(
"python_pkg.screen_locker._time_check._query_ntp_offset",
return_value=None,
):
ok, message = check_clock_skew()
assert ok is True
assert "skipped" in message.lower()
def test_negative_offset_detected(self) -> None:
"""Test detects clock ahead with negative offset."""
with patch(
"python_pkg.screen_locker._time_check._query_ntp_offset",
return_value=-400.0,
):
ok, message = check_clock_skew()
assert ok is False
assert "ahead" in message.lower()

View File

@ -1,194 +0,0 @@
"""Tests for UI transitions, timer logic, and sick day screens."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestUITransitions:
"""Tests for UI state transitions."""
def test_clear_container(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test clear_container destroys all child widgets."""
locker = create_locker(mock_tk, tmp_path)
# Set up mock children
mock_child1 = MagicMock()
mock_child2 = MagicMock()
locker.container.winfo_children.return_value = [
mock_child1,
mock_child2,
]
locker.clear_container()
mock_child1.destroy.assert_called_once()
mock_child2.destroy.assert_called_once()
def test_unlock_screen_saves_and_schedules_close(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test unlock_screen saves log and schedules close."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
locker.workout_data = {"type": "phone_verified"}
locker.unlock_screen()
# Check that after() was called to schedule close
locker.root.after.assert_called()
def test_lockout_starts_countdown(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test lockout initializes countdown timer."""
locker = create_locker(mock_tk, tmp_path)
locker.lockout()
# lockout() sets remaining_time to lockout_time (10 in demo mode)
# then calls update_lockout_countdown() which decrements it by 1
assert locker.remaining_time == 9 # 10 - 1 after first update
def test_close_destroys_root_and_exits(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test close destroys root window and exits."""
locker = create_locker(mock_tk, tmp_path)
locker.close()
locker.root.destroy.assert_called_once()
mock_sys_exit.assert_called_with(0)
class TestTimerLogic:
"""Tests for timer countdown logic."""
def test_update_lockout_countdown_decrements(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test countdown decrements remaining time."""
locker = create_locker(mock_tk, tmp_path)
locker.remaining_time = 5
locker.countdown_label = MagicMock()
locker.update_lockout_countdown()
assert locker.remaining_time == 4
locker.root.after.assert_called_with(1000, locker.update_lockout_countdown)
def test_update_lockout_countdown_at_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test countdown at zero restarts phone check."""
locker = create_locker(mock_tk, tmp_path)
locker.remaining_time = 0
locker.countdown_label = MagicMock()
object.__setattr__(locker, "_start_phone_check", MagicMock())
locker.update_lockout_countdown()
locker._start_phone_check.assert_called_once()
class TestAskIfSick:
"""Tests for ask_if_sick method."""
def test_ask_if_sick_invokes_justification_dialog(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""ask_if_sick now delegates to the structured justification dialog."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_sick_justification", MagicMock())
locker.ask_if_sick()
locker._show_sick_justification.assert_called_once_with()
class TestGetSickDayStatus:
"""Tests for _get_sick_day_status method."""
def test_already_adjusted_today(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test status when sick mode already used today."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=True)
)
text, color = locker._get_sick_day_status()
assert "already adjusted" in text
assert color == "#ffaa00"
def test_adjustment_success(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test status when shutdown time adjusted successfully."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True)
)
text, color = locker._get_sick_day_status()
assert "earlier" in text
assert color == "#00aa00"
def test_adjustment_failure(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test status when adjustment fails."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=False)
)
text, color = locker._get_sick_day_status()
assert "Could not adjust" in text
assert color == "#ff4444"
class TestShowRetryAndSick:
"""Tests for _show_retry_and_sick method."""
def test_displays_buttons(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test _show_retry_and_sick shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
locker._show_retry_and_sick("Test message")
locker.clear_container.assert_called_once()
mock_tk.Label.assert_called()
mock_tk.Button.assert_called()

View File

@ -1,50 +0,0 @@
"""Tests for sick-day countdown flow."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from python_pkg.screen_locker._sick_tracker import SickHistory
from python_pkg.screen_locker.screen_lock import (
SICK_LOCKOUT_SECONDS,
)
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestProceedToSickCountdown:
"""Tests for _proceed_to_sick_countdown."""
def test_sets_up_countdown(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Countdown initialises with computed escalated value."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(
locker, "_sick_mode_used_today", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_adjust_shutdown_time_earlier", MagicMock(return_value=True)
)
locker._sick_history_cache = SickHistory()
locker._proceed_to_sick_countdown()
locker.clear_container.assert_called_once()
# First tick has decremented once -> base - 1
assert locker.sick_remaining_time == SICK_LOCKOUT_SECONDS - 1
class TestShowSickDayUi:
"""Tests for _show_sick_day_ui method."""
def test_displays_ui(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""_show_sick_day_ui displays labels with explicit countdown."""
locker = create_locker(mock_tk, tmp_path)
locker._show_sick_day_ui("Test status", "#00aa00", 120)
mock_tk.Label.assert_called()
assert hasattr(locker, "sick_countdown_label")

View File

@ -1,35 +0,0 @@
"""Tests for UI flows coverage gaps (part 2)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestUpdateSickCountdownAtZero:
"""Tests for _update_sick_countdown at zero remaining."""
def test_records_sick_day_and_unlocks_at_zero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test countdown at zero records sick day and calls unlock."""
locker = create_locker(mock_tk, tmp_path)
locker.sick_remaining_time = 0
locker.sick_countdown_label = MagicMock()
locker.workout_data = {}
locker.log_file = tmp_path / "workout_log.json"
object.__setattr__(locker, "unlock_screen", MagicMock())
locker._update_sick_countdown()
assert locker.workout_data["type"] == "sick_day"
assert locker.workout_data["note"] == "Sick day - shutdown moved earlier"
locker.unlock_screen.assert_called_once()

View File

@ -1,370 +0,0 @@
"""Tests for post-sick-day workout verification (--verify-workout)."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
import pytest
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestIsSickDayLog:
"""Tests for _is_sick_day_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_sick_day_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_sick_day_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_sick_day_log() is False
def test_today_not_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when today's entry is a regular workout."""
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_sick_day_log() is False
def test_today_is_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return True when today's entry is a sick day."""
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": "sick_day"}},
}
)
)
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is True
def test_entry_missing_workout_data(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when entry has no workout_data key."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(json.dumps({today: {}}))
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is False
class TestVerifyOnlyInit:
"""Tests for ScreenLocker initialization with verify_only=True."""
def test_verify_only_exits_when_no_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Exit when verify_only but no sick day logged today."""
mock_sys_exit.side_effect = SystemExit(0)
with pytest.raises(SystemExit):
create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=False,
)
mock_sys_exit.assert_called_once_with(0)
def test_verify_only_starts_when_sick_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Start verification window when sick day is logged."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
assert locker.verify_only is True
mock_sys_exit.assert_not_called()
def test_verify_only_sets_title(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Verify window title includes [VERIFY]."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
locker.root.title.assert_called_with("Workout Locker [VERIFY]")
class TestSetupVerifyWindow:
"""Tests for _setup_verify_window."""
def test_sets_geometry_and_protocol(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Verify window uses 600x400 geometry and WM_DELETE_WINDOW."""
locker = create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
locker.root.geometry.assert_called_with("600x400")
locker.root.configure.assert_called_with(
bg="#1a1a1a",
cursor="arrow",
)
locker.root.protocol.assert_called_with(
"WM_DELETE_WINDOW",
locker.close,
)
class TestStartVerifyWorkoutCheck:
"""Tests for _start_verify_workout_check."""
def test_starts_phone_check_and_polls(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Start phone verification and begin polling."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("verified", "ok")),
)
object.__setattr__(
locker,
"_poll_verify_workout_check",
MagicMock(),
)
locker._start_verify_workout_check()
assert locker._phone_future is not None
locker._poll_verify_workout_check.assert_called_once()
class TestPollVerifyWorkoutCheck:
"""Tests for _poll_verify_workout_check."""
def test_schedules_retry_when_not_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Re-schedule polling when future is not done."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = False
locker._phone_future = mock_future
locker._poll_verify_workout_check()
locker.root.after.assert_called_with(
500,
locker._poll_verify_workout_check,
)
def test_handles_result_when_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Route to result handler when future is done."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("verified", "Found workout")
locker._phone_future = mock_future
object.__setattr__(
locker,
"_handle_verify_workout_result",
MagicMock(),
)
locker._poll_verify_workout_check()
locker._handle_verify_workout_result.assert_called_once_with(
"verified",
"Found workout",
)
class TestHandleVerifyWorkoutResult:
"""Tests for _handle_verify_workout_result."""
def test_verified_adjusts_shutdown_and_saves(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""On verified: adjust shutdown, save log, show success."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
object.__setattr__(
locker,
"_adjust_shutdown_time_later",
MagicMock(return_value=True),
)
locker._handle_verify_workout_result("verified", "1 session found")
assert locker.workout_data["type"] == "phone_verified"
assert locker.workout_data["after_sick_day"] == "true"
locker._adjust_shutdown_time_later.assert_called_once()
locker.root.after.assert_called()
def test_verified_without_adjustment(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""On verified but adjustment fails: still saves and shows success."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json"
object.__setattr__(
locker,
"_adjust_shutdown_time_later",
MagicMock(return_value=False),
)
locker._handle_verify_workout_result("verified", "1 session found")
assert locker.workout_data["type"] == "phone_verified"
locker.root.after.assert_called()
def test_not_verified_shows_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""On not_verified: show retry screen."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_show_verify_retry",
MagicMock(),
)
locker._handle_verify_workout_result(
"not_verified",
"No workout today",
)
locker._show_verify_retry.assert_called_once_with(
"No workout today",
)
def test_error_shows_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""On error: show retry screen."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_show_verify_retry",
MagicMock(),
)
locker._handle_verify_workout_result("error", "ADB failed")
locker._show_verify_retry.assert_called_once_with("ADB failed")
class TestShowVerifyRetry:
"""Tests for _show_verify_retry."""
def test_shows_retry_and_close_buttons(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Show TRY AGAIN and Close buttons."""
locker = create_locker(mock_tk, tmp_path)
locker._show_verify_retry("No workout found")
# Verify container was cleared and buttons were packed
locker.container.winfo_children.return_value = []

View File

@ -1,136 +0,0 @@
"""Tests for VT switching disable/restore during screen lock."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, call, patch
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
_SETXKBMAP = "/usr/bin/setxkbmap"
class TestVTSwitching:
"""Tests for VT switching disable/restore behaviour."""
def test_vt_switching_disabled_in_production_mode(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""setxkbmap srvrkeys:none is called when locker starts in production."""
create_locker(mock_tk, tmp_path, demo_mode=False)
mock_subprocess_run.assert_called_once_with(
[_SETXKBMAP, "-option", "srvrkeys:none"],
check=False,
)
def test_vt_switching_not_disabled_in_demo_mode(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""setxkbmap is NOT called in demo mode."""
create_locker(mock_tk, tmp_path, demo_mode=True)
mock_subprocess_run.assert_not_called()
def test_vt_switching_restored_on_close_in_production(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""setxkbmap -option '' is called when close() runs in production."""
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
mock_subprocess_run.reset_mock()
locker.close()
mock_subprocess_run.assert_called_once_with(
[_SETXKBMAP, "-option", ""],
check=False,
)
def test_vt_switching_not_restored_in_demo_mode(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""close() does NOT call setxkbmap in demo mode."""
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
mock_subprocess_run.reset_mock()
locker.close()
mock_subprocess_run.assert_not_called()
def test_disable_then_restore_are_complementary(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Full lifecycle: disable on init, restore on close in production."""
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
assert mock_subprocess_run.call_count == 1
assert mock_subprocess_run.call_args_list[0] == call(
[_SETXKBMAP, "-option", "srvrkeys:none"],
check=False,
)
locker.close()
assert mock_subprocess_run.call_count == 2
assert mock_subprocess_run.call_args_list[1] == call(
[_SETXKBMAP, "-option", ""],
check=False,
)
def test_disable_graceful_when_setxkbmap_missing(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No crash and no subprocess call when setxkbmap is not installed."""
with patch(
"python_pkg.screen_locker._window_setup.shutil.which",
return_value=None,
):
create_locker(mock_tk, tmp_path, demo_mode=False)
mock_subprocess_run.assert_not_called()
def test_restore_graceful_when_setxkbmap_missing(
self,
mock_tk: MagicMock,
mock_subprocess_run: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No crash and no subprocess call on close when setxkbmap is not installed."""
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
mock_subprocess_run.reset_mock()
with patch(
"python_pkg.screen_locker._window_setup.shutil.which",
return_value=None,
):
locker.close()
mock_subprocess_run.assert_not_called()

View File

@ -1,188 +0,0 @@
"""Tests for rtcwake integration in ShutdownMixin."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestIsTomorrowAlarmDay:
"""Tests for _is_tomorrow_alarm_day."""
def test_sunday_evening_means_monday_alarm(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Sunday evening → Monday is alarm day (weekday=0)."""
locker = create_locker(mock_tk, tmp_path)
from datetime import datetime, timezone
# Sunday 2026-04-12 → tomorrow Monday
with patch(
"python_pkg.screen_locker._shutdown.datetime",
) as mock_dt:
mock_dt.now.return_value = datetime(2026, 4, 12, 23, 0, tzinfo=timezone.utc)
mock_dt.side_effect = datetime
from datetime import timedelta
# Ensure timedelta works
with patch(
"python_pkg.screen_locker._shutdown.timedelta",
timedelta,
):
assert locker._is_tomorrow_alarm_day() is True
def test_monday_evening_is_not_alarm_next(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Monday evening → Tuesday is NOT an alarm day."""
locker = create_locker(mock_tk, tmp_path)
from datetime import datetime, timedelta, timezone
# Monday 2026-04-13 → tomorrow Tuesday (weekday=1)
with (
patch(
"python_pkg.screen_locker._shutdown.datetime",
) as mock_dt,
patch(
"python_pkg.screen_locker._shutdown.timedelta",
timedelta,
),
):
mock_dt.now.return_value = datetime(2026, 4, 13, 23, 0, tzinfo=timezone.utc)
mock_dt.side_effect = datetime
assert locker._is_tomorrow_alarm_day() is False
def test_thursday_evening_friday_is_alarm(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Thursday evening → Friday is alarm day (weekday=4)."""
locker = create_locker(mock_tk, tmp_path)
from datetime import datetime, timedelta, timezone
# Thursday 2026-04-16 → tomorrow Friday (weekday=4)
with (
patch(
"python_pkg.screen_locker._shutdown.datetime",
) as mock_dt,
patch(
"python_pkg.screen_locker._shutdown.timedelta",
timedelta,
),
):
mock_dt.now.return_value = datetime(2026, 4, 16, 23, 0, tzinfo=timezone.utc)
mock_dt.side_effect = datetime
assert locker._is_tomorrow_alarm_day() is True
class TestScheduleRtcwake:
"""Tests for _schedule_rtcwake."""
def test_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Successful rtcwake call returns True."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"python_pkg.screen_locker._shutdown.subprocess.run",
) as mock_run:
mock_run.return_value = MagicMock(returncode=0)
assert locker._schedule_rtcwake() is True
mock_run.assert_called_once()
cmd = mock_run.call_args[0][0]
assert "rtcwake" in cmd[1]
def test_failure_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Failed rtcwake call returns False."""
locker = create_locker(mock_tk, tmp_path)
import subprocess
with patch(
"python_pkg.screen_locker._shutdown.subprocess.run",
side_effect=subprocess.SubprocessError("rtcwake failed"),
):
assert locker._schedule_rtcwake() is False
class TestScheduleWakeIfNeeded:
"""Tests for schedule_wake_if_needed."""
def test_skips_when_not_alarm_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when tomorrow is not an alarm day."""
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_is_tomorrow_alarm_day", return_value=False):
assert locker.schedule_wake_if_needed() is False
def test_schedules_when_alarm_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns True when tomorrow is an alarm day and rtcwake succeeds."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_tomorrow_alarm_day", return_value=True),
patch.object(locker, "_schedule_rtcwake", return_value=True),
):
assert locker.schedule_wake_if_needed() is True
def test_returns_false_when_rtcwake_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Returns False when rtcwake call fails."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_tomorrow_alarm_day", return_value=True),
patch.object(locker, "_schedule_rtcwake", return_value=False),
):
assert locker.schedule_wake_if_needed() is False
class TestComputeWakeTimestamp:
"""Tests for _compute_wake_timestamp."""
def test_returns_future_epoch(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Wake timestamp is roughly 8 hours from now."""
locker = create_locker(mock_tk, tmp_path)
import time
now = int(time.time())
wake = locker._compute_wake_timestamp()
# Should be ~8 hours ahead (within 60 second tolerance)
expected = now + 8 * 3600
assert abs(wake - expected) < 60

View File

@ -1,83 +0,0 @@
"""Tests for wake alarm skip integration in screen_lock.py."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestWakeSkipIntegration:
"""Tests for workout skip via wake alarm in screen locker init."""
def test_exits_when_wake_skip_active(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Screen locker exits if wake alarm granted workout skip today."""
with patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=True,
):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_called_once_with(0)
def test_does_not_exit_when_no_wake_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Screen locker proceeds normally if no wake skip active."""
with patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=False,
):
locker = create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_not_called()
assert locker is not None
def test_logged_today_takes_precedence(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""has_logged_today exits before wake skip is even checked."""
with patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=True,
):
create_locker(mock_tk, tmp_path, has_logged=True)
# Exits because has_logged_today, not because of wake skip
mock_sys_exit.assert_called_once_with(0)
def test_verify_only_mode_ignores_wake_skip(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""verify_only mode checks sick day log, not wake skip."""
with patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=True,
):
create_locker(
mock_tk,
tmp_path,
verify_only=True,
is_sick_day_log=True,
)
# In verify_only mode, exits don't happen from wake skip path
mock_sys_exit.assert_not_called()

View File

@ -1,243 +0,0 @@
"""Tests for _weekly_check: is_relaxed_day, count_weekly_workouts,
has_weekly_minimum."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from python_pkg.screen_locker._weekly_check import (
_RELAXED_WEEKDAYS,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
has_weekly_minimum,
is_relaxed_day,
)
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _dt(weekday: int, hour: int = 10) -> datetime:
"""Return a UTC-aware datetime for the given ISO weekday (0=Mon, 6=Sun)."""
# 2025-05-19 is a Monday (weekday 0)
base = datetime(2025, 5, 19, hour, 0, 0, tzinfo=timezone.utc)
from datetime import timedelta
return base + timedelta(days=weekday)
def _make_log(entries: dict[str, str], log_file: Path) -> Path:
"""Write a workout_log.json with given date→workout_type mapping."""
data: dict[str, Any] = {
date: {
"timestamp": f"{date}T10:00:00+00:00",
"workout_data": {"type": wtype},
}
for date, wtype in entries.items()
}
log_file.write_text(json.dumps(data))
return log_file
# ---------------------------------------------------------------------------
# is_relaxed_day
# ---------------------------------------------------------------------------
class TestIsRelaxedDay:
def test_monday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(0)) is False
def test_tuesday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(1)) is True
def test_wednesday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(2)) is True
def test_thursday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(3)) is True
def test_friday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(4)) is False
def test_saturday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(5)) is False
def test_sunday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(6)) is False
def test_relaxed_weekdays_constant_correct(self) -> None:
assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS
def test_uses_local_time_by_default(self) -> None:
result = is_relaxed_day()
assert isinstance(result, bool)
# ---------------------------------------------------------------------------
# count_weekly_workouts
# ---------------------------------------------------------------------------
class TestCountWeeklyWorkouts:
def test_no_log_file_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
log.write_text("{not valid json}")
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_oserror_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
log.write_text("{}")
with patch("builtins.open", side_effect=OSError("no permission")):
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_phone_verified_in_current_week(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# Mon=2025-05-19, Tue=2025-05-20 both in same week; check on Fri=2025-05-23
_make_log({"2025-05-19": "phone_verified", "2025-05-20": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 2
def test_sick_day_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "sick_day"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_early_bird_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "early_bird"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_previous_week_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# 2025-05-12 is the Monday of the previous week
_make_log({"2025-05-12": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_future_date_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# 2025-05-24 is Saturday, checking on Friday 2025-05-23
_make_log({"2025-05-24": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_invalid_date_key_skipped(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {
"not-a-date": {
"timestamp": "x",
"workout_data": {"type": "phone_verified"},
},
"2025-05-19": {
"timestamp": "x",
"workout_data": {"type": "phone_verified"},
},
}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_non_dict_entry_skipped(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {"2025-05-19": "not-a-dict"}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_up_to_four(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert count_weekly_workouts(log, today=_dt(4)) == 4
def test_today_counts_if_this_week(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# today is Friday 2025-05-23
_make_log({"2025-05-23": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_monday_start_of_week_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "phone_verified"}, log)
# Checking on Monday itself (today=Mon)
assert count_weekly_workouts(log, today=_dt(0)) == 1
def test_mixed_types_only_verified_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "sick_day",
"2025-05-21": "early_bird",
"2025-05-22": "phone_verified",
},
log,
)
assert count_weekly_workouts(log, today=_dt(4)) == 2
# ---------------------------------------------------------------------------
# has_weekly_minimum
# ---------------------------------------------------------------------------
class TestHasWeeklyMinimum:
def test_zero_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_three_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_four_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
"2025-05-23": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_weekly_workout_minimum_constant(self) -> None:
assert WEEKLY_WORKOUT_MINIMUM == 4

View File

@ -1,598 +0,0 @@
"""Tests for weekly workout enforcement and relaxed-day (Tue-Thu) logic."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.screen_lock import ScreenLocker
from python_pkg.screen_locker.tests.conftest import (
create_locker,
create_locker_relaxed_day,
)
# ---------------------------------------------------------------------------
# _check_non_verify_exits: relaxed-day branch
# ---------------------------------------------------------------------------
class TestRelaxedDayBranch:
def test_relaxed_day_sets_flag_instead_of_exiting(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker_relaxed_day(mock_tk, tmp_path)
assert locker._relaxed_day_mode is True
mock_sys_exit.assert_not_called()
def test_relaxed_day_calls_start_relaxed_flow(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
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=False),
patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
),
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=True,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_start_phone_check") as mock_phone,
patch.object(ScreenLocker, "_start_relaxed_day_flow") as mock_relaxed,
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
ScreenLocker(demo_mode=True)
mock_relaxed.assert_called_once()
mock_phone.assert_not_called()
def test_relaxed_day_uses_small_window_not_fullscreen(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
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=False),
patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
),
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=True,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_setup_window") as mock_full,
patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small,
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
ScreenLocker(demo_mode=True)
mock_small.assert_called_once()
mock_full.assert_not_called()
def test_relaxed_day_no_grab_input(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
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=False),
patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
),
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=True,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_grab_input") as mock_grab,
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
ScreenLocker(demo_mode=True)
mock_grab.assert_not_called()
def test_has_logged_today_exits_before_relaxed_check(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
create_locker_relaxed_day(mock_tk, tmp_path, has_logged=True)
mock_sys_exit.assert_called_once_with(0)
# ---------------------------------------------------------------------------
# _check_non_verify_exits: Fri-Mon weekly minimum branch
# ---------------------------------------------------------------------------
class TestWeeklyMinimumBranch:
def test_weekly_minimum_met_exits(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=True,
):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_called_once_with(0)
def test_weekly_minimum_not_met_shows_full_lock(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
# create_locker already stubs _start_phone_check; just verify no exit
# and _relaxed_day_mode stays False (full lock path taken).
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
):
locker = create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_not_called()
assert locker._relaxed_day_mode is False
def test_weekly_minimum_not_checked_on_relaxed_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
) as mock_weekly:
create_locker_relaxed_day(mock_tk, tmp_path)
mock_weekly.assert_not_called()
def test_has_logged_exits_before_weekly_check(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
) as mock_weekly:
create_locker(mock_tk, tmp_path, has_logged=True)
mock_weekly.assert_not_called()
# ---------------------------------------------------------------------------
# Relaxed-day UI flow methods
# ---------------------------------------------------------------------------
class TestStartRelaxedDayFlow:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_shows_weekly_count_in_text(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch(
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
return_value=2,
),
patch.object(locker, "_text") as mock_text,
patch.object(locker, "_label"),
patch.object(locker, "_button_row"),
patch.object(locker, "_button"),
patch.object(locker, "clear_container"),
):
locker._start_relaxed_day_flow()
all_text = " ".join(str(c) for c in mock_text.call_args_list)
assert "2" in all_text
assert "4" in all_text
def test_skip_button_wires_close(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch(
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
return_value=0,
),
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._start_relaxed_day_flow()
skip_cmds = [
c.kwargs["command"]
for c in mock_button.call_args_list
if "Skip" in str(c.args)
]
assert any(cmd == locker.close for cmd in skip_cmds)
def test_log_button_wires_relaxed_phone_check(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch(
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
return_value=1,
),
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._start_relaxed_day_flow()
log_cmds = [
c.kwargs["command"]
for c in mock_button.call_args_list
if "Log" in str(c.args)
]
assert any(cmd == locker._start_relaxed_phone_check for cmd in log_cmds)
class TestStartRelaxedPhoneCheck:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_submits_phone_verify_and_polls(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(
locker, "_verify_phone_workout", return_value=("verified", "ok")
):
locker._start_relaxed_phone_check()
assert locker._phone_future is not None
locker.root.after.assert_called()
def test_poll_routes_when_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("verified", "ok")
locker._phone_future = mock_future
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check()
mock_handle.assert_called_once_with("verified", "ok")
def test_poll_waits_when_not_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = False
locker._phone_future = mock_future
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check()
mock_handle.assert_not_called()
locker.root.after.assert_called_with(500, locker._poll_relaxed_phone_check)
def test_poll_with_none_future_waits(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
locker._phone_future = None
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check()
mock_handle.assert_not_called()
class TestHandleRelaxedPhoneResult:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_verified_calls_unlock_screen(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "unlock_screen"):
locker._handle_relaxed_phone_result("verified", "StrongLifts sync OK")
assert locker.workout_data["type"] == "phone_verified"
assert locker.workout_data["source"] == "StrongLifts sync OK"
locker.root.after.assert_called()
def test_not_verified_shows_relaxed_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("not_verified", "no workout today")
mock_retry.assert_called_once_with("no workout today", "not_verified")
def test_too_short_shows_relaxed_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("too_short", "only 20 min")
mock_retry.assert_called_once_with("only 20 min", "too_short")
def test_no_phone_shows_relaxed_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("no_phone", "ADB not found")
mock_retry.assert_called_once_with("ADB not found", "no_phone")
class TestShowRelaxedRetry:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_shows_try_again_and_close_buttons(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._show_relaxed_retry("msg", "not_verified")
button_texts = " ".join(str(c.args) for c in mock_button.call_args_list)
assert "TRY AGAIN" in button_texts
assert "Close" in button_texts
def test_no_sick_button(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._show_relaxed_retry("msg", "not_verified")
button_texts = " ".join(str(c.args) for c in mock_button.call_args_list)
assert "sick" not in button_texts.lower()
# ---------------------------------------------------------------------------
# _check_today_state_exits: return True/False branches
# ---------------------------------------------------------------------------
class TestCheckTodayStateExits:
"""Cover all return True/False paths in _check_today_state_exits.
sys.exit is mocked without side_effect so execution continues past it
and the 'return True' statements are reachable.
"""
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_early_bird_upgrade_success_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True),
):
result = locker._check_today_state_exits()
assert result is True
def test_early_bird_upgrade_fail_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False),
):
result = locker._check_today_state_exits()
assert result is False
def test_early_bird_window_active_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=True),
):
result = locker._check_today_state_exits()
assert result is True
def test_sick_day_auto_upgrade_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=True),
patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True),
):
result = locker._check_today_state_exits()
assert result is True
def test_workout_skip_today_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=True,
),
):
result = locker._check_today_state_exits()
assert result is True
def test_early_bird_time_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=False,
),
patch.object(locker, "_is_early_bird_time", return_value=True),
patch.object(locker, "_save_early_bird_log"),
):
result = locker._check_today_state_exits()
assert result is True
def test_no_exit_conditions_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=False,
),
patch.object(locker, "_is_early_bird_time", return_value=False),
):
result = locker._check_today_state_exits()
assert result is False
class TestCheckNonVerifyExitsScheduledSkip:
"""Cover the return after scheduled-skip sys.exit in _check_non_verify_exits."""
def test_scheduled_skip_return_reached(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_is_scheduled_skip_today", return_value=True):
locker._check_non_verify_exits()
mock_sys_exit.assert_called_once_with(0)

View File

@ -1,18 +0,0 @@
[Unit]
Description=Workout Screen Locker
After=graphical-session.target
[Service]
Type=simple
WorkingDirectory=/home/kuhy/testsAndMisc
Environment=DISPLAY=:0
Environment=PYTHONPATH=/home/kuhy/testsAndMisc
ExecStartPre=/bin/sleep 1
ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production
Restart=on-failure
RestartSec=2s
RestartPreventExitStatus=0
User=%u
[Install]
WantedBy=graphical-session.target