refactor: remove manual workout forms, ADB-only verification + sick mode

- Remove _workout_forms.py and all manual running/strength workout forms
- Verification is now ADB-only: phone check → verified (unlock) | failed (retry + sick mode)
- Add systemd timer (workout-locker.timer) for periodic 15min checks
- Fix service unit: add PYTHONPATH, WorkingDirectory, use -m invocation
- Update install/remove scripts for timer support
- Remove form-related constants, tests, and conftest helpers
- 127 tests, 100% branch coverage maintained
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-03-27 15:54:01 +01:00
parent 0462565d99
commit ead6072eee
13 changed files with 120 additions and 1392 deletions

View File

@ -4,17 +4,7 @@ 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
@ -26,11 +16,3 @@ SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
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

@ -3,41 +3,21 @@
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,
)
if TYPE_CHECKING:
from collections.abc import Callable
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()
@ -56,6 +36,27 @@ class UIFlowsMixin:
else:
self.root.after(500, self._poll_phone_check)
def _show_retry_and_sick(self, message: str) -> None:
"""Show TRY AGAIN and I'm sick buttons after a failed phone check."""
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(message, color="#ffaa00")
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
command=self._start_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)
def _handle_startup_phone_result(self, status: str, message: str) -> None:
"""Route to appropriate screen based on startup phone check result."""
if status == "verified":
@ -70,32 +71,14 @@ class UIFlowsMixin:
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(
self._show_retry_and_sick(
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)
# no_phone or error — penalty timer, then retry+sick screen
self._show_phone_penalty(message)
def ask_if_sick(self) -> None:
"""Display sick day question dialog."""
@ -198,23 +181,21 @@ class UIFlowsMixin:
self.remaining_time -= 1
self.root.after(1000, self.update_lockout_countdown)
else:
self.ask_workout_done()
self._start_phone_check()
# ------------------------------------------------------------------
# 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
on_done
if on_done is not None
else lambda: self._show_retry_and_sick(message)
)
delay = (
PHONE_PENALTY_DELAY_DEMO
@ -252,43 +233,3 @@ class UIFlowsMixin:
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

@ -1,269 +0,0 @@
"""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()

View File

@ -4,8 +4,10 @@
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
TIMER_FILE="$SCRIPT_DIR/workout-locker.timer"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
SERVICE_NAME="workout-locker.service"
TIMER_NAME="workout-locker.timer"
# Check if service is already installed
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
@ -24,25 +26,31 @@ fi
# Create user systemd directory if it doesn't exist
mkdir -p "$USER_SERVICE_DIR"
# Copy service file to user systemd directory
# Copy service and timer files to user systemd directory
cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
cp "$TIMER_FILE" "$USER_SERVICE_DIR/$TIMER_NAME"
# Update the ExecStart path in the service file to use absolute path with production flag
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 $SCRIPT_DIR/screen_lock.py --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Update paths in the service file to use absolute paths
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
sed -i "s|Environment=PYTHONPATH=.*|Environment=PYTHONPATH=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Reload systemd daemon
systemctl --user daemon-reload
# Enable the service to start on login
# Enable the service to start on login and the timer for periodic checks
systemctl --user enable "$SERVICE_NAME"
systemctl --user enable --now "$TIMER_NAME"
echo "✓ Workout locker service installed"
echo "✓ Service will start automatically on next login"
echo ""
echo "To start now: systemctl --user start workout-locker"
echo "To check status: systemctl --user status workout-locker"
echo "To check timer: systemctl --user list-timers workout-locker.timer"
echo "To stop: systemctl --user stop workout-locker"
echo "To disable autostart: systemctl --user disable workout-locker"
echo "To disable autostart: systemctl --user disable workout-locker workout-locker.timer"
# Check autostart installation status
echo ""

View File

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

View File

@ -20,41 +20,22 @@ if TYPE_CHECKING:
from concurrent.futures import Future
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,
PHONE_PENALTY_DELAY_DEMO,
PHONE_PENALTY_DELAY_PRODUCTION,
SICK_LOCKOUT_SECONDS,
STRONGLIFTS_DB_REMOTE,
SUBMIT_DELAY_DEMO,
SUBMIT_DELAY_PRODUCTION,
)
__all__ = [
"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",
"ScreenLocker",
]
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__)
@ -62,7 +43,6 @@ _logger = logging.getLogger(__name__)
class ScreenLocker(
ShutdownMixin,
PhoneVerificationMixin,
WorkoutFormsMixin,
UIFlowsMixin,
):
"""Screen locker that requires workout logging to unlock."""
@ -200,103 +180,14 @@ class ScreenLocker(
frame.pack(pady=20)
return frame
def _entry_row(
self,
label_text: str,
*,
width: int = 10,
font_size: int = 20,
) -> tk.Entry:
"""Create a labeled entry row, returning the Entry widget."""
frame = tk.Frame(self.container, bg="#1a1a1a")
frame.pack(pady=10)
tk.Label(
frame,
text=label_text,
font=("Arial", font_size),
fg="white",
bg="#1a1a1a",
).pack(side="left", padx=10)
entry = tk.Entry(frame, font=("Arial", font_size), width=width)
entry.pack(side="left", padx=10)
return entry
def _disabled_submit_button(self) -> tk.Button:
"""Create a disabled submit button."""
btn = tk.Button(
self.container,
text="SUBMIT (locked)",
font=("Arial", 24, "bold"),
bg="#666666",
fg="white",
width=15,
state="disabled",
cursor="hand2" if self.demo_mode else "",
)
btn.pack(pady=10)
return btn
def _back_button(self, command: Callable[[], None]) -> tk.Button:
"""Create and pack a back button."""
btn = tk.Button(
self.container,
text="← BACK",
font=("Arial", 18),
bg="#666666",
fg="white",
width=15,
command=command,
cursor="hand2" if self.demo_mode else "",
)
btn.pack(pady=10)
return btn
def _setup_form_controls(
self,
entries: list[tk.Entry],
verify_command: Callable[[], None],
back_command: Callable[[], None],
) -> None:
"""Set up timer, submit button, and back button for a form."""
self.timer_label = self._text("", font_size=16, color="#ffaa00")
self.submit_btn = self._disabled_submit_button()
self._back_button(back_command)
self.submit_unlock_time = (
SUBMIT_DELAY_DEMO if self.demo_mode else SUBMIT_DELAY_PRODUCTION
)
self.entries_to_check = entries
self.submit_command = verify_command
self.update_submit_timer()
# ------------------------------------------------------------------
# Error, unlock, and logging
# Unlock, logging
# ------------------------------------------------------------------
def show_error(self, message: str) -> None:
"""Display error message with retry option."""
self.clear_container()
self._label("ERROR", font_size=48, color="#ff4444", pady=20)
msg_label = tk.Label(
self.container,
text=message,
font=("Arial", 24),
fg="white",
bg="#1a1a1a",
wraplength=800,
)
msg_label.pack(pady=20)
self._button(
self.container,
"TRY AGAIN",
bg="#0066cc",
command=self.ask_workout_done,
width=15,
).pack(pady=30)
def _try_adjust_shutdown_for_workout(self) -> bool:
"""Try to adjust shutdown time later for actual workouts."""
workout_type = self.workout_data.get("type", "")
if workout_type not in ("running", "strength", "phone_verified"):
if workout_type != "phone_verified":
return False
adjusted = self._adjust_shutdown_time_later()
if adjusted:

View File

@ -4,7 +4,7 @@ from __future__ import annotations
from pathlib import Path
import tkinter as tk
from typing import TYPE_CHECKING, NamedTuple
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
@ -15,24 +15,6 @@ 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."""
@ -87,27 +69,3 @@ def create_locker(
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

@ -10,60 +10,12 @@ 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."""
@ -253,24 +205,6 @@ class TestSaveWorkoutLog:
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)
object.__setattr__(locker, "clear_container", MagicMock())
locker.show_error("Test error message")
locker.clear_container.assert_called_once()
class TestRun:
"""Tests for run method."""

View File

@ -182,20 +182,20 @@ class TestStartPhoneCheck:
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(
def test_handle_startup_not_verified_shows_retry_and_sick(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test not_verified result shows blocking screen with buttons."""
"""Test not_verified result shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._handle_startup_phone_result(
"not_verified", "No workout found on phone today"
)
locker.clear_container.assert_called()
locker._show_retry_and_sick.assert_called_once()
def test_handle_startup_no_phone_shows_penalty(
self,
@ -203,15 +203,13 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no_phone result triggers penalty with ask_workout_done as callback."""
"""Test no_phone result triggers penalty with default retry+sick callback."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
locker._handle_startup_phone_result("no_phone", "No phone")
locker._show_phone_penalty.assert_called_once()
_, kwargs = locker._show_phone_penalty.call_args
assert kwargs["on_done"] == locker.ask_workout_done
locker._show_phone_penalty.assert_called_once_with("No phone")
def test_handle_startup_error_shows_penalty(
self,
@ -219,15 +217,13 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test error result triggers penalty with ask_workout_done as callback."""
"""Test error result triggers penalty with default retry+sick callback."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
locker._handle_startup_phone_result("error", "DB not found")
locker._show_phone_penalty.assert_called_once()
_, kwargs = locker._show_phone_penalty.call_args
assert kwargs["on_done"] == locker.ask_workout_done
locker._show_phone_penalty.assert_called_once_with("DB not found")
def test_poll_phone_check_schedules_retry_when_pending(
self,
@ -267,26 +263,6 @@ class TestStartPhoneCheck:
)
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"}
object.__setattr__(locker, "unlock_screen", MagicMock())
locker._attempt_unlock()
locker.unlock_screen.assert_called_once()
class TestShowPhonePenalty:
"""Tests for _show_phone_penalty and _update_phone_penalty methods."""
@ -342,59 +318,38 @@ class TestShowPhonePenalty:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test phone penalty unlocks when timer reaches zero."""
"""Test phone penalty calls done function 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()
object.__setattr__(locker, "unlock_screen", MagicMock())
locker._phone_penalty_done_fn = locker.unlock_screen
mock_done = MagicMock()
locker._phone_penalty_done_fn = mock_done
locker._update_phone_penalty()
locker.unlock_screen.assert_called_once()
mock_done.assert_called_once()
def test_show_phone_penalty_default_callback_shows_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test default phone penalty callback shows retry+sick screen."""
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
locker._show_phone_penalty("No phone connected")
# Simulate timer reaching zero by calling the done function
locker._phone_penalty_done_fn()
locker._show_retry_and_sick.assert_called_once_with("No phone connected")
class TestUnlockScreenShutdownAdjustment:
"""Tests for unlock_screen shutdown time adjustment."""
def test_unlock_screen_adjusts_for_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"}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
locker.unlock_screen()
locker._adjust_shutdown_time_later.assert_called_once()
def test_unlock_screen_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"}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
locker.unlock_screen()
locker._adjust_shutdown_time_later.assert_called_once()
def test_unlock_screen_adjusts_for_phone_verified(
self,
mock_tk: MagicMock,
@ -458,7 +413,7 @@ class TestUnlockScreenShutdownAdjustment:
"""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.workout_data = {"type": "phone_verified"}
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=False)
)

View File

@ -1,22 +1,15 @@
"""Tests for UI transitions, timer logic, and workout detail screens."""
"""Tests for UI transitions, timer logic, and sick day 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."""
@ -52,7 +45,7 @@ class TestUITransitions:
"""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.workout_data = {"type": "phone_verified"}
locker.unlock_screen()
@ -114,308 +107,15 @@ class TestTimerLogic:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test countdown at zero returns to workout question."""
"""Test countdown at zero restarts phone check."""
locker = create_locker(mock_tk, tmp_path)
locker.remaining_time = 0
locker.countdown_label = MagicMock()
object.__setattr__(locker, "ask_workout_done", MagicMock())
object.__setattr__(locker, "_start_phone_check", MagicMock())
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()
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(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(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)
object.__setattr__(locker, "clear_container", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
object.__setattr__(locker, "update_submit_timer", MagicMock())
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)
object.__setattr__(locker, "clear_container", MagicMock())
locker.ask_workout_done()
locker.clear_container.assert_called_once()
mock_tk.Label.assert_called()
mock_tk.Button.assert_called()
locker._start_phone_check.assert_called_once()
class TestAskIfSick:
@ -488,3 +188,20 @@ class TestGetSickDayStatus:
text, color = locker._get_sick_day_status()
assert "Could not adjust" in text
assert color == "#ff4444"
class TestShowRetryAndSick:
"""Tests for _show_retry_and_sick method."""
def test_displays_buttons(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test _show_retry_and_sick shows retry and sick buttons."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "clear_container", MagicMock())
locker._show_retry_and_sick("Test message")
locker.clear_container.assert_called_once()
mock_tk.Label.assert_called()
mock_tk.Button.assert_called()

View File

@ -1,405 +0,0 @@
"""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"}
object.__setattr__(locker, "_attempt_unlock", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"}
object.__setattr__(locker, "_attempt_unlock", MagicMock())
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"}
object.__setattr__(locker, "_attempt_unlock", MagicMock())
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"),
)
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
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"))
object.__setattr__(locker, "show_error", MagicMock())
locker.verify_strength_data()
locker.show_error.assert_called_once()
assert "valid data" in locker.show_error.call_args[0][0]
class TestVariableReps:
"""Tests for variable reps format in strength verification."""
def test_valid_variable_reps(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test valid variable reps with + separator."""
locker = create_locker(mock_tk, tmp_path)
# 3 sets, reps 12+11+12 (3 variable values matching 3 sets), weight 50
# Total = (12+11+12) * 50 = 1750
setup_strength_entries(
locker, StrengthData("Squat", "3", "12+11+12", "50", "1750")
)
locker.log_file = tmp_path / "workout_log.json"
locker.workout_data = {"type": "strength"}
object.__setattr__(locker, "_attempt_unlock", MagicMock())
locker.verify_strength_data()
locker._attempt_unlock.assert_called_once()
def test_variable_reps_count_mismatch(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
"""Test variable reps count not matching sets."""
locker = create_locker(mock_tk, tmp_path)
# 5 sets but only 3 variable reps
setup_strength_entries(
locker, StrengthData("Squat", "5", "12+11+12", "50", "1750")
)
object.__setattr__(locker, "show_error", MagicMock())
locker.verify_strength_data()
locker.show_error.assert_called_once()
assert "variable reps count" in locker.show_error.call_args[0][0]

View File

@ -4,9 +4,11 @@ After=graphical-session.target
[Service]
Type=simple
WorkingDirectory=/home/kuhy/testsAndMisc
Environment=DISPLAY=:0
Environment=PYTHONPATH=/home/kuhy/testsAndMisc
ExecStartPre=/bin/sleep 3
ExecStart=/usr/bin/python3 /home/kuhy/testsAndMisc/python_pkg/screen_locker/screen_lock.py --production
ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production
Restart=no
User=%u

View File

@ -0,0 +1,10 @@
[Unit]
Description=Periodically check if workout was done today
[Timer]
OnBootSec=30s
OnUnitActiveSec=15min
Persistent=true
[Install]
WantedBy=timers.target