From ead6072eee642b899d91a0248b397183035f6aef Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 27 Mar 2026 15:54:01 +0100 Subject: [PATCH] refactor: remove manual workout forms, ADB-only verification + sick mode MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- python_pkg/screen_locker/_constants.py | 18 - python_pkg/screen_locker/_ui_flows.py | 121 ++---- python_pkg/screen_locker/_workout_forms.py | 269 ------------ python_pkg/screen_locker/install_systemd.sh | 18 +- python_pkg/screen_locker/remove_systemd.sh | 12 +- python_pkg/screen_locker/screen_lock.py | 113 +---- python_pkg/screen_locker/tests/conftest.py | 44 +- .../screen_locker/tests/test_init_and_log.py | 66 --- .../tests/test_phone_check_unlock.py | 105 ++--- .../screen_locker/tests/test_ui_and_timers.py | 327 +------------- .../screen_locker/tests/test_verify_data.py | 405 ------------------ .../screen_locker/workout-locker.service | 4 +- python_pkg/screen_locker/workout-locker.timer | 10 + 13 files changed, 120 insertions(+), 1392 deletions(-) delete mode 100644 python_pkg/screen_locker/_workout_forms.py delete mode 100644 python_pkg/screen_locker/tests/test_verify_data.py create mode 100644 python_pkg/screen_locker/workout-locker.timer diff --git a/python_pkg/screen_locker/_constants.py b/python_pkg/screen_locker/_constants.py index d7f22cc..740b63b 100644 --- a/python_pkg/screen_locker/_constants.py +++ b/python_pkg/screen_locker/_constants.py @@ -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), -] diff --git a/python_pkg/screen_locker/_ui_flows.py b/python_pkg/screen_locker/_ui_flows.py index f6045d7..bdd1481 100644 --- a/python_pkg/screen_locker/_ui_flows.py +++ b/python_pkg/screen_locker/_ui_flows.py @@ -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() diff --git a/python_pkg/screen_locker/_workout_forms.py b/python_pkg/screen_locker/_workout_forms.py deleted file mode 100644 index e3a43c3..0000000 --- a/python_pkg/screen_locker/_workout_forms.py +++ /dev/null @@ -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() diff --git a/python_pkg/screen_locker/install_systemd.sh b/python_pkg/screen_locker/install_systemd.sh index 8bec401..703e3cc 100755 --- a/python_pkg/screen_locker/install_systemd.sh +++ b/python_pkg/screen_locker/install_systemd.sh @@ -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 "" diff --git a/python_pkg/screen_locker/remove_systemd.sh b/python_pkg/screen_locker/remove_systemd.sh index a024938..438e450 100755 --- a/python_pkg/screen_locker/remove_systemd.sh +++ b/python_pkg/screen_locker/remove_systemd.sh @@ -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" diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index 2beb690..eb7e159 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -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: diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py index 9c8cc5f..29e19ab 100644 --- a/python_pkg/screen_locker/tests/conftest.py +++ b/python_pkg/screen_locker/tests/conftest.py @@ -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 diff --git a/python_pkg/screen_locker/tests/test_init_and_log.py b/python_pkg/screen_locker/tests/test_init_and_log.py index f7ad5dc..58d02ac 100644 --- a/python_pkg/screen_locker/tests/test_init_and_log.py +++ b/python_pkg/screen_locker/tests/test_init_and_log.py @@ -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.""" diff --git a/python_pkg/screen_locker/tests/test_phone_check_unlock.py b/python_pkg/screen_locker/tests/test_phone_check_unlock.py index 9caedc8..08582ef 100644 --- a/python_pkg/screen_locker/tests/test_phone_check_unlock.py +++ b/python_pkg/screen_locker/tests/test_phone_check_unlock.py @@ -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) ) diff --git a/python_pkg/screen_locker/tests/test_ui_and_timers.py b/python_pkg/screen_locker/tests/test_ui_and_timers.py index f3ae939..59dbd11 100644 --- a/python_pkg/screen_locker/tests/test_ui_and_timers.py +++ b/python_pkg/screen_locker/tests/test_ui_and_timers.py @@ -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() diff --git a/python_pkg/screen_locker/tests/test_verify_data.py b/python_pkg/screen_locker/tests/test_verify_data.py deleted file mode 100644 index 368d657..0000000 --- a/python_pkg/screen_locker/tests/test_verify_data.py +++ /dev/null @@ -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] diff --git a/python_pkg/screen_locker/workout-locker.service b/python_pkg/screen_locker/workout-locker.service index c3da588..2d76902 100644 --- a/python_pkg/screen_locker/workout-locker.service +++ b/python_pkg/screen_locker/workout-locker.service @@ -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 diff --git a/python_pkg/screen_locker/workout-locker.timer b/python_pkg/screen_locker/workout-locker.timer new file mode 100644 index 0000000..7b57a66 --- /dev/null +++ b/python_pkg/screen_locker/workout-locker.timer @@ -0,0 +1,10 @@ +[Unit] +Description=Periodically check if workout was done today + +[Timer] +OnBootSec=30s +OnUnitActiveSec=15min +Persistent=true + +[Install] +WantedBy=timers.target