diff --git a/CLAUDE.md b/CLAUDE.md index b1e65c2..9657f82 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,11 +3,12 @@ ## Project Overview 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 -configuration, and Android phone focus enforcement. +components span personal productivity tools: alarm/shutdown scheduling, Linux system configuration, and +Android phone focus enforcement. -Steam backlog enforcer has been extracted to its own repo: -[`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer). +Extracted to their own repos: +- [`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 [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). @@ -31,17 +32,6 @@ Archived / unmaintained projects live in the sibling repository ### 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 - `_alarm.py` — alarm logic - `_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 - `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 - **random_jpg/** — Random JPEG downloader utility - **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 # 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/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100 diff --git a/docs/superpowers/evidence/remove-screen-locker-2026-05-28.json b/docs/superpowers/evidence/remove-screen-locker-2026-05-28.json new file mode 100644 index 0000000..c23db47 --- /dev/null +++ b/docs/superpowers/evidence/remove-screen-locker-2026-05-28.json @@ -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/" + ] +} diff --git a/python_pkg/screen_locker/__init__.py b/python_pkg/screen_locker/__init__.py deleted file mode 100644 index 45a0d61..0000000 --- a/python_pkg/screen_locker/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Screen locker module.""" diff --git a/python_pkg/screen_locker/_constants.py b/python_pkg/screen_locker/_constants.py deleted file mode 100644 index 892b887..0000000 --- a/python_pkg/screen_locker/_constants.py +++ /dev/null @@ -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" diff --git a/python_pkg/screen_locker/_early_bird.py b/python_pkg/screen_locker/_early_bird.py deleted file mode 100644 index 6f6b748..0000000 --- a/python_pkg/screen_locker/_early_bird.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/_log_integrity.py b/python_pkg/screen_locker/_log_integrity.py deleted file mode 100644 index cc4afcc..0000000 --- a/python_pkg/screen_locker/_log_integrity.py +++ /dev/null @@ -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", -] diff --git a/python_pkg/screen_locker/_phone_verification.py b/python_pkg/screen_locker/_phone_verification.py deleted file mode 100644 index 869be3f..0000000 --- a/python_pkg/screen_locker/_phone_verification.py +++ /dev/null @@ -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))", - ) diff --git a/python_pkg/screen_locker/_shutdown.py b/python_pkg/screen_locker/_shutdown.py deleted file mode 100644 index 89692ef..0000000 --- a/python_pkg/screen_locker/_shutdown.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/_sick_dialog.py b/python_pkg/screen_locker/_sick_dialog.py deleted file mode 100644 index 912a22c..0000000 --- a/python_pkg/screen_locker/_sick_dialog.py +++ /dev/null @@ -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 ("<>", "", "", ""): - 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() diff --git a/python_pkg/screen_locker/_sick_tracker.py b/python_pkg/screen_locker/_sick_tracker.py deleted file mode 100644 index 54dfe43..0000000 --- a/python_pkg/screen_locker/_sick_tracker.py +++ /dev/null @@ -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) diff --git a/python_pkg/screen_locker/_time_check.py b/python_pkg/screen_locker/_time_check.py deleted file mode 100644 index f1d0073..0000000 --- a/python_pkg/screen_locker/_time_check.py +++ /dev/null @@ -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)" diff --git a/python_pkg/screen_locker/_ui_flows.py b/python_pkg/screen_locker/_ui_flows.py deleted file mode 100644 index edd8aca..0000000 --- a/python_pkg/screen_locker/_ui_flows.py +++ /dev/null @@ -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) diff --git a/python_pkg/screen_locker/_weekly_check.py b/python_pkg/screen_locker/_weekly_check.py deleted file mode 100644 index 0c99a37..0000000 --- a/python_pkg/screen_locker/_weekly_check.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/_window_setup.py b/python_pkg/screen_locker/_window_setup.py deleted file mode 100644 index e1f0fcd..0000000 --- a/python_pkg/screen_locker/_window_setup.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/adjust_shutdown_schedule.sh b/python_pkg/screen_locker/adjust_shutdown_schedule.sh deleted file mode 100755 index 4e6621e..0000000 --- a/python_pkg/screen_locker/adjust_shutdown_schedule.sh +++ /dev/null @@ -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] -# -# --restore: Allow restoring to original (possibly later) times -# Without this flag, only stricter (earlier) times are allowed -# -# Add to /etc/sudoers.d/workout-locker: -# 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] " >&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" diff --git a/python_pkg/screen_locker/early-bird-workout-check.timer b/python_pkg/screen_locker/early-bird-workout-check.timer deleted file mode 100644 index 026cf95..0000000 --- a/python_pkg/screen_locker/early-bird-workout-check.timer +++ /dev/null @@ -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 5–8:30 window -OnCalendar=*-*-* 08:30:00 -Unit=workout-locker.service -Persistent=false -AccuracySec=1s -RandomizedDelaySec=0 - -[Install] -WantedBy=timers.target diff --git a/python_pkg/screen_locker/install_autostart.sh b/python_pkg/screen_locker/install_autostart.sh deleted file mode 100755 index de8c462..0000000 --- a/python_pkg/screen_locker/install_autostart.sh +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/install_systemd.sh b/python_pkg/screen_locker/install_systemd.sh deleted file mode 100755 index e353eed..0000000 --- a/python_pkg/screen_locker/install_systemd.sh +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/remove_autostart.sh b/python_pkg/screen_locker/remove_autostart.sh deleted file mode 100755 index 2507ffc..0000000 --- a/python_pkg/screen_locker/remove_autostart.sh +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/remove_systemd.sh b/python_pkg/screen_locker/remove_systemd.sh deleted file mode 100755 index 438e450..0000000 --- a/python_pkg/screen_locker/remove_systemd.sh +++ /dev/null @@ -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" diff --git a/python_pkg/screen_locker/run.sh b/python_pkg/screen_locker/run.sh deleted file mode 100755 index d0202f6..0000000 --- a/python_pkg/screen_locker/run.sh +++ /dev/null @@ -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 "$@" diff --git a/python_pkg/screen_locker/scheduled_skips.json b/python_pkg/screen_locker/scheduled_skips.json deleted file mode 100644 index 4afbae5..0000000 --- a/python_pkg/screen_locker/scheduled_skips.json +++ /dev/null @@ -1 +0,0 @@ -["2026-05-19", "2026-05-20", "2026-05-21"] diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py deleted file mode 100755 index fd0eb86..0000000 --- a/python_pkg/screen_locker/screen_lock.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/__init__.py b/python_pkg/screen_locker/tests/__init__.py deleted file mode 100644 index 9bdc078..0000000 --- a/python_pkg/screen_locker/tests/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Tests for screen_locker module.""" diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py deleted file mode 100644 index 1164cd6..0000000 --- a/python_pkg/screen_locker/tests/conftest.py +++ /dev/null @@ -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) diff --git a/python_pkg/screen_locker/tests/test_adb_and_phone.py b/python_pkg/screen_locker/tests/test_adb_and_phone.py deleted file mode 100644 index 0e8f7e0..0000000 --- a/python_pkg/screen_locker/tests/test_adb_and_phone.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_adb_and_phone_part2.py b/python_pkg/screen_locker/tests/test_adb_and_phone_part2.py deleted file mode 100644 index 5e38be1..0000000 --- a/python_pkg/screen_locker/tests/test_adb_and_phone_part2.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_early_bird.py b/python_pkg/screen_locker/tests/test_early_bird.py deleted file mode 100644 index c7444f0..0000000 --- a/python_pkg/screen_locker/tests/test_early_bird.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_init_and_log.py b/python_pkg/screen_locker/tests/test_init_and_log.py deleted file mode 100644 index fbeb824..0000000 --- a/python_pkg/screen_locker/tests/test_init_and_log.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_init_and_log_part2.py b/python_pkg/screen_locker/tests/test_init_and_log_part2.py deleted file mode 100644 index f6d08c3..0000000 --- a/python_pkg/screen_locker/tests/test_init_and_log_part2.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_log_integrity.py b/python_pkg/screen_locker/tests/test_log_integrity.py deleted file mode 100644 index 2200c80..0000000 --- a/python_pkg/screen_locker/tests/test_log_integrity.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_phone_check_unlock.py b/python_pkg/screen_locker/tests/test_phone_check_unlock.py deleted file mode 100644 index f99a0f4..0000000 --- a/python_pkg/screen_locker/tests/test_phone_check_unlock.py +++ /dev/null @@ -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" - ) diff --git a/python_pkg/screen_locker/tests/test_phone_check_unlock_part2.py b/python_pkg/screen_locker/tests/test_phone_check_unlock_part2.py deleted file mode 100644 index 1035ce3..0000000 --- a/python_pkg/screen_locker/tests/test_phone_check_unlock_part2.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_phone_verification_part2.py b/python_pkg/screen_locker/tests/test_phone_verification_part2.py deleted file mode 100644 index 5ee4a5b..0000000 --- a/python_pkg/screen_locker/tests/test_phone_verification_part2.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_scheduled_skip.py b/python_pkg/screen_locker/tests/test_scheduled_skip.py deleted file mode 100644 index cc3b6a7..0000000 --- a/python_pkg/screen_locker/tests/test_scheduled_skip.py +++ /dev/null @@ -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) diff --git a/python_pkg/screen_locker/tests/test_shutdown_part2.py b/python_pkg/screen_locker/tests/test_shutdown_part2.py deleted file mode 100644 index 28822a8..0000000 --- a/python_pkg/screen_locker/tests/test_shutdown_part2.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_shutdown_part3.py b/python_pkg/screen_locker/tests/test_shutdown_part3.py deleted file mode 100644 index 7ec85c7..0000000 --- a/python_pkg/screen_locker/tests/test_shutdown_part3.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_sick_features.py b/python_pkg/screen_locker/tests/test_sick_features.py deleted file mode 100644 index cef712f..0000000 --- a/python_pkg/screen_locker/tests/test_sick_features.py +++ /dev/null @@ -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) diff --git a/python_pkg/screen_locker/tests/test_sick_tracker.py b/python_pkg/screen_locker/tests/test_sick_tracker.py deleted file mode 100644 index e7d7e4a..0000000 --- a/python_pkg/screen_locker/tests/test_sick_tracker.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_time_check.py b/python_pkg/screen_locker/tests/test_time_check.py deleted file mode 100644 index b8474bc..0000000 --- a/python_pkg/screen_locker/tests/test_time_check.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_ui_and_timers.py b/python_pkg/screen_locker/tests/test_ui_and_timers.py deleted file mode 100644 index e9f615f..0000000 --- a/python_pkg/screen_locker/tests/test_ui_and_timers.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_ui_and_timers_part2.py b/python_pkg/screen_locker/tests/test_ui_and_timers_part2.py deleted file mode 100644 index 6a51cb2..0000000 --- a/python_pkg/screen_locker/tests/test_ui_and_timers_part2.py +++ /dev/null @@ -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") diff --git a/python_pkg/screen_locker/tests/test_ui_flows_part2.py b/python_pkg/screen_locker/tests/test_ui_flows_part2.py deleted file mode 100644 index 0e8facf..0000000 --- a/python_pkg/screen_locker/tests/test_ui_flows_part2.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_verify_workout.py b/python_pkg/screen_locker/tests/test_verify_workout.py deleted file mode 100644 index e4134c5..0000000 --- a/python_pkg/screen_locker/tests/test_verify_workout.py +++ /dev/null @@ -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 = [] diff --git a/python_pkg/screen_locker/tests/test_vt_switching.py b/python_pkg/screen_locker/tests/test_vt_switching.py deleted file mode 100644 index 8001f80..0000000 --- a/python_pkg/screen_locker/tests/test_vt_switching.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_wake_shutdown.py b/python_pkg/screen_locker/tests/test_wake_shutdown.py deleted file mode 100644 index 4881f86..0000000 --- a/python_pkg/screen_locker/tests/test_wake_shutdown.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_wake_skip.py b/python_pkg/screen_locker/tests/test_wake_skip.py deleted file mode 100644 index a7becca..0000000 --- a/python_pkg/screen_locker/tests/test_wake_skip.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/tests/test_weekly_check.py b/python_pkg/screen_locker/tests/test_weekly_check.py deleted file mode 100644 index 5ff4f50..0000000 --- a/python_pkg/screen_locker/tests/test_weekly_check.py +++ /dev/null @@ -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 diff --git a/python_pkg/screen_locker/tests/test_weekly_logic.py b/python_pkg/screen_locker/tests/test_weekly_logic.py deleted file mode 100644 index 13f4442..0000000 --- a/python_pkg/screen_locker/tests/test_weekly_logic.py +++ /dev/null @@ -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) diff --git a/python_pkg/screen_locker/workout-locker.service b/python_pkg/screen_locker/workout-locker.service deleted file mode 100644 index 47e5437..0000000 --- a/python_pkg/screen_locker/workout-locker.service +++ /dev/null @@ -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