WIP: Enforce 500-line limit - split batch 1

Split 16+ files. 27 files still need splitting. See session notes.
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-16 22:46:48 +01:00
parent 71cfe84990
commit aaca61a830
13 changed files with 3230 additions and 3068 deletions

View File

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

View File

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

262
screen_locker/_shutdown.py Normal file
View File

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

294
screen_locker/_ui_flows.py Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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