diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py new file mode 100644 index 0000000..d7f22cc --- /dev/null +++ b/screen_locker/_constants.py @@ -0,0 +1,36 @@ +"""Constants for the screen locker module.""" + +from __future__ import annotations + +from pathlib import Path + +# Validation limits for workout data +MAX_DISTANCE_KM = 100 +MAX_TIME_MINUTES = 600 +MAX_PACE_MIN_PER_KM = 20 +MIN_EXERCISE_NAME_LEN = 3 +MAX_SETS = 20 +MAX_REPS = 100 +MAX_WEIGHT_KG = 500 +SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick +SUBMIT_DELAY_DEMO = 30 +SUBMIT_DELAY_PRODUCTION = 180 +PHONE_PENALTY_DELAY_DEMO = 10 +PHONE_PENALTY_DELAY_PRODUCTION = 600 +ADB_TIMEOUT = 15 +STRONGLIFTS_DB_REMOTE = ( + "/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" +) +SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") +# 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" + +STRENGTH_FIELDS: list[tuple[str, int]] = [ + ("Exercises (comma-separated):", 50), + ("Sets per exercise (comma-separated):", 20), + ("Reps (comma-sep, + for variable: 12+11+12):", 30), + ("Weight per exercise kg (comma-separated):", 20), + ("Total weight lifted (kg):", 15), +] diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py new file mode 100644 index 0000000..5d158ef --- /dev/null +++ b/screen_locker/_phone_verification.py @@ -0,0 +1,203 @@ +"""Phone workout verification mixin using ADB and StrongLifts.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor, as_completed +import contextlib +import logging +from pathlib import Path +import shutil +import socket +import sqlite3 +import subprocess +import tempfile + +from python_pkg.screen_locker._constants import ADB_TIMEOUT, STRONGLIFTS_DB_REMOTE + +_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 result.returncode == 0, 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 _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 on phone. + - "not_verified": Phone connected but no workout found. + - "no_phone": No phone connected via ADB. + - "error": Could not access StrongLifts database. + """ + 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" + count = self._count_today_workouts(local_db) + if count > 0: + return ( + "verified", + f"Workout verified! ({count} session(s) found on phone)", + ) + return "not_verified", "No workout found on phone today" diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py new file mode 100644 index 0000000..bb90a56 --- /dev/null +++ b/screen_locker/_shutdown.py @@ -0,0 +1,262 @@ +"""Shutdown schedule adjustment mixin for the screen locker.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging +import subprocess + +from python_pkg.screen_locker._constants import ( + ADJUST_SHUTDOWN_SCRIPT, + SHUTDOWN_CONFIG_FILE, + SICK_DAY_STATE_FILE, +) + +_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 diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py new file mode 100644 index 0000000..f6045d7 --- /dev/null +++ b/screen_locker/_ui_flows.py @@ -0,0 +1,294 @@ +"""UI flow methods mixin for the screen locker.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor +import contextlib +import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + +from python_pkg.screen_locker._constants import ( + PHONE_PENALTY_DELAY_DEMO, + PHONE_PENALTY_DELAY_PRODUCTION, + SICK_LOCKOUT_SECONDS, +) + + +class UIFlowsMixin: + """Mixin providing UI flow logic for the screen locker.""" + + def ask_workout_done(self) -> None: + """Display the initial workout question dialog.""" + self.clear_container() + self._label("Did you work out today?", pady=30) + frame = self._button_row() + self._button( + frame, + "YES", + bg="#00aa00", + command=self.ask_workout_type, + ).pack(side="left", padx=20) + self._button( + frame, + "NO", + bg="#aa0000", + command=self.ask_if_sick, + ).pack(side="left", padx=20) + + 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 _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( + "\u2713 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 == "not_verified": + self.clear_container() + self._label("No Workout Found", font_size=36, color="#ff4444", pady=20) + self._text( + f"\u274c {message}\n\n" + "StrongLifts shows no workout today.\n" + "Go do your workout first!", + color="#ffaa00", + ) + frame = self._button_row() + self._button( + frame, + "TRY AGAIN", + bg="#0066cc", + command=self._start_phone_check, + width=12, + ).pack(side="left", padx=10) + self._button( + frame, + "I'm sick", + bg="#cc6600", + command=self.ask_if_sick, + width=12, + ).pack(side="left", padx=10) + else: + # no_phone or error — penalty timer, then proceed to logging form + self._show_phone_penalty(message, on_done=self.ask_workout_done) + + def ask_if_sick(self) -> None: + """Display sick day question dialog.""" + self.clear_container() + self._label("Are you sick?", pady=30) + self._text( + "If yes, shutdown time will be moved 1.5 hours earlier", + color="#ffaa00", + ) + self._sick_question_buttons() + + def _sick_question_buttons(self) -> None: + """Create the sick day yes/no buttons.""" + frame = self._button_row() + self._button( + frame, + "YES (sick)", + bg="#cc6600", + command=self.handle_sick_day, + width=12, + ).pack(side="left", padx=20) + self._button( + frame, + "NO", + bg="#aa0000", + command=self.lockout, + width=12, + ).pack(side="left", padx=20) + + 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 \u2713\n(Will revert tomorrow)" + ), "#00aa00" + return "Could not adjust shutdown time (check permissions)", "#ff4444" + + def handle_sick_day(self) -> None: + """Handle sick day: adjust shutdown time and start 2-minute wait.""" + self.clear_container() + status_text, status_color = self._get_sick_day_status() + self._show_sick_day_ui(status_text, status_color) + self.sick_remaining_time = SICK_LOCKOUT_SECONDS + self._update_sick_countdown() + + def _show_sick_day_ui(self, status_text: str, status_color: str) -> None: + """Display sick day UI labels and countdown.""" + self._label("Sick Day Mode", color="#cc6600", pady=20) + self._text(status_text, color=status_color) + self._text( + "Please wait 2 minutes before unlocking...", + font_size=24, + pady=20, + ) + self.sick_countdown_label = self._label( + str(SICK_LOCKOUT_SECONDS), + 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: + # Record sick day and unlock + self.workout_data["type"] = "sick_day" + self.workout_data["note"] = "Sick day - shutdown moved earlier" + 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.ask_workout_done() + + # ------------------------------------------------------------------ + # Phone penalty + # ------------------------------------------------------------------ + + def _attempt_unlock(self) -> None: + """Unlock screen after workout form submission.""" + self.unlock_screen() + + 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 self.unlock_screen + ) + delay = ( + PHONE_PENALTY_DELAY_DEMO + if self.demo_mode + else PHONE_PENALTY_DELAY_PRODUCTION + ) + 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() + + # ------------------------------------------------------------------ + # Submit timer and entry checking + # ------------------------------------------------------------------ + + def _tick_submit_timer(self) -> None: + """Decrement submit timer and schedule next tick.""" + self.timer_label.config( + text=f"Submit available in {self.submit_unlock_time} seconds...", + ) + self.submit_unlock_time -= 1 + self.root.after(1000, self.update_submit_timer) + + def _try_enable_submit(self) -> None: + """Enable submit button if all entries are filled.""" + all_filled = all(entry.get().strip() for entry in self.entries_to_check) + if all_filled: + self.submit_btn.config( + text="SUBMIT", + state="normal", + bg="#00aa00", + command=self.submit_command, + ) + self.timer_label.config(text="You can now submit!") + else: + self.timer_label.config(text="Fill all fields to enable submit") + self.root.after(1000, self.check_entries_filled) + + def update_submit_timer(self) -> None: + """Update countdown timer and check if submit can be enabled.""" + with contextlib.suppress(tk.TclError): + if self.submit_unlock_time > 0: + self._tick_submit_timer() + else: + self._try_enable_submit() + + def check_entries_filled(self) -> None: + """Continuously check if entries are filled after timer expires.""" + with contextlib.suppress(tk.TclError): + self._try_enable_submit() diff --git a/screen_locker/_workout_forms.py b/screen_locker/_workout_forms.py new file mode 100644 index 0000000..e3a43c3 --- /dev/null +++ b/screen_locker/_workout_forms.py @@ -0,0 +1,269 @@ +"""Workout form methods mixin for the screen locker.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from python_pkg.screen_locker._constants import ( + MAX_DISTANCE_KM, + MAX_PACE_MIN_PER_KM, + MAX_REPS, + MAX_SETS, + MAX_TIME_MINUTES, + MAX_WEIGHT_KG, + MIN_EXERCISE_NAME_LEN, + STRENGTH_FIELDS, +) + +if TYPE_CHECKING: + import tkinter as tk + + +class WorkoutFormsMixin: + """Mixin providing workout form creation and validation.""" + + # ------------------------------------------------------------------ + # Workout type selection + # ------------------------------------------------------------------ + + def ask_workout_type(self) -> None: + """Display workout type selection dialog.""" + self.clear_container() + self._label("What type of workout?", pady=30) + frame = self._button_row() + self._button( + frame, + "STRENGTH", + bg="#cc6600", + command=self.ask_strength_details, + width=12, + ).pack(side="left", padx=20) + + # ------------------------------------------------------------------ + # Running workout + # ------------------------------------------------------------------ + + def _create_running_entries(self) -> list[tk.Entry]: + """Create running workout entry fields.""" + self.distance_entry = self._entry_row("Distance (km):") + self.time_entry = self._entry_row("Time (minutes):") + self.pace_entry = self._entry_row("Pace (min/km):") + return [self.distance_entry, self.time_entry, self.pace_entry] + + def ask_running_details(self) -> None: + """Display running workout input form.""" + self.clear_container() + self.workout_data["type"] = "running" + self._label("Running Details", pady=20) + entries = self._create_running_entries() + self._setup_form_controls( + entries, + self.verify_running_data, + self.ask_workout_type, + ) + + def _check_running_ranges( + self, + distance: float, + time_mins: float, + pace: float, + ) -> str | None: + """Check if running values are in valid ranges.""" + if distance <= 0 or distance > MAX_DISTANCE_KM: + return f"Distance seems unrealistic (0-{MAX_DISTANCE_KM} km)" + if time_mins <= 0 or time_mins > MAX_TIME_MINUTES: + return f"Time seems unrealistic (0-{MAX_TIME_MINUTES} minutes)" + if pace <= 0 or pace > MAX_PACE_MIN_PER_KM: + return f"Pace seems unrealistic (0-{MAX_PACE_MIN_PER_KM} min/km)" + expected_pace = time_mins / distance + tolerance = expected_pace * 0.15 # 15% tolerance + if abs(pace - expected_pace) > tolerance: + return ( + f"Pace doesn't match! " + f"Expected ~{expected_pace:.2f} min/km, got {pace:.2f}" + ) + return None + + def _validate_running_input(self) -> tuple[float, float, float] | None: + """Parse and validate running input fields.""" + try: + distance = float(self.distance_entry.get()) + time_mins = float(self.time_entry.get()) + pace = float(self.pace_entry.get()) + except ValueError: + self.show_error("Please enter valid numbers") + return None + error = self._check_running_ranges(distance, time_mins, pace) + if error: + self.show_error(error) + return None + return distance, time_mins, pace + + def verify_running_data(self) -> None: + """Validate running workout data and unlock if valid.""" + result = self._validate_running_input() + if result is None: + return + distance, time_mins, pace = result + self.workout_data["distance_km"] = str(distance) + self.workout_data["time_minutes"] = str(time_mins) + self.workout_data["pace_min_per_km"] = str(pace) + self._attempt_unlock() + + # ------------------------------------------------------------------ + # Strength workout + # ------------------------------------------------------------------ + + def _create_strength_entries(self) -> list[tk.Entry]: + """Create strength training entry fields.""" + entries = [ + self._entry_row(lbl, width=w, font_size=18) for lbl, w in STRENGTH_FIELDS + ] + ( + self.exercises_entry, + self.sets_entry, + self.reps_entry, + self.weights_entry, + self.total_weight_entry, + ) = entries + return entries + + def ask_strength_details(self) -> None: + """Display strength training input form.""" + self.clear_container() + self.workout_data["type"] = "strength" + self._label("Strength Training Details", pady=20) + entries = self._create_strength_entries() + self._setup_form_controls( + entries, + self.verify_strength_data, + self.ask_workout_type, + ) + + def _parse_reps(self, reps_raw: list[str]) -> list[list[int]]: + """Parse reps input - single number or variable reps like '12+11+12'.""" + reps: list[list[int]] = [] + for r in reps_raw: + if "+" in r: + reps.append([int(x.strip()) for x in r.split("+")]) + else: + reps.append([int(r)]) + return reps + + def _validate_strength_inputs( + self, + exercises: list[str], + sets: list[int], + reps: list[list[int]], + weights: list[float], + ) -> str | None: + """Validate strength workout inputs. Returns error message or None.""" + if not (len(exercises) == len(sets) == len(reps) == len(weights)): + return "Number of exercises, sets, reps, and weights must match" + if any(len(ex) < MIN_EXERCISE_NAME_LEN for ex in exercises): + return "Exercise names too short - be specific" + if any(s < 1 or s > MAX_SETS for s in sets): + return f"Sets should be between 1-{MAX_SETS}" + if any(w < 0 or w > MAX_WEIGHT_KG for w in weights): + return f"Weights should be between 0-{MAX_WEIGHT_KG} kg" + return self._validate_reps(exercises, sets, reps) + + def _validate_reps( + self, + exercises: list[str], + sets: list[int], + reps: list[list[int]], + ) -> str | None: + """Validate reps data. Returns error message or None if valid.""" + for i, rep_list in enumerate(reps): + if any(r < 1 or r > MAX_REPS for r in rep_list): + return f"Reps should be between 1-{MAX_REPS}" + if len(rep_list) > 1 and len(rep_list) != sets[i]: + return ( + f"For {exercises[i]!r}: variable reps count " + f"({len(rep_list)}) doesn't match sets ({sets[i]})" + ) + return None + + def _calculate_expected_total( + self, + sets: list[int], + reps: list[list[int]], + weights: list[float], + ) -> float: + """Calculate expected total weight lifted.""" + expected_total = 0.0 + for i, rep_list in enumerate(reps): + if len(rep_list) == 1: + expected_total += sets[i] * rep_list[0] * weights[i] + else: + expected_total += sum(rep_list) * weights[i] + return expected_total + + def _parse_strength_entries( + self, + ) -> tuple[list[str], list[int], list[list[int]], list[float], float]: + """Parse raw strength training input from entry widgets.""" + exercises = [e.strip() for e in self.exercises_entry.get().split(",")] + sets = [int(s.strip()) for s in self.sets_entry.get().split(",")] + reps_raw = [r.strip() for r in self.reps_entry.get().split(",")] + reps = self._parse_reps(reps_raw) + weights = [float(w.strip()) for w in self.weights_entry.get().split(",")] + total_weight = float(self.total_weight_entry.get()) + return exercises, sets, reps, weights, total_weight + + def _check_total_weight( + self, + sets: list[int], + reps: list[list[int]], + weights: list[float], + total_weight: float, + ) -> str | None: + """Verify total weight matches individual exercise calculations.""" + expected = self._calculate_expected_total(sets, reps, weights) + tolerance = expected * 0.15 # 15% tolerance + if abs(total_weight - expected) > tolerance: + return ( + f"Total weight doesn't match! " + f"Expected ~{expected:.1f} kg, got {total_weight:.1f}" + ) + return None + + def _store_strength_data( + self, + exercises: list[str], + sets: list[int], + reps: list[list[int]], + weights: list[float], + total_weight: float, + ) -> None: + """Store validated strength workout data.""" + self.workout_data["exercises"] = exercises + self.workout_data["sets"] = [str(s) for s in sets] + self.workout_data["reps"] = [ + "+".join(str(r) for r in rep_list) for rep_list in reps + ] + self.workout_data["weights_kg"] = [str(w) for w in weights] + self.workout_data["total_weight_kg"] = str(total_weight) + + def verify_strength_data(self) -> None: + """Validate strength workout data and unlock if valid.""" + try: + self._verify_strength_data_inner() + except ValueError: + self.show_error("Please enter valid data in correct format") + + def _verify_strength_data_inner(self) -> None: + """Parse, validate, and store strength data.""" + data = self._parse_strength_entries() + exercises, sets, reps, weights, total_weight = data + error = self._validate_strength_inputs(exercises, sets, reps, weights) + if error: + self.show_error(error) + return + total_err = self._check_total_weight(sets, reps, weights, total_weight) + if total_err: + self.show_error(total_err) + return + self._store_strength_data(exercises, sets, reps, weights, total_weight) + self._attempt_unlock() diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index f217786..3f376a3 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -6,59 +6,48 @@ Requires user to log their workout to unlock the screen. from __future__ import annotations -from concurrent.futures import Future, ThreadPoolExecutor, as_completed import contextlib from datetime import datetime, timezone import json import logging from pathlib import Path -import shutil -import socket -import sqlite3 -import subprocess import sys -import tempfile import tkinter as tk from typing import TYPE_CHECKING if TYPE_CHECKING: from collections.abc import Callable + from concurrent.futures import Future + +from python_pkg.screen_locker._constants import ( # noqa: F401 + MAX_DISTANCE_KM, + MAX_PACE_MIN_PER_KM, + MAX_REPS, + MAX_SETS, + MAX_TIME_MINUTES, + MAX_WEIGHT_KG, + MIN_EXERCISE_NAME_LEN, + PHONE_PENALTY_DELAY_DEMO, + PHONE_PENALTY_DELAY_PRODUCTION, + SICK_LOCKOUT_SECONDS, + STRONGLIFTS_DB_REMOTE, + SUBMIT_DELAY_DEMO, + SUBMIT_DELAY_PRODUCTION, +) +from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin +from python_pkg.screen_locker._shutdown import ShutdownMixin +from python_pkg.screen_locker._ui_flows import UIFlowsMixin +from python_pkg.screen_locker._workout_forms import WorkoutFormsMixin _logger = logging.getLogger(__name__) -# Validation limits for workout data -MAX_DISTANCE_KM = 100 -MAX_TIME_MINUTES = 600 -MAX_PACE_MIN_PER_KM = 20 -MIN_EXERCISE_NAME_LEN = 3 -MAX_SETS = 20 -MAX_REPS = 100 -MAX_WEIGHT_KG = 500 -SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick -SUBMIT_DELAY_DEMO = 30 -SUBMIT_DELAY_PRODUCTION = 180 -PHONE_PENALTY_DELAY_DEMO = 10 -PHONE_PENALTY_DELAY_PRODUCTION = 600 -ADB_TIMEOUT = 15 -STRONGLIFTS_DB_REMOTE = ( - "/data/data/com.stronglifts.app/databases/StrongLifts-Database-3" -) -SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf") -# 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" -_STRENGTH_FIELDS: list[tuple[str, int]] = [ - ("Exercises (comma-separated):", 50), - ("Sets per exercise (comma-separated):", 20), - ("Reps (comma-sep, + for variable: 12+11+12):", 30), - ("Weight per exercise kg (comma-separated):", 20), - ("Total weight lifted (kg):", 15), -] - - -class ScreenLocker: +class ScreenLocker( + ShutdownMixin, + PhoneVerificationMixin, + WorkoutFormsMixin, + UIFlowsMixin, +): """Screen locker that requires workout logging to unlock.""" def __init__(self, *, demo_mode: bool = True) -> None: @@ -262,957 +251,6 @@ class ScreenLocker: self.submit_command = verify_command self.update_submit_timer() - # ------------------------------------------------------------------ - # Main screen flows - # ------------------------------------------------------------------ - - def ask_workout_done(self) -> None: - """Display the initial workout question dialog.""" - self.clear_container() - self._label("Did you work out today?", pady=30) - frame = self._button_row() - self._button( - frame, - "YES", - bg="#00aa00", - command=self.ask_workout_type, - ).pack(side="left", padx=20) - self._button( - frame, - "NO", - bg="#aa0000", - command=self.ask_if_sick, - ).pack(side="left", padx=20) - - 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 _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( - "\u2713 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 == "not_verified": - self.clear_container() - self._label("No Workout Found", font_size=36, color="#ff4444", pady=20) - self._text( - f"\u274c {message}\n\n" - "StrongLifts shows no workout today.\n" - "Go do your workout first!", - color="#ffaa00", - ) - frame = self._button_row() - self._button( - frame, - "TRY AGAIN", - bg="#0066cc", - command=self._start_phone_check, - width=12, - ).pack(side="left", padx=10) - self._button( - frame, - "I'm sick", - bg="#cc6600", - command=self.ask_if_sick, - width=12, - ).pack(side="left", padx=10) - else: - # no_phone or error — penalty timer, then proceed to logging form - self._show_phone_penalty(message, on_done=self.ask_workout_done) - - def ask_if_sick(self) -> None: - """Display sick day question dialog.""" - self.clear_container() - self._label("Are you sick?", pady=30) - self._text( - "If yes, shutdown time will be moved 1.5 hours earlier", - color="#ffaa00", - ) - self._sick_question_buttons() - - def _sick_question_buttons(self) -> None: - """Create the sick day yes/no buttons.""" - frame = self._button_row() - self._button( - frame, - "YES (sick)", - bg="#cc6600", - command=self.handle_sick_day, - width=12, - ).pack(side="left", padx=20) - self._button( - frame, - "NO", - bg="#aa0000", - command=self.lockout, - width=12, - ).pack(side="left", padx=20) - - 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 handle_sick_day(self) -> None: - """Handle sick day: adjust shutdown time and start 2-minute wait.""" - self.clear_container() - status_text, status_color = self._get_sick_day_status() - self._show_sick_day_ui(status_text, status_color) - self.sick_remaining_time = SICK_LOCKOUT_SECONDS - self._update_sick_countdown() - - def _show_sick_day_ui(self, status_text: str, status_color: str) -> None: - """Display sick day UI labels and countdown.""" - self._label("Sick Day Mode", color="#cc6600", pady=20) - self._text(status_text, color=status_color) - self._text( - "Please wait 2 minutes before unlocking...", - font_size=24, - pady=20, - ) - self.sick_countdown_label = self._label( - str(SICK_LOCKOUT_SECONDS), - 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: - # Record sick day and unlock - self.workout_data["type"] = "sick_day" - self.workout_data["note"] = "Sick day - shutdown moved earlier" - self.unlock_screen() - - # ------------------------------------------------------------------ - # Shutdown schedule adjustment - # ------------------------------------------------------------------ - - 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 - - # ------------------------------------------------------------------ - # 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.ask_workout_done() - - # ------------------------------------------------------------------ - # Workout type selection - # ------------------------------------------------------------------ - - def ask_workout_type(self) -> None: - """Display workout type selection dialog.""" - self.clear_container() - self._label("What type of workout?", pady=30) - frame = self._button_row() - self._button( - frame, - "STRENGTH", - bg="#cc6600", - command=self.ask_strength_details, - width=12, - ).pack(side="left", padx=20) - - # ------------------------------------------------------------------ - # Running workout - # ------------------------------------------------------------------ - - def _create_running_entries(self) -> list[tk.Entry]: - """Create running workout entry fields.""" - self.distance_entry = self._entry_row("Distance (km):") - self.time_entry = self._entry_row("Time (minutes):") - self.pace_entry = self._entry_row("Pace (min/km):") - return [self.distance_entry, self.time_entry, self.pace_entry] - - def ask_running_details(self) -> None: - """Display running workout input form.""" - self.clear_container() - self.workout_data["type"] = "running" - self._label("Running Details", pady=20) - entries = self._create_running_entries() - self._setup_form_controls( - entries, - self.verify_running_data, - self.ask_workout_type, - ) - - def _check_running_ranges( - self, - distance: float, - time_mins: float, - pace: float, - ) -> str | None: - """Check if running values are in valid ranges.""" - if distance <= 0 or distance > MAX_DISTANCE_KM: - return f"Distance seems unrealistic (0-{MAX_DISTANCE_KM} km)" - if time_mins <= 0 or time_mins > MAX_TIME_MINUTES: - return f"Time seems unrealistic (0-{MAX_TIME_MINUTES} minutes)" - if pace <= 0 or pace > MAX_PACE_MIN_PER_KM: - return f"Pace seems unrealistic (0-{MAX_PACE_MIN_PER_KM} min/km)" - expected_pace = time_mins / distance - tolerance = expected_pace * 0.15 # 15% tolerance - if abs(pace - expected_pace) > tolerance: - return ( - f"Pace doesn't match! " - f"Expected ~{expected_pace:.2f} min/km, got {pace:.2f}" - ) - return None - - def _validate_running_input(self) -> tuple[float, float, float] | None: - """Parse and validate running input fields.""" - try: - distance = float(self.distance_entry.get()) - time_mins = float(self.time_entry.get()) - pace = float(self.pace_entry.get()) - except ValueError: - self.show_error("Please enter valid numbers") - return None - error = self._check_running_ranges(distance, time_mins, pace) - if error: - self.show_error(error) - return None - return distance, time_mins, pace - - def verify_running_data(self) -> None: - """Validate running workout data and unlock if valid.""" - result = self._validate_running_input() - if result is None: - return - distance, time_mins, pace = result - self.workout_data["distance_km"] = str(distance) - self.workout_data["time_minutes"] = str(time_mins) - self.workout_data["pace_min_per_km"] = str(pace) - self._attempt_unlock() - - # ------------------------------------------------------------------ - # Strength workout - # ------------------------------------------------------------------ - - def _create_strength_entries(self) -> list[tk.Entry]: - """Create strength training entry fields.""" - entries = [ - self._entry_row(lbl, width=w, font_size=18) for lbl, w in _STRENGTH_FIELDS - ] - ( - self.exercises_entry, - self.sets_entry, - self.reps_entry, - self.weights_entry, - self.total_weight_entry, - ) = entries - return entries - - def ask_strength_details(self) -> None: - """Display strength training input form.""" - self.clear_container() - self.workout_data["type"] = "strength" - self._label("Strength Training Details", pady=20) - entries = self._create_strength_entries() - self._setup_form_controls( - entries, - self.verify_strength_data, - self.ask_workout_type, - ) - - def _parse_reps(self, reps_raw: list[str]) -> list[list[int]]: - """Parse reps input - can be single number or variable reps like '12+11+12'.""" - reps: list[list[int]] = [] - for r in reps_raw: - if "+" in r: - reps.append([int(x.strip()) for x in r.split("+")]) - else: - reps.append([int(r)]) - return reps - - def _validate_strength_inputs( - self, - exercises: list[str], - sets: list[int], - reps: list[list[int]], - weights: list[float], - ) -> str | None: - """Validate strength workout inputs. Returns error message or None if valid.""" - if not (len(exercises) == len(sets) == len(reps) == len(weights)): - return "Number of exercises, sets, reps, and weights must match" - if any(len(ex) < MIN_EXERCISE_NAME_LEN for ex in exercises): - return "Exercise names too short - be specific" - if any(s < 1 or s > MAX_SETS for s in sets): - return f"Sets should be between 1-{MAX_SETS}" - if any(w < 0 or w > MAX_WEIGHT_KG for w in weights): - return f"Weights should be between 0-{MAX_WEIGHT_KG} kg" - return self._validate_reps(exercises, sets, reps) - - def _validate_reps( - self, - exercises: list[str], - sets: list[int], - reps: list[list[int]], - ) -> str | None: - """Validate reps data. Returns error message or None if valid.""" - for i, rep_list in enumerate(reps): - if any(r < 1 or r > MAX_REPS for r in rep_list): - return f"Reps should be between 1-{MAX_REPS}" - if len(rep_list) > 1 and len(rep_list) != sets[i]: - return ( - f"For {exercises[i]!r}: variable reps count " - f"({len(rep_list)}) doesn't match sets ({sets[i]})" - ) - return None - - def _calculate_expected_total( - self, - sets: list[int], - reps: list[list[int]], - weights: list[float], - ) -> float: - """Calculate expected total weight lifted.""" - expected_total = 0.0 - for i, rep_list in enumerate(reps): - if len(rep_list) == 1: - expected_total += sets[i] * rep_list[0] * weights[i] - else: - expected_total += sum(rep_list) * weights[i] - return expected_total - - def _parse_strength_entries( - self, - ) -> tuple[list[str], list[int], list[list[int]], list[float], float]: - """Parse raw strength training input from entry widgets.""" - exercises = [e.strip() for e in self.exercises_entry.get().split(",")] - sets = [int(s.strip()) for s in self.sets_entry.get().split(",")] - reps_raw = [r.strip() for r in self.reps_entry.get().split(",")] - reps = self._parse_reps(reps_raw) - weights = [float(w.strip()) for w in self.weights_entry.get().split(",")] - total_weight = float(self.total_weight_entry.get()) - return exercises, sets, reps, weights, total_weight - - def _check_total_weight( - self, - sets: list[int], - reps: list[list[int]], - weights: list[float], - total_weight: float, - ) -> str | None: - """Verify total weight matches individual exercise calculations.""" - expected = self._calculate_expected_total(sets, reps, weights) - tolerance = expected * 0.15 # 15% tolerance - if abs(total_weight - expected) > tolerance: - return ( - f"Total weight doesn't match! " - f"Expected ~{expected:.1f} kg, got {total_weight:.1f}" - ) - return None - - def _store_strength_data( - self, - exercises: list[str], - sets: list[int], - reps: list[list[int]], - weights: list[float], - total_weight: float, - ) -> None: - """Store validated strength workout data.""" - self.workout_data["exercises"] = exercises - self.workout_data["sets"] = [str(s) for s in sets] - self.workout_data["reps"] = [ - "+".join(str(r) for r in rep_list) for rep_list in reps - ] - self.workout_data["weights_kg"] = [str(w) for w in weights] - self.workout_data["total_weight_kg"] = str(total_weight) - - def verify_strength_data(self) -> None: - """Validate strength workout data and unlock if valid.""" - try: - self._verify_strength_data_inner() - except ValueError: - self.show_error("Please enter valid data in correct format") - - def _verify_strength_data_inner(self) -> None: - """Parse, validate, and store strength data.""" - data = self._parse_strength_entries() - exercises, sets, reps, weights, total_weight = data - error = self._validate_strength_inputs(exercises, sets, reps, weights) - if error: - self.show_error(error) - return - total_err = self._check_total_weight(sets, reps, weights, total_weight) - if total_err: - self.show_error(total_err) - return - self._store_strength_data(exercises, sets, reps, weights, total_weight) - self._attempt_unlock() - - # ------------------------------------------------------------------ - # Phone workout verification via ADB + StrongLifts DB - # ------------------------------------------------------------------ - - 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 result.returncode == 0, 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 _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 on phone. - - "not_verified": Phone connected but no workout found. - - "no_phone": No phone connected via ADB. - - "error": Could not access StrongLifts database. - """ - 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" - count = self._count_today_workouts(local_db) - if count > 0: - return ( - "verified", - f"Workout verified! ({count} session(s) found on phone)", - ) - return "not_verified", "No workout found on phone today" - - def _attempt_unlock(self) -> None: - """Unlock screen after workout form submission.""" - self.unlock_screen() - - 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 self.unlock_screen - ) - delay = ( - PHONE_PENALTY_DELAY_DEMO - if self.demo_mode - else PHONE_PENALTY_DELAY_PRODUCTION - ) - 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() - - # ------------------------------------------------------------------ - # Submit timer and entry checking - # ------------------------------------------------------------------ - - def _tick_submit_timer(self) -> None: - """Decrement submit timer and schedule next tick.""" - self.timer_label.config( - text=f"Submit available in {self.submit_unlock_time} seconds...", - ) - self.submit_unlock_time -= 1 - self.root.after(1000, self.update_submit_timer) - - def _try_enable_submit(self) -> None: - """Enable submit button if all entries are filled.""" - all_filled = all(entry.get().strip() for entry in self.entries_to_check) - if all_filled: - self.submit_btn.config( - text="SUBMIT", - state="normal", - bg="#00aa00", - command=self.submit_command, - ) - self.timer_label.config(text="You can now submit!") - else: - self.timer_label.config(text="Fill all fields to enable submit") - self.root.after(1000, self.check_entries_filled) - - def update_submit_timer(self) -> None: - """Update countdown timer and check if submit can be enabled.""" - with contextlib.suppress(tk.TclError): - if self.submit_unlock_time > 0: - self._tick_submit_timer() - else: - self._try_enable_submit() - - def check_entries_filled(self) -> None: - """Continuously check if entries are filled after timer expires.""" - with contextlib.suppress(tk.TclError): - self._try_enable_submit() - # ------------------------------------------------------------------ # Error, unlock, and logging # ------------------------------------------------------------------ diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py new file mode 100644 index 0000000..9c8cc5f --- /dev/null +++ b/screen_locker/tests/conftest.py @@ -0,0 +1,113 @@ +"""Shared fixtures and helpers for screen_locker tests.""" + +from __future__ import annotations + +from pathlib import Path +import tkinter as tk +from typing import TYPE_CHECKING, NamedTuple +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 + + +class RunningData(NamedTuple): + """Running workout data for tests.""" + + distance: str + time_mins: str + pace: str + + +class StrengthData(NamedTuple): + """Strength workout data for tests.""" + + exercises: str + sets: str + reps: str + weights: str + total_weight: str + + +@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, +) -> ScreenLocker: + """Create a ScreenLocker instance for testing.""" + with ( + patch.object(Path, "resolve", return_value=tmp_path), + patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), + patch.object(ScreenLocker, "_start_phone_check"), + ): + return ScreenLocker(demo_mode=demo_mode) + + +def setup_running_entries(locker: ScreenLocker, data: RunningData) -> None: + """Set up mock running entry widgets.""" + locker.distance_entry = MagicMock() + locker.distance_entry.get.return_value = data.distance + locker.time_entry = MagicMock() + locker.time_entry.get.return_value = data.time_mins + locker.pace_entry = MagicMock() + locker.pace_entry.get.return_value = data.pace + + +def setup_strength_entries(locker: ScreenLocker, data: StrengthData) -> None: + """Set up mock strength entry widgets.""" + locker.exercises_entry = MagicMock() + locker.exercises_entry.get.return_value = data.exercises + locker.sets_entry = MagicMock() + locker.sets_entry.get.return_value = data.sets + locker.reps_entry = MagicMock() + locker.reps_entry.get.return_value = data.reps + locker.weights_entry = MagicMock() + locker.weights_entry.get.return_value = data.weights + locker.total_weight_entry = MagicMock() + locker.total_weight_entry.get.return_value = data.total_weight diff --git a/screen_locker/tests/test_adb_and_phone.py b/screen_locker/tests/test_adb_and_phone.py new file mode 100644 index 0000000..9c2b5e1 --- /dev/null +++ b/screen_locker/tests/test_adb_and_phone.py @@ -0,0 +1,411 @@ +"""Tests for ADB commands, phone connection, and database operations.""" + +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 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 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 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) + locker._run_adb = MagicMock( # type: ignore[method-assign] + 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) + locker._run_adb = MagicMock( # type: ignore[method-assign] + 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) + locker._run_adb = MagicMock( # type: ignore[method-assign] + 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) + locker._run_adb = MagicMock( # type: ignore[method-assign] + return_value=(True, "List of devices attached\n\n"), + ) + locker._try_wireless_reconnect = MagicMock( # type: ignore[method-assign] + 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) + locker._run_adb = MagicMock( # type: ignore[method-assign] + return_value=( + True, + "List of devices attached\nABC123\toffline\n\n", + ), + ) + locker._try_wireless_reconnect = MagicMock( # type: ignore[method-assign] + 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) + locker._run_adb = MagicMock( # type: ignore[method-assign] + return_value=(False, ""), + ) + locker._try_wireless_reconnect = MagicMock( # type: ignore[method-assign] + 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) + locker._adb_shell = MagicMock( # type: ignore[method-assign] + return_value=(True, ""), + ) + locker._run_adb = MagicMock( # type: ignore[method-assign] + 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) + locker._adb_shell = MagicMock( # type: ignore[method-assign] + 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) + locker._adb_shell = MagicMock( # type: ignore[method-assign] + return_value=(True, ""), + ) + locker._run_adb = MagicMock( # type: ignore[method-assign] + 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) + locker._adb_shell = MagicMock( # type: ignore[method-assign] + return_value=(True, ""), + ) + locker._run_adb = MagicMock( # type: ignore[method-assign] + 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 locker._count_today_workouts(db_file) == 0 + + 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 locker._count_today_workouts(bad_file) == 0 + + 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 locker._count_today_workouts(db_file) == 0 + + 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/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py new file mode 100644 index 0000000..feaa5c8 --- /dev/null +++ b/screen_locker/tests/test_init_and_log.py @@ -0,0 +1,390 @@ +"""Tests for screen_locker initialization, logging, and basic operations.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock + +import pytest + +from python_pkg.screen_locker.screen_lock import ( + MAX_DISTANCE_KM, + MAX_PACE_MIN_PER_KM, + MAX_REPS, + MAX_SETS, + MAX_TIME_MINUTES, + MAX_WEIGHT_KG, + MIN_EXERCISE_NAME_LEN, +) +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestConstants: + """Tests for module constants.""" + + def test_max_distance_km(self) -> None: + """Test MAX_DISTANCE_KM is reasonable.""" + assert MAX_DISTANCE_KM == 100 + assert MAX_DISTANCE_KM > 0 + + def test_max_time_minutes(self) -> None: + """Test MAX_TIME_MINUTES is reasonable.""" + assert MAX_TIME_MINUTES == 600 + assert MAX_TIME_MINUTES > 0 + + def test_max_pace_min_per_km(self) -> None: + """Test MAX_PACE_MIN_PER_KM is reasonable.""" + assert MAX_PACE_MIN_PER_KM == 20 + assert MAX_PACE_MIN_PER_KM > 0 + + def test_min_exercise_name_len(self) -> None: + """Test MIN_EXERCISE_NAME_LEN is reasonable.""" + assert MIN_EXERCISE_NAME_LEN == 3 + assert MIN_EXERCISE_NAME_LEN > 0 + + def test_max_sets(self) -> None: + """Test MAX_SETS is reasonable.""" + assert MAX_SETS == 20 + assert MAX_SETS > 0 + + def test_max_reps(self) -> None: + """Test MAX_REPS is reasonable.""" + assert MAX_REPS == 100 + assert MAX_REPS > 0 + + def test_max_weight_kg(self) -> None: + """Test MAX_WEIGHT_KG is reasonable.""" + assert MAX_WEIGHT_KG == 500 + assert MAX_WEIGHT_KG > 0 + + +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.""" + 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 + assert locker.has_logged_today() is True + + 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.""" + log_file = tmp_path / "workout_log.json" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + locker.workout_data = {"type": "running"} + 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" + + 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"} + 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"} + 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"} + # Should not raise, just log warning + locker.save_workout_log() + + +class TestShowError: + """Tests for show_error method.""" + + def test_show_error_displays_message( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test show_error clears container and displays error.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + + locker.show_error("Test error message") + + locker.clear_container.assert_called_once() + + +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() # type: ignore[attr-defined] + + +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) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=(21, 22, 8) + ) + locker._write_shutdown_config = MagicMock( # type: ignore[method-assign] + 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) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + return_value=(22, 23, 8) + ) + locker._write_shutdown_config = MagicMock( # type: ignore[method-assign] + 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) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + 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) + locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] + side_effect=OSError("permission denied") + ) + + result = locker._adjust_shutdown_time_later() + + assert result is False diff --git a/screen_locker/tests/test_phone_check_unlock.py b/screen_locker/tests/test_phone_check_unlock.py new file mode 100644 index 0000000..599324d --- /dev/null +++ b/screen_locker/tests/test_phone_check_unlock.py @@ -0,0 +1,430 @@ +"""Tests for phone workout verification, phone check, and unlock operations.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +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 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.""" + locker = create_locker(mock_tk, tmp_path) + locker._is_phone_connected = MagicMock( # type: ignore[method-assign] + return_value=True, + ) + locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign] + return_value=tmp_path / "sl.db", + ) + locker._count_today_workouts = MagicMock( # type: ignore[method-assign] + return_value=2, + ) + + status, message = locker._verify_phone_workout() + + assert status == "verified" + assert "2 session" 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) + locker._is_phone_connected = MagicMock( # type: ignore[method-assign] + return_value=True, + ) + locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign] + return_value=tmp_path / "sl.db", + ) + locker._count_today_workouts = MagicMock( # type: ignore[method-assign] + return_value=0, + ) + + status, message = locker._verify_phone_workout() + + assert status == "not_verified" + assert "No workout" 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) + locker._is_phone_connected = MagicMock( # type: ignore[method-assign] + return_value=False, + ) + + 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) + locker._is_phone_connected = MagicMock( # type: ignore[method-assign] + return_value=True, + ) + locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign] + return_value=None, + ) + + status, message = locker._verify_phone_workout() + + assert status == "error" + assert "database" 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) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker._verify_phone_workout = MagicMock( # type: ignore[method-assign] + return_value=("no_phone", "No phone"), + ) + locker._poll_phone_check = MagicMock() # type: ignore[method-assign] + + 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) + locker.unlock_screen = MagicMock() # type: ignore[method-assign] + locker.root.after = MagicMock() # type: ignore[method-assign] + + 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_block( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test not_verified result shows blocking screen with buttons.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker._handle_startup_phone_result( + "not_verified", "No workout found on phone today" + ) + + locker.clear_container.assert_called() + + 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 ask_workout_done as callback.""" + locker = create_locker(mock_tk, tmp_path) + locker._show_phone_penalty = MagicMock() # type: ignore[method-assign] + + locker._handle_startup_phone_result("no_phone", "No phone") + + locker._show_phone_penalty.assert_called_once() + _, kwargs = locker._show_phone_penalty.call_args + assert kwargs["on_done"] == locker.ask_workout_done + + 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 ask_workout_done as callback.""" + locker = create_locker(mock_tk, tmp_path) + locker._show_phone_penalty = MagicMock() # type: ignore[method-assign] + + locker._handle_startup_phone_result("error", "DB not found") + + locker._show_phone_penalty.assert_called_once() + _, kwargs = locker._show_phone_penalty.call_args + assert kwargs["on_done"] == locker.ask_workout_done + + 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 + locker._phone_future = mock_future # type: ignore[assignment] + locker.root.after = MagicMock() # type: ignore[method-assign] + + 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") + locker._phone_future = mock_future # type: ignore[assignment] + locker._handle_startup_phone_result = MagicMock() # type: ignore[method-assign] + + locker._poll_phone_check() + + locker._handle_startup_phone_result.assert_called_once_with( + "no_phone", "No phone" + ) + + +class TestAttemptUnlock: + """Tests for _attempt_unlock method.""" + + def test_attempt_unlock_calls_unlock_screen( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test _attempt_unlock calls unlock_screen directly.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + locker.unlock_screen = MagicMock() # type: ignore[method-assign] + + locker._attempt_unlock() + + locker.unlock_screen.assert_called_once() + + +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) + locker.clear_container = MagicMock() # type: ignore[method-assign] + + 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.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + locker.clear_container = MagicMock() # type: ignore[method-assign] + + locker._show_phone_penalty("test message") + + assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_PRODUCTION - 1 + + 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() # type: ignore[attr-defined] + + def test_update_phone_penalty_at_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test phone penalty unlocks when timer reaches zero.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + locker.phone_penalty_remaining = 0 + locker.phone_penalty_label = MagicMock() + locker.unlock_screen = MagicMock() # type: ignore[method-assign] + locker._phone_penalty_done_fn = locker.unlock_screen # type: ignore[attr-defined] + + locker._update_phone_penalty() + + locker.unlock_screen.assert_called_once() + + +class TestUnlockScreenShutdownAdjustment: + """Tests for unlock_screen shutdown time adjustment.""" + + def test_unlock_screen_adjusts_for_running( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test unlock_screen adjusts shutdown for running workout.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "running"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + + def test_unlock_screen_adjusts_for_strength( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test unlock_screen adjusts shutdown for strength workout.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + return_value=True + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + + 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"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + 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"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + 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 = {} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + 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": "running"} + locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] + 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() # type: ignore[attr-defined] diff --git a/screen_locker/tests/test_screen_lock.py b/screen_locker/tests/test_screen_lock.py deleted file mode 100644 index 19bd424..0000000 --- a/screen_locker/tests/test_screen_lock.py +++ /dev/null @@ -1,2079 +0,0 @@ -"""Comprehensive tests for screen_locker module. - -Tests cover: -- ScreenLocker initialization and configuration -- Workout data validation (running and strength) -- Log file operations (reading/writing) -- UI state transitions -- Timer logic -""" - -from __future__ import annotations - -from datetime import datetime, timezone -import json -from pathlib import Path -import sqlite3 -import subprocess -import tkinter as tk -from typing import TYPE_CHECKING, Any, NamedTuple -from unittest.mock import MagicMock, patch - -import pytest - -from python_pkg.screen_locker.screen_lock import ( - MAX_DISTANCE_KM, - MAX_PACE_MIN_PER_KM, - MAX_REPS, - MAX_SETS, - MAX_TIME_MINUTES, - MAX_WEIGHT_KG, - MIN_EXERCISE_NAME_LEN, - PHONE_PENALTY_DELAY_DEMO, - PHONE_PENALTY_DELAY_PRODUCTION, - STRONGLIFTS_DB_REMOTE, - SUBMIT_DELAY_DEMO, - SUBMIT_DELAY_PRODUCTION, - ScreenLocker, -) - -if TYPE_CHECKING: - from collections.abc import Generator - -# Reference tk to avoid import-but-unused error -_TK_TCLERROR = tk.TclError - - -class RunningData(NamedTuple): - """Running workout data for tests.""" - - distance: str - time_mins: str - pace: str - - -class StrengthData(NamedTuple): - """Strength workout data for tests.""" - - exercises: str - sets: str - reps: str - weights: str - total_weight: str - - -@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 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, -) -> ScreenLocker: - """Create a ScreenLocker instance for testing.""" - with ( - patch.object(Path, "resolve", return_value=tmp_path), - patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), - patch.object(ScreenLocker, "_start_phone_check"), - ): - return ScreenLocker(demo_mode=demo_mode) - - -def setup_running_entries(locker: ScreenLocker, data: RunningData) -> None: - """Set up mock running entry widgets.""" - locker.distance_entry = MagicMock() - locker.distance_entry.get.return_value = data.distance - locker.time_entry = MagicMock() - locker.time_entry.get.return_value = data.time_mins - locker.pace_entry = MagicMock() - locker.pace_entry.get.return_value = data.pace - - -def setup_strength_entries(locker: ScreenLocker, data: StrengthData) -> None: - """Set up mock strength entry widgets.""" - locker.exercises_entry = MagicMock() - locker.exercises_entry.get.return_value = data.exercises - locker.sets_entry = MagicMock() - locker.sets_entry.get.return_value = data.sets - locker.reps_entry = MagicMock() - locker.reps_entry.get.return_value = data.reps - locker.weights_entry = MagicMock() - locker.weights_entry.get.return_value = data.weights - locker.total_weight_entry = MagicMock() - locker.total_weight_entry.get.return_value = data.total_weight - - -class TestConstants: - """Tests for module constants.""" - - def test_max_distance_km(self) -> None: - """Test MAX_DISTANCE_KM is reasonable.""" - assert MAX_DISTANCE_KM == 100 - assert MAX_DISTANCE_KM > 0 - - def test_max_time_minutes(self) -> None: - """Test MAX_TIME_MINUTES is reasonable.""" - assert MAX_TIME_MINUTES == 600 - assert MAX_TIME_MINUTES > 0 - - def test_max_pace_min_per_km(self) -> None: - """Test MAX_PACE_MIN_PER_KM is reasonable.""" - assert MAX_PACE_MIN_PER_KM == 20 - assert MAX_PACE_MIN_PER_KM > 0 - - def test_min_exercise_name_len(self) -> None: - """Test MIN_EXERCISE_NAME_LEN is reasonable.""" - assert MIN_EXERCISE_NAME_LEN == 3 - assert MIN_EXERCISE_NAME_LEN > 0 - - def test_max_sets(self) -> None: - """Test MAX_SETS is reasonable.""" - assert MAX_SETS == 20 - assert MAX_SETS > 0 - - def test_max_reps(self) -> None: - """Test MAX_REPS is reasonable.""" - assert MAX_REPS == 100 - assert MAX_REPS > 0 - - def test_max_weight_kg(self) -> None: - """Test MAX_WEIGHT_KG is reasonable.""" - assert MAX_WEIGHT_KG == 500 - assert MAX_WEIGHT_KG > 0 - - -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.""" - 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 - assert locker.has_logged_today() is True - - 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.""" - log_file = tmp_path / "workout_log.json" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - locker.workout_data = {"type": "running"} - 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" - - 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"} - 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"} - 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"} - # Should not raise, just log warning - locker.save_workout_log() - - -class TestVerifyRunningData: - """Tests for verify_running_data method.""" - - def test_valid_running_data( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test valid running data triggers unlock attempt.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("5", "25", "5")) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "running"} - locker._attempt_unlock = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker._attempt_unlock.assert_called_once() - - def test_invalid_distance_zero( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test zero distance is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("0", "25", "5")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Distance" in locker.show_error.call_args[0][0] - - def test_invalid_distance_too_high( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test distance over max is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("150", "600", "4")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Distance" in locker.show_error.call_args[0][0] - - def test_invalid_time_zero( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test zero time is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("5", "0", "5")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Time" in locker.show_error.call_args[0][0] - - def test_invalid_time_too_high( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test time over max is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("5", "700", "5")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Time" in locker.show_error.call_args[0][0] - - def test_invalid_pace_zero( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test zero pace is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("5", "25", "0")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Pace" in locker.show_error.call_args[0][0] - - def test_invalid_pace_too_high( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test pace over max is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("5", "25", "25")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Pace" in locker.show_error.call_args[0][0] - - def test_pace_mismatch( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test pace mismatch is rejected.""" - # 5km in 25 min should be 5 min/km, but we say 10 min/km - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("5", "25", "10")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "Pace doesn't match" in locker.show_error.call_args[0][0] - - def test_invalid_number_format( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test non-numeric input is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_running_entries(locker, RunningData("abc", "25", "5")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_running_data() - - locker.show_error.assert_called_once() - assert "valid numbers" in locker.show_error.call_args[0][0] - - -class TestVerifyStrengthData: - """Tests for verify_strength_data method.""" - - def test_valid_strength_data( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test valid strength data triggers unlock attempt.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "3", "10", "50", "1500")) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "strength"} - locker._attempt_unlock = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker._attempt_unlock.assert_called_once() - - def test_valid_multiple_exercises( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test valid data with multiple exercises.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries( - locker, - StrengthData("Squat, Bench Press", "3, 3", "10, 8", "50, 40", "2460"), - ) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "strength"} - locker._attempt_unlock = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker._attempt_unlock.assert_called_once() - - def test_mismatched_list_lengths( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test mismatched list lengths are rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries( - locker, - StrengthData("Squat, Bench", "3", "10, 8", "50, 40", "2000"), - ) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "must match" in locker.show_error.call_args[0][0] - - def test_short_exercise_name( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test short exercise names are rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Sq", "3", "10", "50", "1500")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "too short" in locker.show_error.call_args[0][0] - - def test_invalid_sets_zero( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test zero sets is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "0", "10", "50", "0")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Sets" in locker.show_error.call_args[0][0] - - def test_invalid_sets_too_high( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test sets over max is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "25", "10", "50", "12500")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Sets" in locker.show_error.call_args[0][0] - - def test_invalid_reps_zero( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test zero reps is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "3", "0", "50", "0")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Reps" in locker.show_error.call_args[0][0] - - def test_invalid_reps_too_high( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test reps over max is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "3", "150", "50", "22500")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Reps" in locker.show_error.call_args[0][0] - - def test_invalid_weight_negative( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test negative weight is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "3", "10", "-10", "-300")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Weights" in locker.show_error.call_args[0][0] - - def test_invalid_weight_too_high( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test weight over max is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "3", "10", "600", "18000")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Weights" in locker.show_error.call_args[0][0] - - def test_total_weight_mismatch( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test total weight mismatch is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "3", "10", "50", "3000")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "Total weight doesn't match" in locker.show_error.call_args[0][0] - - def test_invalid_format( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test invalid format is rejected.""" - locker = create_locker(mock_tk, tmp_path) - setup_strength_entries(locker, StrengthData("Squat", "abc", "10", "50", "1500")) - locker.show_error = MagicMock() # type: ignore[method-assign] - - locker.verify_strength_data() - - locker.show_error.assert_called_once() - assert "valid data" in locker.show_error.call_args[0][0] - - -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 = [ # type: ignore[attr-defined] - 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": "running"} - - locker.unlock_screen() - - # Check that after() was called to schedule close - locker.root.after.assert_called() # type: ignore[attr-defined] - - 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() # type: ignore[attr-defined] - 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( # type: ignore[attr-defined] - 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 returns to workout question.""" - locker = create_locker(mock_tk, tmp_path) - locker.remaining_time = 0 - locker.countdown_label = MagicMock() - locker.ask_workout_done = MagicMock() # type: ignore[method-assign] - - locker.update_lockout_countdown() - - locker.ask_workout_done.assert_called_once() - - def test_update_submit_timer_countdown( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test submit timer counts down.""" - locker = create_locker(mock_tk, tmp_path) - locker.submit_unlock_time = 5 - locker.timer_label = MagicMock() - locker.submit_btn = MagicMock() - locker.entries_to_check = [] - - locker.update_submit_timer() - - assert locker.submit_unlock_time == 4 - locker.root.after.assert_called() # type: ignore[attr-defined] - - def test_update_submit_timer_enables_when_filled( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test submit enabled when timer done and entries filled.""" - locker = create_locker(mock_tk, tmp_path) - locker.submit_unlock_time = 0 - locker.timer_label = MagicMock() - locker.submit_btn = MagicMock() - mock_entry = MagicMock() - mock_entry.get.return_value = "some value" - locker.entries_to_check = [mock_entry] - locker.submit_command = MagicMock() - - locker.update_submit_timer() - - locker.submit_btn.config.assert_called() - - def test_update_submit_timer_waits_for_entries( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test submit waits when entries not filled.""" - locker = create_locker(mock_tk, tmp_path) - locker.submit_unlock_time = 0 - locker.timer_label = MagicMock() - locker.submit_btn = MagicMock() - mock_entry = MagicMock() - mock_entry.get.return_value = "" # Empty entry - locker.entries_to_check = [mock_entry] - - locker.update_submit_timer() - - locker.root.after.assert_called_with( # type: ignore[attr-defined] - 1000, locker.check_entries_filled - ) - - def test_update_submit_timer_handles_tcl_error( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test timer handles TclError when widgets destroyed.""" - locker = create_locker(mock_tk, tmp_path) - locker.submit_unlock_time = 5 - locker.timer_label = MagicMock() - locker.timer_label.config.side_effect = _TK_TCLERROR("widget destroyed") - - # Should not raise - locker.update_submit_timer() - - def test_check_entries_filled_enables_submit( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test check_entries_filled enables submit when all filled.""" - locker = create_locker(mock_tk, tmp_path) - locker.timer_label = MagicMock() - locker.submit_btn = MagicMock() - mock_entry = MagicMock() - mock_entry.get.return_value = "value" - locker.entries_to_check = [mock_entry] - locker.submit_command = MagicMock() - - locker.check_entries_filled() - - locker.submit_btn.config.assert_called() - - def test_check_entries_filled_continues_waiting( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test check_entries_filled continues waiting when not filled.""" - locker = create_locker(mock_tk, tmp_path) - locker.timer_label = MagicMock() - locker.submit_btn = MagicMock() - mock_entry = MagicMock() - mock_entry.get.return_value = "" - locker.entries_to_check = [mock_entry] - - locker.check_entries_filled() - - locker.root.after.assert_called_with( # type: ignore[attr-defined] - 1000, locker.check_entries_filled - ) - - def test_check_entries_filled_handles_tcl_error( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test check_entries_filled handles TclError.""" - locker = create_locker(mock_tk, tmp_path) - locker.timer_label = MagicMock() - mock_entry = MagicMock() - mock_entry.get.side_effect = _TK_TCLERROR("widget destroyed") - locker.entries_to_check = [mock_entry] - - # Should not raise - locker.check_entries_filled() - - -class TestShowError: - """Tests for show_error method.""" - - def test_show_error_displays_message( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test show_error clears container and displays error.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - - locker.show_error("Test error message") - - locker.clear_container.assert_called_once() - - -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() # type: ignore[attr-defined] - - -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 TestAskWorkoutType: - """Tests for ask_workout_type method.""" - - def test_ask_workout_type_creates_buttons( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_workout_type creates running and strength buttons.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - - locker.ask_workout_type() - - locker.clear_container.assert_called_once() - # Verify Label and Button were called - mock_tk.Label.assert_called() - mock_tk.Button.assert_called() - - -class TestAskRunningDetails: - """Tests for ask_running_details method.""" - - def test_ask_running_details_sets_workout_type( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_running_details sets workout type to running.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_running_details() - - assert locker.workout_data["type"] == "running" - locker.clear_container.assert_called_once() - - def test_ask_running_details_creates_entry_fields( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_running_details creates entry fields.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_running_details() - - # Verify Entry fields were created - mock_tk.Entry.assert_called() - assert hasattr(locker, "distance_entry") - assert hasattr(locker, "time_entry") - assert hasattr(locker, "pace_entry") - - def test_ask_running_details_sets_timer( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_running_details initializes submit timer.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_running_details() - - assert locker.submit_unlock_time == SUBMIT_DELAY_DEMO - locker.update_submit_timer.assert_called_once() - - -class TestAskStrengthDetails: - """Tests for ask_strength_details method.""" - - def test_ask_strength_details_sets_workout_type( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_strength_details sets workout type to strength.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_strength_details() - - assert locker.workout_data["type"] == "strength" - locker.clear_container.assert_called_once() - - def test_ask_strength_details_creates_entry_fields( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_strength_details creates entry fields.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_strength_details() - - # Verify Entry fields were created - mock_tk.Entry.assert_called() - assert hasattr(locker, "exercises_entry") - assert hasattr(locker, "sets_entry") - assert hasattr(locker, "reps_entry") - assert hasattr(locker, "weights_entry") - assert hasattr(locker, "total_weight_entry") - - def test_ask_strength_details_sets_timer( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_strength_details initializes submit timer.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_strength_details() - - assert locker.submit_unlock_time == SUBMIT_DELAY_DEMO - locker.update_submit_timer.assert_called_once() - - def test_ask_strength_details_production_timer( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test production mode uses longer submit delay.""" - locker = create_locker(mock_tk, tmp_path, demo_mode=False) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker.update_submit_timer = MagicMock() # type: ignore[method-assign] - - locker.ask_strength_details() - - assert locker.submit_unlock_time == SUBMIT_DELAY_PRODUCTION - - -class TestAskWorkoutDone: - """Tests for ask_workout_done method.""" - - def test_ask_workout_done_creates_buttons( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ask_workout_done creates yes/no buttons.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - - locker.ask_workout_done() - - locker.clear_container.assert_called_once() - mock_tk.Label.assert_called() - mock_tk.Button.assert_called() - - -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) - locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] - return_value=(21, 22, 8) - ) - locker._write_shutdown_config = MagicMock( # type: ignore[method-assign] - 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) - locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] - return_value=(22, 23, 8) - ) - locker._write_shutdown_config = MagicMock( # type: ignore[method-assign] - 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) - locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] - 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) - locker._read_shutdown_config = MagicMock( # type: ignore[method-assign] - side_effect=OSError("permission denied") - ) - - result = locker._adjust_shutdown_time_later() - - assert result is False - - -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.screen_lock.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.screen_lock.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.screen_lock.subprocess.run", - side_effect=FileNotFoundError("adb not found"), - ): - success, output = locker._run_adb(["devices"]) - - assert success is False - assert 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.screen_lock.subprocess.run", - side_effect=OSError("permission denied"), - ): - success, output = locker._run_adb(["devices"]) - - assert success is False - assert 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.screen_lock.subprocess.run", - side_effect=subprocess.TimeoutExpired("adb", 15), - ): - success, output = locker._run_adb(["devices"]) - - assert success is False - assert 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) - locker._run_adb = MagicMock( # type: ignore[method-assign] - 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) - locker._run_adb = MagicMock( # type: ignore[method-assign] - 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) - locker._run_adb = MagicMock( # type: ignore[method-assign] - 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) - locker._run_adb = MagicMock( # type: ignore[method-assign] - return_value=(True, "List of devices attached\n\n"), - ) - locker._try_wireless_reconnect = MagicMock( # type: ignore[method-assign] - 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) - locker._run_adb = MagicMock( # type: ignore[method-assign] - return_value=( - True, - "List of devices attached\nABC123\toffline\n\n", - ), - ) - locker._try_wireless_reconnect = MagicMock( # type: ignore[method-assign] - 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) - locker._run_adb = MagicMock( # type: ignore[method-assign] - return_value=(False, ""), - ) - locker._try_wireless_reconnect = MagicMock( # type: ignore[method-assign] - 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) - locker._adb_shell = MagicMock( # type: ignore[method-assign] - return_value=(True, ""), - ) - locker._run_adb = MagicMock( # type: ignore[method-assign] - 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) - locker._adb_shell = MagicMock( # type: ignore[method-assign] - 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) - locker._adb_shell = MagicMock( # type: ignore[method-assign] - return_value=(True, ""), - ) - locker._run_adb = MagicMock( # type: ignore[method-assign] - 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) - locker._adb_shell = MagicMock( # type: ignore[method-assign] - return_value=(True, ""), - ) - locker._run_adb = MagicMock( # type: ignore[method-assign] - 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) - import time - - 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) - import time - - yesterday_ms = int((time.time() - 200000) * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", yesterday_ms, yesterday_ms + 3600000), - ) - conn.commit() - conn.close() - - assert locker._count_today_workouts(db_file) == 0 - - 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 locker._count_today_workouts(bad_file) == 0 - - 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 locker._count_today_workouts(db_file) == 0 - - 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)", - ) - import time - - 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 - - -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.""" - locker = create_locker(mock_tk, tmp_path) - locker._is_phone_connected = MagicMock( # type: ignore[method-assign] - return_value=True, - ) - locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign] - return_value=tmp_path / "sl.db", - ) - locker._count_today_workouts = MagicMock( # type: ignore[method-assign] - return_value=2, - ) - - status, message = locker._verify_phone_workout() - - assert status == "verified" - assert "2 session" 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) - locker._is_phone_connected = MagicMock( # type: ignore[method-assign] - return_value=True, - ) - locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign] - return_value=tmp_path / "sl.db", - ) - locker._count_today_workouts = MagicMock( # type: ignore[method-assign] - return_value=0, - ) - - status, message = locker._verify_phone_workout() - - assert status == "not_verified" - assert "No workout" 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) - locker._is_phone_connected = MagicMock( # type: ignore[method-assign] - return_value=False, - ) - - 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) - locker._is_phone_connected = MagicMock( # type: ignore[method-assign] - return_value=True, - ) - locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign] - return_value=None, - ) - - status, message = locker._verify_phone_workout() - - assert status == "error" - assert "database" 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) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker._verify_phone_workout = MagicMock( # type: ignore[method-assign] - return_value=("no_phone", "No phone"), - ) - locker._poll_phone_check = MagicMock() # type: ignore[method-assign] - - 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) - locker.unlock_screen = MagicMock() # type: ignore[method-assign] - locker.root.after = MagicMock() # type: ignore[method-assign] - - 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_block( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test not_verified result shows blocking screen with buttons.""" - locker = create_locker(mock_tk, tmp_path) - locker.clear_container = MagicMock() # type: ignore[method-assign] - locker._handle_startup_phone_result( - "not_verified", "No workout found on phone today" - ) - - locker.clear_container.assert_called() - - 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 ask_workout_done as callback.""" - locker = create_locker(mock_tk, tmp_path) - locker._show_phone_penalty = MagicMock() # type: ignore[method-assign] - - locker._handle_startup_phone_result("no_phone", "No phone") - - locker._show_phone_penalty.assert_called_once() - _, kwargs = locker._show_phone_penalty.call_args - assert kwargs["on_done"] == locker.ask_workout_done - - 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 ask_workout_done as callback.""" - locker = create_locker(mock_tk, tmp_path) - locker._show_phone_penalty = MagicMock() # type: ignore[method-assign] - - locker._handle_startup_phone_result("error", "DB not found") - - locker._show_phone_penalty.assert_called_once() - _, kwargs = locker._show_phone_penalty.call_args - assert kwargs["on_done"] == locker.ask_workout_done - - 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 - locker._phone_future = mock_future # type: ignore[assignment] - locker.root.after = MagicMock() # type: ignore[method-assign] - - 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") - locker._phone_future = mock_future # type: ignore[assignment] - locker._handle_startup_phone_result = MagicMock() # type: ignore[method-assign] - - locker._poll_phone_check() - - locker._handle_startup_phone_result.assert_called_once_with( - "no_phone", "No phone" - ) - - -class TestAttemptUnlock: - """Tests for _attempt_unlock method.""" - - def test_attempt_unlock_calls_unlock_screen( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test _attempt_unlock calls unlock_screen directly.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "strength"} - locker.unlock_screen = MagicMock() # type: ignore[method-assign] - - locker._attempt_unlock() - - locker.unlock_screen.assert_called_once() - - -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) - locker.clear_container = MagicMock() # type: ignore[method-assign] - - 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.""" - locker = create_locker(mock_tk, tmp_path, demo_mode=False) - locker.clear_container = MagicMock() # type: ignore[method-assign] - - locker._show_phone_penalty("test message") - - assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_PRODUCTION - 1 - - 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() # type: ignore[attr-defined] - - def test_update_phone_penalty_at_zero( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test phone penalty unlocks when timer reaches zero.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "strength"} - locker.phone_penalty_remaining = 0 - locker.phone_penalty_label = MagicMock() - locker.unlock_screen = MagicMock() # type: ignore[method-assign] - locker._phone_penalty_done_fn = locker.unlock_screen # type: ignore[attr-defined] - - locker._update_phone_penalty() - - locker.unlock_screen.assert_called_once() - - -class TestUnlockScreenShutdownAdjustment: - """Tests for unlock_screen shutdown time adjustment.""" - - def test_unlock_screen_adjusts_for_running( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test unlock_screen adjusts shutdown for running workout.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "running"} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - return_value=True - ) - - locker.unlock_screen() - - locker._adjust_shutdown_time_later.assert_called_once() - - def test_unlock_screen_adjusts_for_strength( - self, - mock_tk: MagicMock, - _mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test unlock_screen adjusts shutdown for strength workout.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "strength"} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - return_value=True - ) - - locker.unlock_screen() - - locker._adjust_shutdown_time_later.assert_called_once() - - 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"} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - 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"} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - 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 = {} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - 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": "running"} - locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign] - 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() # type: ignore[attr-defined] diff --git a/screen_locker/tests/test_ui_and_timers.py b/screen_locker/tests/test_ui_and_timers.py new file mode 100644 index 0000000..209b104 --- /dev/null +++ b/screen_locker/tests/test_ui_and_timers.py @@ -0,0 +1,424 @@ +"""Tests for UI transitions, timer logic, and workout detail screens.""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from python_pkg.screen_locker.screen_lock import ( + SUBMIT_DELAY_DEMO, + SUBMIT_DELAY_PRODUCTION, +) +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + +_TK_TCLERROR = tk.TclError + + +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 = [ # type: ignore[attr-defined] + 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": "running"} + + locker.unlock_screen() + + # Check that after() was called to schedule close + locker.root.after.assert_called() # type: ignore[attr-defined] + + 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() # type: ignore[attr-defined] + 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( # type: ignore[attr-defined] + 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 returns to workout question.""" + locker = create_locker(mock_tk, tmp_path) + locker.remaining_time = 0 + locker.countdown_label = MagicMock() + locker.ask_workout_done = MagicMock() # type: ignore[method-assign] + + locker.update_lockout_countdown() + + locker.ask_workout_done.assert_called_once() + + def test_update_submit_timer_countdown( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test submit timer counts down.""" + locker = create_locker(mock_tk, tmp_path) + locker.submit_unlock_time = 5 + locker.timer_label = MagicMock() + locker.submit_btn = MagicMock() + locker.entries_to_check = [] + + locker.update_submit_timer() + + assert locker.submit_unlock_time == 4 + locker.root.after.assert_called() # type: ignore[attr-defined] + + def test_update_submit_timer_enables_when_filled( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test submit enabled when timer done and entries filled.""" + locker = create_locker(mock_tk, tmp_path) + locker.submit_unlock_time = 0 + locker.timer_label = MagicMock() + locker.submit_btn = MagicMock() + mock_entry = MagicMock() + mock_entry.get.return_value = "some value" + locker.entries_to_check = [mock_entry] + locker.submit_command = MagicMock() + + locker.update_submit_timer() + + locker.submit_btn.config.assert_called() + + def test_update_submit_timer_waits_for_entries( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test submit waits when entries not filled.""" + locker = create_locker(mock_tk, tmp_path) + locker.submit_unlock_time = 0 + locker.timer_label = MagicMock() + locker.submit_btn = MagicMock() + mock_entry = MagicMock() + mock_entry.get.return_value = "" # Empty entry + locker.entries_to_check = [mock_entry] + + locker.update_submit_timer() + + locker.root.after.assert_called_with( # type: ignore[attr-defined] + 1000, locker.check_entries_filled + ) + + def test_update_submit_timer_handles_tcl_error( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test timer handles TclError when widgets destroyed.""" + locker = create_locker(mock_tk, tmp_path) + locker.submit_unlock_time = 5 + locker.timer_label = MagicMock() + locker.timer_label.config.side_effect = _TK_TCLERROR("widget destroyed") + + # Should not raise + locker.update_submit_timer() + + def test_check_entries_filled_enables_submit( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test check_entries_filled enables submit when all filled.""" + locker = create_locker(mock_tk, tmp_path) + locker.timer_label = MagicMock() + locker.submit_btn = MagicMock() + mock_entry = MagicMock() + mock_entry.get.return_value = "value" + locker.entries_to_check = [mock_entry] + locker.submit_command = MagicMock() + + locker.check_entries_filled() + + locker.submit_btn.config.assert_called() + + def test_check_entries_filled_continues_waiting( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test check_entries_filled continues waiting when not filled.""" + locker = create_locker(mock_tk, tmp_path) + locker.timer_label = MagicMock() + locker.submit_btn = MagicMock() + mock_entry = MagicMock() + mock_entry.get.return_value = "" + locker.entries_to_check = [mock_entry] + + locker.check_entries_filled() + + locker.root.after.assert_called_with( # type: ignore[attr-defined] + 1000, locker.check_entries_filled + ) + + def test_check_entries_filled_handles_tcl_error( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test check_entries_filled handles TclError.""" + locker = create_locker(mock_tk, tmp_path) + locker.timer_label = MagicMock() + mock_entry = MagicMock() + mock_entry.get.side_effect = _TK_TCLERROR("widget destroyed") + locker.entries_to_check = [mock_entry] + + # Should not raise + locker.check_entries_filled() + + +class TestAskWorkoutType: + """Tests for ask_workout_type method.""" + + def test_ask_workout_type_creates_buttons( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_workout_type creates running and strength buttons.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + + locker.ask_workout_type() + + locker.clear_container.assert_called_once() + # Verify Label and Button were called + mock_tk.Label.assert_called() + mock_tk.Button.assert_called() + + +class TestAskRunningDetails: + """Tests for ask_running_details method.""" + + def test_ask_running_details_sets_workout_type( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_running_details sets workout type to running.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_running_details() + + assert locker.workout_data["type"] == "running" + locker.clear_container.assert_called_once() + + def test_ask_running_details_creates_entry_fields( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_running_details creates entry fields.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_running_details() + + # Verify Entry fields were created + mock_tk.Entry.assert_called() + assert hasattr(locker, "distance_entry") + assert hasattr(locker, "time_entry") + assert hasattr(locker, "pace_entry") + + def test_ask_running_details_sets_timer( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_running_details initializes submit timer.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_running_details() + + assert locker.submit_unlock_time == SUBMIT_DELAY_DEMO + locker.update_submit_timer.assert_called_once() + + +class TestAskStrengthDetails: + """Tests for ask_strength_details method.""" + + def test_ask_strength_details_sets_workout_type( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_strength_details sets workout type to strength.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_strength_details() + + assert locker.workout_data["type"] == "strength" + locker.clear_container.assert_called_once() + + def test_ask_strength_details_creates_entry_fields( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_strength_details creates entry fields.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_strength_details() + + # Verify Entry fields were created + mock_tk.Entry.assert_called() + assert hasattr(locker, "exercises_entry") + assert hasattr(locker, "sets_entry") + assert hasattr(locker, "reps_entry") + assert hasattr(locker, "weights_entry") + assert hasattr(locker, "total_weight_entry") + + def test_ask_strength_details_sets_timer( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_strength_details initializes submit timer.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_strength_details() + + assert locker.submit_unlock_time == SUBMIT_DELAY_DEMO + locker.update_submit_timer.assert_called_once() + + def test_ask_strength_details_production_timer( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test production mode uses longer submit delay.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + locker.clear_container = MagicMock() # type: ignore[method-assign] + locker.update_submit_timer = MagicMock() # type: ignore[method-assign] + + locker.ask_strength_details() + + assert locker.submit_unlock_time == SUBMIT_DELAY_PRODUCTION + + +class TestAskWorkoutDone: + """Tests for ask_workout_done method.""" + + def test_ask_workout_done_creates_buttons( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ask_workout_done creates yes/no buttons.""" + locker = create_locker(mock_tk, tmp_path) + locker.clear_container = MagicMock() # type: ignore[method-assign] + + locker.ask_workout_done() + + locker.clear_container.assert_called_once() + mock_tk.Label.assert_called() + mock_tk.Button.assert_called() diff --git a/screen_locker/tests/test_verify_data.py b/screen_locker/tests/test_verify_data.py new file mode 100644 index 0000000..05a064b --- /dev/null +++ b/screen_locker/tests/test_verify_data.py @@ -0,0 +1,371 @@ +"""Tests for running and strength data verification.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from python_pkg.screen_locker.tests.conftest import ( + RunningData, + StrengthData, + create_locker, + setup_running_entries, + setup_strength_entries, +) + +if TYPE_CHECKING: + from pathlib import Path + + +class TestVerifyRunningData: + """Tests for verify_running_data method.""" + + def test_valid_running_data( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test valid running data triggers unlock attempt.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("5", "25", "5")) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "running"} + locker._attempt_unlock = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker._attempt_unlock.assert_called_once() + + def test_invalid_distance_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test zero distance is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("0", "25", "5")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Distance" in locker.show_error.call_args[0][0] + + def test_invalid_distance_too_high( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test distance over max is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("150", "600", "4")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Distance" in locker.show_error.call_args[0][0] + + def test_invalid_time_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test zero time is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("5", "0", "5")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Time" in locker.show_error.call_args[0][0] + + def test_invalid_time_too_high( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test time over max is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("5", "700", "5")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Time" in locker.show_error.call_args[0][0] + + def test_invalid_pace_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test zero pace is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("5", "25", "0")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Pace" in locker.show_error.call_args[0][0] + + def test_invalid_pace_too_high( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test pace over max is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("5", "25", "25")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Pace" in locker.show_error.call_args[0][0] + + def test_pace_mismatch( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test pace mismatch is rejected.""" + # 5km in 25 min should be 5 min/km, but we say 10 min/km + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("5", "25", "10")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "Pace doesn't match" in locker.show_error.call_args[0][0] + + def test_invalid_number_format( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test non-numeric input is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_running_entries(locker, RunningData("abc", "25", "5")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_running_data() + + locker.show_error.assert_called_once() + assert "valid numbers" in locker.show_error.call_args[0][0] + + +class TestVerifyStrengthData: + """Tests for verify_strength_data method.""" + + def test_valid_strength_data( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test valid strength data triggers unlock attempt.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "3", "10", "50", "1500")) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + locker._attempt_unlock = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker._attempt_unlock.assert_called_once() + + def test_valid_multiple_exercises( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test valid data with multiple exercises.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries( + locker, + StrengthData("Squat, Bench Press", "3, 3", "10, 8", "50, 40", "2460"), + ) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "strength"} + locker._attempt_unlock = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker._attempt_unlock.assert_called_once() + + def test_mismatched_list_lengths( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test mismatched list lengths are rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries( + locker, + StrengthData("Squat, Bench", "3", "10, 8", "50, 40", "2000"), + ) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "must match" in locker.show_error.call_args[0][0] + + def test_short_exercise_name( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test short exercise names are rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Sq", "3", "10", "50", "1500")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "too short" in locker.show_error.call_args[0][0] + + def test_invalid_sets_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test zero sets is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "0", "10", "50", "0")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Sets" in locker.show_error.call_args[0][0] + + def test_invalid_sets_too_high( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test sets over max is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "25", "10", "50", "12500")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Sets" in locker.show_error.call_args[0][0] + + def test_invalid_reps_zero( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test zero reps is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "3", "0", "50", "0")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Reps" in locker.show_error.call_args[0][0] + + def test_invalid_reps_too_high( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test reps over max is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "3", "150", "50", "22500")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Reps" in locker.show_error.call_args[0][0] + + def test_invalid_weight_negative( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test negative weight is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "3", "10", "-10", "-300")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Weights" in locker.show_error.call_args[0][0] + + def test_invalid_weight_too_high( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test weight over max is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "3", "10", "600", "18000")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Weights" in locker.show_error.call_args[0][0] + + def test_total_weight_mismatch( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test total weight mismatch is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "3", "10", "50", "3000")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "Total weight doesn't match" in locker.show_error.call_args[0][0] + + def test_invalid_format( + self, + mock_tk: MagicMock, + _mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test invalid format is rejected.""" + locker = create_locker(mock_tk, tmp_path) + setup_strength_entries(locker, StrengthData("Squat", "abc", "10", "50", "1500")) + locker.show_error = MagicMock() # type: ignore[method-assign] + + locker.verify_strength_data() + + locker.show_error.assert_called_once() + assert "valid data" in locker.show_error.call_args[0][0]