mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 15:43:02 +02:00
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:
parent
d56ed74acc
commit
bb5c43400f
@ -4,17 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
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
|
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_DEMO = 10
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION = 600
|
PHONE_PENALTY_DELAY_PRODUCTION = 600
|
||||||
ADB_TIMEOUT = 15
|
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"
|
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||||
# State file to track sick day usage and original config values
|
# State file to track sick day usage and original config values
|
||||||
SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
|
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),
|
|
||||||
]
|
|
||||||
|
|||||||
@ -3,41 +3,21 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from concurrent.futures import ThreadPoolExecutor
|
from concurrent.futures import ThreadPoolExecutor
|
||||||
import contextlib
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from collections.abc import Callable
|
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import (
|
from python_pkg.screen_locker._constants import (
|
||||||
PHONE_PENALTY_DELAY_DEMO,
|
PHONE_PENALTY_DELAY_DEMO,
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||||
SICK_LOCKOUT_SECONDS,
|
SICK_LOCKOUT_SECONDS,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Callable
|
||||||
|
|
||||||
|
|
||||||
class UIFlowsMixin:
|
class UIFlowsMixin:
|
||||||
"""Mixin providing UI flow logic for the screen locker."""
|
"""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:
|
def _start_phone_check(self) -> None:
|
||||||
"""Check phone for today's workout immediately at startup."""
|
"""Check phone for today's workout immediately at startup."""
|
||||||
self.clear_container()
|
self.clear_container()
|
||||||
@ -56,6 +36,27 @@ class UIFlowsMixin:
|
|||||||
else:
|
else:
|
||||||
self.root.after(500, self._poll_phone_check)
|
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:
|
def _handle_startup_phone_result(self, status: str, message: str) -> None:
|
||||||
"""Route to appropriate screen based on startup phone check result."""
|
"""Route to appropriate screen based on startup phone check result."""
|
||||||
if status == "verified":
|
if status == "verified":
|
||||||
@ -70,32 +71,14 @@ class UIFlowsMixin:
|
|||||||
unlock_delay = 1500 if self.demo_mode else 2000
|
unlock_delay = 1500 if self.demo_mode else 2000
|
||||||
self.root.after(unlock_delay, self.unlock_screen)
|
self.root.after(unlock_delay, self.unlock_screen)
|
||||||
elif status == "not_verified":
|
elif status == "not_verified":
|
||||||
self.clear_container()
|
self._show_retry_and_sick(
|
||||||
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
|
|
||||||
self._text(
|
|
||||||
f"\u274c {message}\n\n"
|
f"\u274c {message}\n\n"
|
||||||
"StrongLifts shows no workout today.\n"
|
"StrongLifts shows no workout today.\n"
|
||||||
"Go do your workout first!",
|
"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:
|
else:
|
||||||
# no_phone or error — penalty timer, then proceed to logging form
|
# no_phone or error — penalty timer, then retry+sick screen
|
||||||
self._show_phone_penalty(message, on_done=self.ask_workout_done)
|
self._show_phone_penalty(message)
|
||||||
|
|
||||||
def ask_if_sick(self) -> None:
|
def ask_if_sick(self) -> None:
|
||||||
"""Display sick day question dialog."""
|
"""Display sick day question dialog."""
|
||||||
@ -198,23 +181,21 @@ class UIFlowsMixin:
|
|||||||
self.remaining_time -= 1
|
self.remaining_time -= 1
|
||||||
self.root.after(1000, self.update_lockout_countdown)
|
self.root.after(1000, self.update_lockout_countdown)
|
||||||
else:
|
else:
|
||||||
self.ask_workout_done()
|
self._start_phone_check()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Phone penalty
|
# Phone penalty
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _attempt_unlock(self) -> None:
|
|
||||||
"""Unlock screen after workout form submission."""
|
|
||||||
self.unlock_screen()
|
|
||||||
|
|
||||||
def _show_phone_penalty(
|
def _show_phone_penalty(
|
||||||
self, message: str, *, on_done: Callable[[], None] | None = None
|
self, message: str, *, on_done: Callable[[], None] | None = None
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Show penalty countdown when phone verification is unavailable."""
|
"""Show penalty countdown when phone verification is unavailable."""
|
||||||
self.clear_container()
|
self.clear_container()
|
||||||
self._phone_penalty_done_fn: Callable[[], None] = (
|
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 = (
|
delay = (
|
||||||
PHONE_PENALTY_DELAY_DEMO
|
PHONE_PENALTY_DELAY_DEMO
|
||||||
@ -252,43 +233,3 @@ class UIFlowsMixin:
|
|||||||
self.root.after(1000, self._update_phone_penalty)
|
self.root.after(1000, self._update_phone_penalty)
|
||||||
else:
|
else:
|
||||||
self._phone_penalty_done_fn()
|
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()
|
|
||||||
|
|||||||
@ -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()
|
|
||||||
@ -4,8 +4,10 @@
|
|||||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||||
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
|
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
|
||||||
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
|
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
|
||||||
|
TIMER_FILE="$SCRIPT_DIR/workout-locker.timer"
|
||||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
||||||
SERVICE_NAME="workout-locker.service"
|
SERVICE_NAME="workout-locker.service"
|
||||||
|
TIMER_NAME="workout-locker.timer"
|
||||||
|
|
||||||
# Check if service is already installed
|
# Check if service is already installed
|
||||||
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
|
if [ -f "$USER_SERVICE_DIR/$SERVICE_NAME" ]; then
|
||||||
@ -24,25 +26,31 @@ fi
|
|||||||
# Create user systemd directory if it doesn't exist
|
# Create user systemd directory if it doesn't exist
|
||||||
mkdir -p "$USER_SERVICE_DIR"
|
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 "$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
|
# Update paths in the service file to use absolute paths
|
||||||
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 $SCRIPT_DIR/screen_lock.py --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
|
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
|
# Reload systemd daemon
|
||||||
systemctl --user daemon-reload
|
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 "$SERVICE_NAME"
|
||||||
|
systemctl --user enable --now "$TIMER_NAME"
|
||||||
|
|
||||||
echo "✓ Workout locker service installed"
|
echo "✓ Workout locker service installed"
|
||||||
echo "✓ Service will start automatically on next login"
|
echo "✓ Service will start automatically on next login"
|
||||||
echo ""
|
echo ""
|
||||||
echo "To start now: systemctl --user start workout-locker"
|
echo "To start now: systemctl --user start workout-locker"
|
||||||
echo "To check status: systemctl --user status 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 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
|
# Check autostart installation status
|
||||||
echo ""
|
echo ""
|
||||||
|
|||||||
@ -2,18 +2,22 @@
|
|||||||
# Remove workout locker systemd service
|
# Remove workout locker systemd service
|
||||||
|
|
||||||
SERVICE_NAME="workout-locker.service"
|
SERVICE_NAME="workout-locker.service"
|
||||||
|
TIMER_NAME="workout-locker.timer"
|
||||||
USER_SERVICE_DIR="$HOME/.config/systemd/user"
|
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
|
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
|
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/$SERVICE_NAME"
|
||||||
|
rm -f "$USER_SERVICE_DIR/$TIMER_NAME"
|
||||||
|
|
||||||
# Reload systemd daemon
|
# Reload systemd daemon
|
||||||
systemctl --user daemon-reload
|
systemctl --user daemon-reload
|
||||||
|
|
||||||
echo "✓ Workout locker service removed"
|
echo "✓ Workout locker service and timer removed"
|
||||||
|
|||||||
@ -20,41 +20,22 @@ if TYPE_CHECKING:
|
|||||||
from concurrent.futures import Future
|
from concurrent.futures import Future
|
||||||
|
|
||||||
from python_pkg.screen_locker._constants import (
|
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_DEMO,
|
||||||
PHONE_PENALTY_DELAY_PRODUCTION,
|
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||||
SICK_LOCKOUT_SECONDS,
|
SICK_LOCKOUT_SECONDS,
|
||||||
STRONGLIFTS_DB_REMOTE,
|
STRONGLIFTS_DB_REMOTE,
|
||||||
SUBMIT_DELAY_DEMO,
|
|
||||||
SUBMIT_DELAY_PRODUCTION,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
__all__ = [
|
__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_DEMO",
|
||||||
"PHONE_PENALTY_DELAY_PRODUCTION",
|
"PHONE_PENALTY_DELAY_PRODUCTION",
|
||||||
"SICK_LOCKOUT_SECONDS",
|
"SICK_LOCKOUT_SECONDS",
|
||||||
"STRONGLIFTS_DB_REMOTE",
|
"STRONGLIFTS_DB_REMOTE",
|
||||||
"SUBMIT_DELAY_DEMO",
|
|
||||||
"SUBMIT_DELAY_PRODUCTION",
|
|
||||||
"ScreenLocker",
|
"ScreenLocker",
|
||||||
]
|
]
|
||||||
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
|
||||||
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
from python_pkg.screen_locker._shutdown import ShutdownMixin
|
||||||
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
from python_pkg.screen_locker._ui_flows import UIFlowsMixin
|
||||||
from python_pkg.screen_locker._workout_forms import WorkoutFormsMixin
|
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@ -62,7 +43,6 @@ _logger = logging.getLogger(__name__)
|
|||||||
class ScreenLocker(
|
class ScreenLocker(
|
||||||
ShutdownMixin,
|
ShutdownMixin,
|
||||||
PhoneVerificationMixin,
|
PhoneVerificationMixin,
|
||||||
WorkoutFormsMixin,
|
|
||||||
UIFlowsMixin,
|
UIFlowsMixin,
|
||||||
):
|
):
|
||||||
"""Screen locker that requires workout logging to unlock."""
|
"""Screen locker that requires workout logging to unlock."""
|
||||||
@ -200,103 +180,14 @@ class ScreenLocker(
|
|||||||
frame.pack(pady=20)
|
frame.pack(pady=20)
|
||||||
return frame
|
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:
|
def _try_adjust_shutdown_for_workout(self) -> bool:
|
||||||
"""Try to adjust shutdown time later for actual workouts."""
|
"""Try to adjust shutdown time later for actual workouts."""
|
||||||
workout_type = self.workout_data.get("type", "")
|
workout_type = self.workout_data.get("type", "")
|
||||||
if workout_type not in ("running", "strength", "phone_verified"):
|
if workout_type != "phone_verified":
|
||||||
return False
|
return False
|
||||||
adjusted = self._adjust_shutdown_time_later()
|
adjusted = self._adjust_shutdown_time_later()
|
||||||
if adjusted:
|
if adjusted:
|
||||||
|
|||||||
@ -4,7 +4,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING, NamedTuple
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
@ -15,24 +15,6 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Generator
|
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
|
@pytest.fixture
|
||||||
def mock_tk() -> Generator[MagicMock]:
|
def mock_tk() -> Generator[MagicMock]:
|
||||||
"""Mock tkinter module for testing without display."""
|
"""Mock tkinter module for testing without display."""
|
||||||
@ -87,27 +69,3 @@ def create_locker(
|
|||||||
patch.object(ScreenLocker, "_start_phone_check"),
|
patch.object(ScreenLocker, "_start_phone_check"),
|
||||||
):
|
):
|
||||||
return ScreenLocker(demo_mode=demo_mode)
|
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
|
|
||||||
|
|||||||
@ -10,60 +10,12 @@ from unittest.mock import MagicMock
|
|||||||
|
|
||||||
import pytest
|
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
|
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
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:
|
class TestScreenLockerInit:
|
||||||
"""Tests for ScreenLocker initialization."""
|
"""Tests for ScreenLocker initialization."""
|
||||||
|
|
||||||
@ -253,24 +205,6 @@ class TestSaveWorkoutLog:
|
|||||||
locker.save_workout_log()
|
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:
|
class TestRun:
|
||||||
"""Tests for run method."""
|
"""Tests for run method."""
|
||||||
|
|
||||||
|
|||||||
@ -182,20 +182,20 @@ class TestStartPhoneCheck:
|
|||||||
assert locker.workout_data["type"] == "phone_verified"
|
assert locker.workout_data["type"] == "phone_verified"
|
||||||
locker.root.after.assert_called_once_with(1500, locker.unlock_screen)
|
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,
|
self,
|
||||||
mock_tk: MagicMock,
|
mock_tk: MagicMock,
|
||||||
mock_sys_exit: MagicMock,
|
mock_sys_exit: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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)
|
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(
|
locker._handle_startup_phone_result(
|
||||||
"not_verified", "No workout found on phone today"
|
"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(
|
def test_handle_startup_no_phone_shows_penalty(
|
||||||
self,
|
self,
|
||||||
@ -203,15 +203,13 @@ class TestStartPhoneCheck:
|
|||||||
mock_sys_exit: MagicMock,
|
mock_sys_exit: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
|
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
|
||||||
|
|
||||||
locker._handle_startup_phone_result("no_phone", "No phone")
|
locker._handle_startup_phone_result("no_phone", "No phone")
|
||||||
|
|
||||||
locker._show_phone_penalty.assert_called_once()
|
locker._show_phone_penalty.assert_called_once_with("No phone")
|
||||||
_, kwargs = locker._show_phone_penalty.call_args
|
|
||||||
assert kwargs["on_done"] == locker.ask_workout_done
|
|
||||||
|
|
||||||
def test_handle_startup_error_shows_penalty(
|
def test_handle_startup_error_shows_penalty(
|
||||||
self,
|
self,
|
||||||
@ -219,15 +217,13 @@ class TestStartPhoneCheck:
|
|||||||
mock_sys_exit: MagicMock,
|
mock_sys_exit: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
|
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
|
||||||
|
|
||||||
locker._handle_startup_phone_result("error", "DB not found")
|
locker._handle_startup_phone_result("error", "DB not found")
|
||||||
|
|
||||||
locker._show_phone_penalty.assert_called_once()
|
locker._show_phone_penalty.assert_called_once_with("DB not found")
|
||||||
_, kwargs = locker._show_phone_penalty.call_args
|
|
||||||
assert kwargs["on_done"] == locker.ask_workout_done
|
|
||||||
|
|
||||||
def test_poll_phone_check_schedules_retry_when_pending(
|
def test_poll_phone_check_schedules_retry_when_pending(
|
||||||
self,
|
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:
|
class TestShowPhonePenalty:
|
||||||
"""Tests for _show_phone_penalty and _update_phone_penalty methods."""
|
"""Tests for _show_phone_penalty and _update_phone_penalty methods."""
|
||||||
|
|
||||||
@ -342,59 +318,38 @@ class TestShowPhonePenalty:
|
|||||||
mock_sys_exit: MagicMock,
|
mock_sys_exit: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> 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 = 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_remaining = 0
|
||||||
locker.phone_penalty_label = MagicMock()
|
locker.phone_penalty_label = MagicMock()
|
||||||
object.__setattr__(locker, "unlock_screen", MagicMock())
|
mock_done = MagicMock()
|
||||||
locker._phone_penalty_done_fn = locker.unlock_screen
|
locker._phone_penalty_done_fn = mock_done
|
||||||
|
|
||||||
locker._update_phone_penalty()
|
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:
|
class TestUnlockScreenShutdownAdjustment:
|
||||||
"""Tests for unlock_screen shutdown time adjustment."""
|
"""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(
|
def test_unlock_screen_adjusts_for_phone_verified(
|
||||||
self,
|
self,
|
||||||
mock_tk: MagicMock,
|
mock_tk: MagicMock,
|
||||||
@ -458,7 +413,7 @@ class TestUnlockScreenShutdownAdjustment:
|
|||||||
"""Test unlock_screen continues when adjustment fails."""
|
"""Test unlock_screen continues when adjustment fails."""
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
locker.workout_data = {"type": "running"}
|
locker.workout_data = {"type": "phone_verified"}
|
||||||
object.__setattr__(
|
object.__setattr__(
|
||||||
locker, "_adjust_shutdown_time_later", MagicMock(return_value=False)
|
locker, "_adjust_shutdown_time_later", MagicMock(return_value=False)
|
||||||
)
|
)
|
||||||
|
|||||||
@ -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
|
from __future__ import annotations
|
||||||
|
|
||||||
import tkinter as tk
|
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock
|
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
|
from python_pkg.screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
_TK_TCLERROR = tk.TclError
|
|
||||||
|
|
||||||
|
|
||||||
class TestUITransitions:
|
class TestUITransitions:
|
||||||
"""Tests for UI state transitions."""
|
"""Tests for UI state transitions."""
|
||||||
@ -52,7 +45,7 @@ class TestUITransitions:
|
|||||||
"""Test unlock_screen saves log and schedules close."""
|
"""Test unlock_screen saves log and schedules close."""
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
locker.workout_data = {"type": "running"}
|
locker.workout_data = {"type": "phone_verified"}
|
||||||
|
|
||||||
locker.unlock_screen()
|
locker.unlock_screen()
|
||||||
|
|
||||||
@ -114,308 +107,15 @@ class TestTimerLogic:
|
|||||||
mock_sys_exit: MagicMock,
|
mock_sys_exit: MagicMock,
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test countdown at zero returns to workout question."""
|
"""Test countdown at zero restarts phone check."""
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
locker.remaining_time = 0
|
locker.remaining_time = 0
|
||||||
locker.countdown_label = MagicMock()
|
locker.countdown_label = MagicMock()
|
||||||
object.__setattr__(locker, "ask_workout_done", MagicMock())
|
object.__setattr__(locker, "_start_phone_check", MagicMock())
|
||||||
|
|
||||||
locker.update_lockout_countdown()
|
locker.update_lockout_countdown()
|
||||||
|
|
||||||
locker.ask_workout_done.assert_called_once()
|
locker._start_phone_check.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()
|
|
||||||
|
|
||||||
|
|
||||||
class TestAskIfSick:
|
class TestAskIfSick:
|
||||||
@ -488,3 +188,20 @@ class TestGetSickDayStatus:
|
|||||||
text, color = locker._get_sick_day_status()
|
text, color = locker._get_sick_day_status()
|
||||||
assert "Could not adjust" in text
|
assert "Could not adjust" in text
|
||||||
assert color == "#ff4444"
|
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()
|
||||||
|
|||||||
@ -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]
|
|
||||||
@ -4,9 +4,11 @@ After=graphical-session.target
|
|||||||
|
|
||||||
[Service]
|
[Service]
|
||||||
Type=simple
|
Type=simple
|
||||||
|
WorkingDirectory=/home/kuhy/testsAndMisc
|
||||||
Environment=DISPLAY=:0
|
Environment=DISPLAY=:0
|
||||||
|
Environment=PYTHONPATH=/home/kuhy/testsAndMisc
|
||||||
ExecStartPre=/bin/sleep 3
|
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
|
Restart=no
|
||||||
User=%u
|
User=%u
|
||||||
|
|
||||||
|
|||||||
10
screen_locker/workout-locker.timer
Normal file
10
screen_locker/workout-locker.timer
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user