diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 9debf3f..bcd046b 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -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!", diff --git a/screen_locker/tests/test_screen_lock.py b/screen_locker/tests/test_screen_lock.py index 475a6f9..fb117aa 100644 --- a/screen_locker/tests/test_screen_lock.py +++ b/screen_locker/tests/test_screen_lock.py @@ -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]