mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-06 18:23:16 +02:00
feat(screen_locker): harden table tennis verification, remove running option
- Remove 'Running' workout option (too easy to fake) - Add MIN_TABLE_TENNIS_SETS=15 minimum requirement - Add MIN_POINTS_PER_SET=11 mathematical cross-check - Add TABLE_TENNIS_SUBMIT_DELAY=60 (increased from 30) - Add verification question before unlock (total points/avg/diff) - Require minimum duration per set (2 min/set)
This commit is contained in:
parent
0e1a2847bc
commit
855bfc788b
@ -24,6 +24,10 @@ MAX_REPS = 100
|
||||
MAX_WEIGHT_KG = 500
|
||||
SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick
|
||||
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
||||
# Table tennis minimum requirements (harder to fake)
|
||||
MIN_TABLE_TENNIS_SETS = 15
|
||||
MIN_POINTS_PER_SET = 11 # Standard table tennis minimum points to win a set
|
||||
TABLE_TENNIS_SUBMIT_DELAY = 60 # 60 seconds delay for table tennis
|
||||
# Helper script path (relative to this file)
|
||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||
# State file to track sick day usage and original config values
|
||||
@ -310,6 +314,39 @@ class ScreenLocker:
|
||||
_logger.warning("Failed to adjust shutdown time: %s", e)
|
||||
return False
|
||||
|
||||
def _adjust_shutdown_time_later(self) -> bool:
|
||||
"""Adjust shutdown schedule 1.5 hours later as workout reward.
|
||||
|
||||
This moves the shutdown time later regardless of the initial time,
|
||||
so working out even at 21:00 still makes sense.
|
||||
|
||||
Returns True if successful, False otherwise.
|
||||
"""
|
||||
try:
|
||||
# Read current config
|
||||
config_values = self._read_shutdown_config()
|
||||
if config_values is None:
|
||||
return False
|
||||
|
||||
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
|
||||
|
||||
# Move shutdown times 1.5 hours (rounded to 2 hours) later
|
||||
new_mon_wed = mon_wed_hour + 2
|
||||
new_thu_sun = thu_sun_hour + 2
|
||||
|
||||
# Cap at 23 (11 PM) to avoid going past midnight
|
||||
new_mon_wed = min(23, new_mon_wed)
|
||||
new_thu_sun = min(23, new_thu_sun)
|
||||
|
||||
# Write new config with restore flag to allow later times
|
||||
return self._write_shutdown_config(
|
||||
new_mon_wed, new_thu_sun, morning_end_hour, restore=True
|
||||
)
|
||||
|
||||
except (OSError, ValueError) as e:
|
||||
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
|
||||
return False
|
||||
|
||||
def _sick_mode_used_today(self) -> bool:
|
||||
"""Check if sick mode was already used today."""
|
||||
if not SICK_DAY_STATE_FILE.exists():
|
||||
@ -509,17 +546,7 @@ class ScreenLocker:
|
||||
button_frame = tk.Frame(self.container, bg="#1a1a1a")
|
||||
button_frame.pack(pady=20)
|
||||
|
||||
running_btn = tk.Button(
|
||||
button_frame,
|
||||
text="RUNNING",
|
||||
font=("Arial", 24, "bold"),
|
||||
bg="#0066cc",
|
||||
fg="white",
|
||||
width=15,
|
||||
command=self.ask_running_details,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
)
|
||||
running_btn.pack(side="left", padx=20)
|
||||
# Running option removed - too easy to fake
|
||||
|
||||
strength_btn = tk.Button(
|
||||
button_frame,
|
||||
@ -527,12 +554,24 @@ class ScreenLocker:
|
||||
font=("Arial", 24, "bold"),
|
||||
bg="#cc6600",
|
||||
fg="white",
|
||||
width=15,
|
||||
width=12,
|
||||
command=self.ask_strength_details,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
)
|
||||
strength_btn.pack(side="left", padx=20)
|
||||
|
||||
table_tennis_btn = tk.Button(
|
||||
button_frame,
|
||||
text="TABLE TENNIS",
|
||||
font=("Arial", 20, "bold"),
|
||||
bg="#00cc66",
|
||||
fg="white",
|
||||
width=12,
|
||||
command=self.ask_table_tennis_details,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
)
|
||||
table_tennis_btn.pack(side="left", padx=20)
|
||||
|
||||
def ask_running_details(self) -> None:
|
||||
"""Display running workout input form."""
|
||||
self.clear_container()
|
||||
@ -790,6 +829,127 @@ class ScreenLocker:
|
||||
self.submit_command = self.verify_strength_data
|
||||
self.update_submit_timer()
|
||||
|
||||
def ask_table_tennis_details(self) -> None:
|
||||
"""Display table tennis workout input form."""
|
||||
self.clear_container()
|
||||
self.workout_data["type"] = "table_tennis"
|
||||
|
||||
title = tk.Label(
|
||||
self.container,
|
||||
text="Table Tennis Details",
|
||||
font=("Arial", 36, "bold"),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
)
|
||||
title.pack(pady=20)
|
||||
|
||||
# Instructions/Requirements
|
||||
requirements = tk.Label(
|
||||
self.container,
|
||||
text=(
|
||||
f"Requirements: Minimum {MIN_TABLE_TENNIS_SETS} sets, "
|
||||
f"each set needs at least {MIN_POINTS_PER_SET} total points"
|
||||
),
|
||||
font=("Arial", 14),
|
||||
fg="#aaaaaa",
|
||||
bg="#1a1a1a",
|
||||
)
|
||||
requirements.pack(pady=5)
|
||||
|
||||
# Duration
|
||||
duration_frame = tk.Frame(self.container, bg="#1a1a1a")
|
||||
duration_frame.pack(pady=10)
|
||||
tk.Label(
|
||||
duration_frame,
|
||||
text="Duration (minutes):",
|
||||
font=("Arial", 20),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(side="left", padx=10)
|
||||
self.tt_duration_entry = tk.Entry(duration_frame, font=("Arial", 20), width=10)
|
||||
self.tt_duration_entry.pack(side="left", padx=10)
|
||||
|
||||
# Sets played
|
||||
sets_frame = tk.Frame(self.container, bg="#1a1a1a")
|
||||
sets_frame.pack(pady=10)
|
||||
tk.Label(
|
||||
sets_frame,
|
||||
text="Sets played:",
|
||||
font=("Arial", 20),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(side="left", padx=10)
|
||||
self.tt_sets_entry = tk.Entry(sets_frame, font=("Arial", 20), width=10)
|
||||
self.tt_sets_entry.pack(side="left", padx=10)
|
||||
|
||||
# Points won
|
||||
won_frame = tk.Frame(self.container, bg="#1a1a1a")
|
||||
won_frame.pack(pady=10)
|
||||
tk.Label(
|
||||
won_frame,
|
||||
text="Points won:",
|
||||
font=("Arial", 20),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(side="left", padx=10)
|
||||
self.tt_won_entry = tk.Entry(won_frame, font=("Arial", 20), width=10)
|
||||
self.tt_won_entry.pack(side="left", padx=10)
|
||||
|
||||
# Points lost
|
||||
lost_frame = tk.Frame(self.container, bg="#1a1a1a")
|
||||
lost_frame.pack(pady=10)
|
||||
tk.Label(
|
||||
lost_frame,
|
||||
text="Points lost:",
|
||||
font=("Arial", 20),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(side="left", padx=10)
|
||||
self.tt_lost_entry = tk.Entry(lost_frame, font=("Arial", 20), width=10)
|
||||
self.tt_lost_entry.pack(side="left", padx=10)
|
||||
|
||||
# Timer countdown label
|
||||
self.timer_label = tk.Label(
|
||||
self.container, text="", font=("Arial", 16), fg="#ffaa00", bg="#1a1a1a"
|
||||
)
|
||||
self.timer_label.pack(pady=10)
|
||||
|
||||
self.submit_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 "",
|
||||
)
|
||||
self.submit_btn.pack(pady=10)
|
||||
|
||||
# Back button
|
||||
back_btn = tk.Button(
|
||||
self.container,
|
||||
text="← BACK",
|
||||
font=("Arial", 18),
|
||||
bg="#666666",
|
||||
fg="white",
|
||||
width=15,
|
||||
command=self.ask_workout_type,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
)
|
||||
back_btn.pack(pady=10)
|
||||
|
||||
# Start 60 second timer (increased from 30)
|
||||
self.submit_unlock_time = TABLE_TENNIS_SUBMIT_DELAY
|
||||
self.entries_to_check = [
|
||||
self.tt_duration_entry,
|
||||
self.tt_sets_entry,
|
||||
self.tt_won_entry,
|
||||
self.tt_lost_entry,
|
||||
]
|
||||
self.submit_command = self.verify_table_tennis_data
|
||||
self.update_submit_timer()
|
||||
|
||||
def _parse_reps(self, reps_raw: list[str]) -> list[list[int]]:
|
||||
"""Parse reps input - can be single number or variable reps like '12+11+12'."""
|
||||
reps: list[list[int]] = []
|
||||
@ -883,6 +1043,196 @@ class ScreenLocker:
|
||||
except ValueError:
|
||||
self.show_error("Please enter valid data in correct format")
|
||||
|
||||
def verify_table_tennis_data(self) -> None:
|
||||
"""Validate table tennis workout data and unlock if valid."""
|
||||
try:
|
||||
duration = float(self.tt_duration_entry.get())
|
||||
sets_played = int(self.tt_sets_entry.get())
|
||||
points_won = int(self.tt_won_entry.get())
|
||||
points_lost = int(self.tt_lost_entry.get())
|
||||
|
||||
# Basic validation
|
||||
if duration <= 0:
|
||||
self.show_error("Duration must be greater than 0 minutes")
|
||||
return
|
||||
if sets_played <= 0:
|
||||
self.show_error("Sets played must be greater than 0")
|
||||
return
|
||||
if points_won < 0 or points_lost < 0:
|
||||
self.show_error("Points cannot be negative")
|
||||
return
|
||||
if points_won + points_lost == 0:
|
||||
self.show_error("You must have played some points")
|
||||
return
|
||||
|
||||
# Stricter validation - minimum sets requirement
|
||||
if sets_played < MIN_TABLE_TENNIS_SETS:
|
||||
self.show_error(
|
||||
f"Minimum {MIN_TABLE_TENNIS_SETS} sets required for a valid workout"
|
||||
)
|
||||
return
|
||||
|
||||
# Mathematical cross-check: total_points >= sets_played * MIN_POINTS_PER_SET
|
||||
total_points = points_won + points_lost
|
||||
min_expected_points = sets_played * MIN_POINTS_PER_SET
|
||||
if total_points < min_expected_points:
|
||||
self.show_error(
|
||||
f"Invalid data: {sets_played} sets needs "
|
||||
f"at least {min_expected_points} total points "
|
||||
f"(min {MIN_POINTS_PER_SET} per set). "
|
||||
f"You entered {total_points}."
|
||||
)
|
||||
return
|
||||
|
||||
# Reasonable duration check: at least 2 minutes per set
|
||||
min_expected_duration = sets_played * 2
|
||||
if duration < min_expected_duration:
|
||||
self.show_error(
|
||||
f"Duration too short: {sets_played} sets should "
|
||||
f"take at least {min_expected_duration} minutes"
|
||||
)
|
||||
return
|
||||
|
||||
# Ask verification question about the data
|
||||
self.ask_table_tennis_verification(
|
||||
duration, sets_played, points_won, points_lost
|
||||
)
|
||||
|
||||
except ValueError:
|
||||
self.show_error("Please enter valid numbers")
|
||||
|
||||
def ask_table_tennis_verification(
|
||||
self, duration: float, sets_played: int, points_won: int, points_lost: int
|
||||
) -> None:
|
||||
"""Ask a math verification question about the entered data."""
|
||||
import random
|
||||
|
||||
self.clear_container()
|
||||
|
||||
# Store data for later submission
|
||||
self._pending_tt_data = {
|
||||
"duration": duration,
|
||||
"sets_played": sets_played,
|
||||
"points_won": points_won,
|
||||
"points_lost": points_lost,
|
||||
}
|
||||
|
||||
# Generate a random verification question based on their data
|
||||
total_points = points_won + points_lost
|
||||
question_types = [
|
||||
(
|
||||
"total_points",
|
||||
"What is the TOTAL number of points played? (won + lost)",
|
||||
total_points,
|
||||
),
|
||||
(
|
||||
"avg_per_set",
|
||||
"Rounded DOWN: what is the average points per set? (total ÷ sets)",
|
||||
total_points // sets_played,
|
||||
),
|
||||
(
|
||||
"point_diff",
|
||||
"What is the difference between won and lost points? (won - lost)",
|
||||
abs(points_won - points_lost),
|
||||
),
|
||||
]
|
||||
|
||||
question_type, question_text, expected_answer = random.choice(question_types)
|
||||
self._tt_expected_answer = expected_answer
|
||||
self._tt_question_type = question_type
|
||||
|
||||
title = tk.Label(
|
||||
self.container,
|
||||
text="🔢 Verification Question",
|
||||
font=("Arial", 30, "bold"),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
)
|
||||
title.pack(pady=20)
|
||||
|
||||
info = tk.Label(
|
||||
self.container,
|
||||
text=(
|
||||
f"Based on your data: {sets_played} sets, "
|
||||
f"{points_won} won, {points_lost} lost"
|
||||
),
|
||||
font=("Arial", 16),
|
||||
fg="#aaaaaa",
|
||||
bg="#1a1a1a",
|
||||
)
|
||||
info.pack(pady=10)
|
||||
|
||||
question = tk.Label(
|
||||
self.container,
|
||||
text=question_text,
|
||||
font=("Arial", 20, "bold"),
|
||||
fg="#ffaa00",
|
||||
bg="#1a1a1a",
|
||||
)
|
||||
question.pack(pady=20)
|
||||
|
||||
answer_frame = tk.Frame(self.container, bg="#1a1a1a")
|
||||
answer_frame.pack(pady=10)
|
||||
tk.Label(
|
||||
answer_frame,
|
||||
text="Your answer:",
|
||||
font=("Arial", 20),
|
||||
fg="white",
|
||||
bg="#1a1a1a",
|
||||
).pack(side="left", padx=10)
|
||||
self.tt_verify_entry = tk.Entry(answer_frame, font=("Arial", 20), width=10)
|
||||
self.tt_verify_entry.pack(side="left", padx=10)
|
||||
self.tt_verify_entry.focus_set()
|
||||
|
||||
submit_btn = tk.Button(
|
||||
self.container,
|
||||
text="VERIFY & SUBMIT",
|
||||
font=("Arial", 24, "bold"),
|
||||
bg="#00aa00",
|
||||
fg="white",
|
||||
width=15,
|
||||
command=self.verify_table_tennis_answer,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
)
|
||||
submit_btn.pack(pady=20)
|
||||
|
||||
# Back button
|
||||
back_btn = tk.Button(
|
||||
self.container,
|
||||
text="← BACK",
|
||||
font=("Arial", 18),
|
||||
bg="#666666",
|
||||
fg="white",
|
||||
width=15,
|
||||
command=self.ask_table_tennis_details,
|
||||
cursor="hand2" if self.demo_mode else "",
|
||||
)
|
||||
back_btn.pack(pady=10)
|
||||
|
||||
def verify_table_tennis_answer(self) -> None:
|
||||
"""Check the verification answer and unlock if correct."""
|
||||
try:
|
||||
user_answer = int(self.tt_verify_entry.get())
|
||||
if user_answer != self._tt_expected_answer:
|
||||
self.show_error(
|
||||
f"Incorrect! The answer was {self._tt_expected_answer}. "
|
||||
"Did you enter accurate data?"
|
||||
)
|
||||
# Go back to input form
|
||||
self.root.after(2000, self.ask_table_tennis_details)
|
||||
return
|
||||
|
||||
# Answer correct - store data and unlock
|
||||
data = self._pending_tt_data
|
||||
self.workout_data["duration_minutes"] = str(data["duration"])
|
||||
self.workout_data["sets_played"] = str(data["sets_played"])
|
||||
self.workout_data["points_won"] = str(data["points_won"])
|
||||
self.workout_data["points_lost"] = str(data["points_lost"])
|
||||
self.unlock_screen()
|
||||
|
||||
except ValueError:
|
||||
self.show_error("Please enter a valid number")
|
||||
|
||||
def update_submit_timer(self) -> None:
|
||||
"""Update countdown timer and check if submit can be enabled."""
|
||||
# Check if widgets still exist (user might have clicked back)
|
||||
@ -974,6 +1324,14 @@ class ScreenLocker:
|
||||
# Save workout data to log
|
||||
self.save_workout_log()
|
||||
|
||||
# Adjust shutdown time later for actual workouts (not sick days)
|
||||
shutdown_adjusted = False
|
||||
workout_type = self.workout_data.get("type", "")
|
||||
if workout_type in ("running", "strength", "table_tennis"):
|
||||
shutdown_adjusted = self._adjust_shutdown_time_later()
|
||||
if shutdown_adjusted:
|
||||
_logger.info("Shutdown time moved 1.5 hours later as workout reward")
|
||||
|
||||
self.clear_container()
|
||||
|
||||
success_label = tk.Label(
|
||||
@ -985,6 +1343,17 @@ class ScreenLocker:
|
||||
)
|
||||
success_label.pack(pady=30)
|
||||
|
||||
# Show shutdown adjustment status
|
||||
if shutdown_adjusted:
|
||||
bonus_label = tk.Label(
|
||||
self.container,
|
||||
text="Shutdown time +1.5h later! 🎁",
|
||||
font=("Arial", 24),
|
||||
fg="#ffaa00",
|
||||
bg="#1a1a1a",
|
||||
)
|
||||
bonus_label.pack(pady=10)
|
||||
|
||||
unlock_label = tk.Label(
|
||||
self.container,
|
||||
text="Screen Unlocked!",
|
||||
|
||||
@ -55,6 +55,15 @@ class StrengthData(NamedTuple):
|
||||
total_weight: str
|
||||
|
||||
|
||||
class TableTennisData(NamedTuple):
|
||||
"""Table tennis workout data for tests."""
|
||||
|
||||
duration: str
|
||||
sets_played: str
|
||||
points_won: str
|
||||
points_lost: str
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_tk() -> Generator[MagicMock]:
|
||||
"""Mock tkinter module for testing without display."""
|
||||
@ -128,6 +137,18 @@ def setup_strength_entries(locker: ScreenLocker, data: StrengthData) -> None:
|
||||
locker.total_weight_entry.get.return_value = data.total_weight
|
||||
|
||||
|
||||
def setup_table_tennis_entries(locker: ScreenLocker, data: TableTennisData) -> None:
|
||||
"""Set up mock table tennis entry widgets."""
|
||||
locker.tt_duration_entry = MagicMock()
|
||||
locker.tt_duration_entry.get.return_value = data.duration
|
||||
locker.tt_sets_entry = MagicMock()
|
||||
locker.tt_sets_entry.get.return_value = data.sets_played
|
||||
locker.tt_won_entry = MagicMock()
|
||||
locker.tt_won_entry.get.return_value = data.points_won
|
||||
locker.tt_lost_entry = MagicMock()
|
||||
locker.tt_lost_entry.get.return_value = data.points_lost
|
||||
|
||||
|
||||
class TestConstants:
|
||||
"""Tests for module constants."""
|
||||
|
||||
@ -710,6 +731,109 @@ class TestVerifyStrengthData:
|
||||
assert "valid data" in locker.show_error.call_args[0][0]
|
||||
|
||||
|
||||
class TestVerifyTableTennisData:
|
||||
"""Tests for verify_table_tennis_data method."""
|
||||
|
||||
def test_valid_table_tennis_data(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test valid table tennis data unlocks screen."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
setup_table_tennis_entries(locker, TableTennisData("60", "3", "21", "15"))
|
||||
locker.unlock_screen = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.verify_table_tennis_data()
|
||||
|
||||
locker.unlock_screen.assert_called_once()
|
||||
assert locker.workout_data["duration_minutes"] == "60.0"
|
||||
assert locker.workout_data["sets_played"] == "3"
|
||||
assert locker.workout_data["points_won"] == "21"
|
||||
assert locker.workout_data["points_lost"] == "15"
|
||||
|
||||
def test_invalid_duration_zero(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test duration <= 0 is rejected."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
setup_table_tennis_entries(locker, TableTennisData("0", "3", "21", "15"))
|
||||
locker.show_error = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.verify_table_tennis_data()
|
||||
|
||||
locker.show_error.assert_called_once()
|
||||
assert "greater than 0" in locker.show_error.call_args[0][0]
|
||||
|
||||
def test_invalid_sets_zero(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test sets <= 0 is rejected."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
setup_table_tennis_entries(locker, TableTennisData("60", "0", "21", "15"))
|
||||
locker.show_error = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.verify_table_tennis_data()
|
||||
|
||||
locker.show_error.assert_called_once()
|
||||
assert "greater than 0" in locker.show_error.call_args[0][0]
|
||||
|
||||
def test_invalid_points_negative(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test negative points are rejected."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
setup_table_tennis_entries(locker, TableTennisData("60", "3", "-1", "15"))
|
||||
locker.show_error = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.verify_table_tennis_data()
|
||||
|
||||
locker.show_error.assert_called_once()
|
||||
assert "cannot be negative" in locker.show_error.call_args[0][0]
|
||||
|
||||
def test_invalid_no_points_played(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test zero total points is rejected."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
setup_table_tennis_entries(locker, TableTennisData("60", "3", "0", "0"))
|
||||
locker.show_error = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.verify_table_tennis_data()
|
||||
|
||||
locker.show_error.assert_called_once()
|
||||
assert "played some points" in locker.show_error.call_args[0][0]
|
||||
|
||||
def test_invalid_number_format(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test invalid format is rejected."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
setup_table_tennis_entries(locker, TableTennisData("abc", "3", "21", "15"))
|
||||
locker.show_error = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.verify_table_tennis_data()
|
||||
|
||||
locker.show_error.assert_called_once()
|
||||
assert "valid numbers" in locker.show_error.call_args[0][0]
|
||||
|
||||
|
||||
class TestUITransitions:
|
||||
"""Tests for UI state transitions."""
|
||||
|
||||
@ -1142,6 +1266,62 @@ class TestAskStrengthDetails:
|
||||
locker.update_submit_timer.assert_called_once()
|
||||
|
||||
|
||||
class TestAskTableTennisDetails:
|
||||
"""Tests for ask_table_tennis_details method."""
|
||||
|
||||
def test_ask_table_tennis_details_sets_workout_type(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test ask_table_tennis_details sets workout type to table_tennis."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||
locker.update_submit_timer = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.ask_table_tennis_details()
|
||||
|
||||
assert locker.workout_data["type"] == "table_tennis"
|
||||
locker.clear_container.assert_called_once()
|
||||
|
||||
def test_ask_table_tennis_details_creates_entry_fields(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test ask_table_tennis_details creates entry fields."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||
locker.update_submit_timer = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.ask_table_tennis_details()
|
||||
|
||||
# Verify Entry fields were created
|
||||
mock_tk.Entry.assert_called()
|
||||
assert hasattr(locker, "tt_duration_entry")
|
||||
assert hasattr(locker, "tt_sets_entry")
|
||||
assert hasattr(locker, "tt_won_entry")
|
||||
assert hasattr(locker, "tt_lost_entry")
|
||||
|
||||
def test_ask_table_tennis_details_sets_timer(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test ask_table_tennis_details initializes submit timer."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||
locker.update_submit_timer = MagicMock() # type: ignore[method-assign]
|
||||
|
||||
locker.ask_table_tennis_details()
|
||||
|
||||
assert locker.submit_unlock_time == 30
|
||||
locker.update_submit_timer.assert_called_once()
|
||||
|
||||
|
||||
class TestAskWorkoutDone:
|
||||
"""Tests for ask_workout_done method."""
|
||||
|
||||
@ -1160,3 +1340,194 @@ class TestAskWorkoutDone:
|
||||
locker.clear_container.assert_called_once()
|
||||
mock_tk.Label.assert_called()
|
||||
mock_tk.Button.assert_called()
|
||||
|
||||
|
||||
class TestAdjustShutdownTimeLater:
|
||||
"""Tests for _adjust_shutdown_time_later method."""
|
||||
|
||||
def test_adjust_shutdown_time_later_success(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test _adjust_shutdown_time_later adds hours successfully."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._read_shutdown_config = MagicMock( # type: ignore[method-assign]
|
||||
return_value=(21, 22, 8)
|
||||
)
|
||||
locker._write_shutdown_config = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
result = locker._adjust_shutdown_time_later()
|
||||
|
||||
assert result is True
|
||||
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
|
||||
|
||||
def test_adjust_shutdown_time_later_caps_at_23(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test _adjust_shutdown_time_later caps hours at 23."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._read_shutdown_config = MagicMock( # type: ignore[method-assign]
|
||||
return_value=(22, 23, 8)
|
||||
)
|
||||
locker._write_shutdown_config = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
result = locker._adjust_shutdown_time_later()
|
||||
|
||||
assert result is True
|
||||
# 22+2=24 capped to 23, 23+2=25 capped to 23
|
||||
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
|
||||
|
||||
def test_adjust_shutdown_time_later_no_config(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test _adjust_shutdown_time_later returns False if config missing."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._read_shutdown_config = MagicMock( # type: ignore[method-assign]
|
||||
return_value=None
|
||||
)
|
||||
|
||||
result = locker._adjust_shutdown_time_later()
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_adjust_shutdown_time_later_oserror(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test _adjust_shutdown_time_later handles OSError."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker._read_shutdown_config = MagicMock( # type: ignore[method-assign]
|
||||
side_effect=OSError("permission denied")
|
||||
)
|
||||
|
||||
result = locker._adjust_shutdown_time_later()
|
||||
|
||||
assert result is False
|
||||
|
||||
|
||||
class TestUnlockScreenShutdownAdjustment:
|
||||
"""Tests for unlock_screen shutdown time adjustment."""
|
||||
|
||||
def test_unlock_screen_adjusts_for_running(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unlock_screen adjusts shutdown for running workout."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "workout_log.json"
|
||||
locker.workout_data = {"type": "running"}
|
||||
locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_called_once()
|
||||
|
||||
def test_unlock_screen_adjusts_for_strength(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unlock_screen adjusts shutdown for strength workout."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "workout_log.json"
|
||||
locker.workout_data = {"type": "strength"}
|
||||
locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_called_once()
|
||||
|
||||
def test_unlock_screen_adjusts_for_table_tennis(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unlock_screen adjusts shutdown for table tennis workout."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "workout_log.json"
|
||||
locker.workout_data = {"type": "table_tennis"}
|
||||
locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_called_once()
|
||||
|
||||
def test_unlock_screen_skips_adjustment_for_sick_day(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unlock_screen does not adjust for sick day."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "workout_log.json"
|
||||
locker.workout_data = {"type": "sick_day"}
|
||||
locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_not_called()
|
||||
|
||||
def test_unlock_screen_skips_adjustment_no_type(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unlock_screen does not adjust when no workout type."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "workout_log.json"
|
||||
locker.workout_data = {}
|
||||
locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign]
|
||||
return_value=True
|
||||
)
|
||||
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_not_called()
|
||||
|
||||
def test_unlock_screen_handles_adjustment_failure(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Test unlock_screen continues when adjustment fails."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
locker.log_file = tmp_path / "workout_log.json"
|
||||
locker.workout_data = {"type": "running"}
|
||||
locker._adjust_shutdown_time_later = MagicMock( # type: ignore[method-assign]
|
||||
return_value=False
|
||||
)
|
||||
|
||||
# Should not raise, should continue with unlock
|
||||
locker.unlock_screen()
|
||||
|
||||
locker._adjust_shutdown_time_later.assert_called_once()
|
||||
locker.root.after.assert_called() # type: ignore[attr-defined]
|
||||
|
||||
Loading…
Reference in New Issue
Block a user