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:
Krzysztof kuhy Rudnicki 2026-02-02 21:38:52 +01:00
parent 0e1a2847bc
commit 855bfc788b
2 changed files with 752 additions and 12 deletions

View File

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

View File

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