diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 502b9ef..7e27913 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -129,3 +129,8 @@ repos: entry: bash -c 'printf "%s\0" "$@" | xargs -0 -n 40 shellcheck --severity=warning' -- language: system types: [shell] + - id: max-file-length + name: Max file length (400 lines) + entry: python3 scripts/check_file_length.py + language: system + types_or: [python, shell] diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index 1ce2d7b..8533111 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -11,10 +11,6 @@ from screen_locker._constants import ( PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_PRODUCTION, ) -from screen_locker._weekly_check import ( - WEEKLY_WORKOUT_MINIMUM, - count_weekly_workouts, -) if TYPE_CHECKING: from collections.abc import Callable @@ -270,183 +266,3 @@ class UIFlowsMixin: self.root.after(1000, self._update_phone_penalty) else: self._phone_penalty_done_fn() - - # ------------------------------------------------------------------ - # Verify-workout flow (post-sick-day) - # ------------------------------------------------------------------ - - def _start_verify_workout_check(self) -> None: - """Start phone check for post-sick-day workout verification.""" - self.clear_container() - self._label( - "Verifying Workout", - font_size=36, - color="#ffaa00", - pady=30, - ) - self._text( - "Checking phone for today's workout...", - font_size=18, - ) - executor = ThreadPoolExecutor(max_workers=1) - self._phone_future = executor.submit(self._verify_phone_workout) - executor.shutdown(wait=False) - self._poll_verify_workout_check() - - def _poll_verify_workout_check(self) -> None: - """Poll background phone check for verify-workout mode.""" - if self._phone_future is not None and self._phone_future.done(): - status, message = self._phone_future.result() - self._handle_verify_workout_result(status, message) - else: - self.root.after(500, self._poll_verify_workout_check) - - def _handle_verify_workout_result( - self, - status: str, - message: str, - ) -> None: - """Route phone check result in verify-workout mode.""" - if status == "verified": - self.workout_data["type"] = "phone_verified" - self.workout_data["source"] = message - self.workout_data["after_sick_day"] = "true" - adjusted = self._adjust_shutdown_time_later() - self.save_workout_log() - self.clear_container() - self._label( - "✓ Workout Verified!", - font_size=42, - color="#00cc44", - pady=30, - ) - self._text(message, font_size=20, color="#aaffaa") - if adjusted: - self._text( - "Shutdown time moved later!", - font_size=20, - color="#ffaa00", - ) - self.root.after(2000, self.close) - else: - self._show_verify_retry(message) - - def _show_verify_retry(self, message: str) -> None: - """Show retry/close buttons when workout not found in verify mode.""" - self.clear_container() - self._label( - "Workout Not 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_verify_workout_check, - width=12, - ).pack(side="left", padx=10) - self._button( - frame, - "Close", - bg="#aa0000", - command=self.close, - width=12, - ).pack(side="left", padx=10) - - # ------------------------------------------------------------------ - # Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping) - # ------------------------------------------------------------------ - - def _start_relaxed_day_flow(self) -> None: - """Show optional workout prompt for relaxed days (Tue-Thu). - - The screen is not locked — the user can skip freely or voluntarily - import a Stronglift workout that counts toward the weekly minimum. - """ - count = count_weekly_workouts(self.log_file) - self.clear_container() - self._label( - "Optional Day (Tue / Wed / Thu)", - font_size=30, - color="#ffaa00", - pady=20, - ) - self._text( - f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n" - "No penalty for skipping today.", - font_size=20, - color="#aaaaaa", - pady=10, - ) - frame = self._button_row() - self._button( - frame, - "Skip — No Penalty", - bg="#006600", - command=self.close, - width=18, - ).pack(side="left", padx=10) - self._button( - frame, - "Log Stronglift Workout", - bg="#0066cc", - command=self._start_relaxed_phone_check, - width=20, - ).pack(side="left", padx=10) - - def _start_relaxed_phone_check(self) -> None: - """Run Stronglift check in relaxed mode (no screen grab, no sick option).""" - self.clear_container() - self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30) - self._text("Looking for today's workout in StrongLifts...", font_size=18) - executor = ThreadPoolExecutor(max_workers=1) - self._phone_future = executor.submit(self._verify_phone_workout) - executor.shutdown(wait=False) - self._poll_relaxed_phone_check() - - def _poll_relaxed_phone_check(self) -> None: - """Poll background phone check in relaxed-day mode.""" - if self._phone_future is not None and self._phone_future.done(): - status, message = self._phone_future.result() - self._handle_relaxed_phone_result(status, message) - else: - self.root.after(500, self._poll_relaxed_phone_check) - - def _handle_relaxed_phone_result(self, status: str, message: str) -> None: - """Route phone check result in relaxed-day mode. - - On success saves the workout (counts toward weekly total) then closes. - On failure shows retry and close — no sick option since skipping is free. - """ - if status == "verified": - self.workout_data["type"] = "phone_verified" - self.workout_data["source"] = message - unlock_delay = 1500 if self.demo_mode else 2000 - self.root.after(unlock_delay, self.unlock_screen) - else: - self._show_relaxed_retry(message, status) - - def _show_relaxed_retry(self, message: str, status: str) -> None: - """Show retry and skip-close when workout not found in relaxed mode.""" - self.clear_container() - self._label("No Workout Found", font_size=36, color="#ff4444", pady=20) - self._text(f"❌ {message}\n\nReason: {status}", color="#ffaa00") - frame = self._button_row() - self._button( - frame, - "TRY AGAIN", - bg="#0066cc", - command=self._start_relaxed_phone_check, - width=12, - ).pack(side="left", padx=10) - self._button( - frame, - "Close (Skip)", - bg="#006600", - command=self.close, - width=14, - ).pack(side="left", padx=10) diff --git a/screen_locker/_ui_flows_relaxed.py b/screen_locker/_ui_flows_relaxed.py new file mode 100644 index 0000000..511c0d5 --- /dev/null +++ b/screen_locker/_ui_flows_relaxed.py @@ -0,0 +1,194 @@ +"""Verify-workout and relaxed-day UI flow methods mixin.""" + +from __future__ import annotations + +from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module + +from screen_locker._weekly_check import ( + WEEKLY_WORKOUT_MINIMUM, + count_weekly_workouts, +) + + +class UIFlowsRelaxedMixin: + """Mixin providing verify-workout and relaxed-day UI flow logic.""" + + # ------------------------------------------------------------------ + # Verify-workout flow (post-sick-day) + # ------------------------------------------------------------------ + + def _start_verify_workout_check(self) -> None: + """Start phone check for post-sick-day workout verification.""" + self.clear_container() + self._label( + "Verifying Workout", + font_size=36, + color="#ffaa00", + pady=30, + ) + self._text( + "Checking phone for today's workout...", + font_size=18, + ) + executor = ThreadPoolExecutor(max_workers=1) + self._phone_future = executor.submit(self._verify_phone_workout) + executor.shutdown(wait=False) + self._poll_verify_workout_check() + + def _poll_verify_workout_check(self) -> None: + """Poll background phone check for verify-workout mode.""" + if self._phone_future is not None and self._phone_future.done(): + status, message = self._phone_future.result() + self._handle_verify_workout_result(status, message) + else: + self.root.after(500, self._poll_verify_workout_check) + + def _handle_verify_workout_result( + self, + status: str, + message: str, + ) -> None: + """Route phone check result in verify-workout mode.""" + if status == "verified": + self.workout_data["type"] = "phone_verified" + self.workout_data["source"] = message + self.workout_data["after_sick_day"] = "true" + adjusted = self._adjust_shutdown_time_later() + self.save_workout_log() + self.clear_container() + self._label( + "✓ Workout Verified!", + font_size=42, + color="#00cc44", + pady=30, + ) + self._text(message, font_size=20, color="#aaffaa") + if adjusted: + self._text( + "Shutdown time moved later!", + font_size=20, + color="#ffaa00", + ) + self.root.after(2000, self.close) + else: + self._show_verify_retry(message) + + def _show_verify_retry(self, message: str) -> None: + """Show retry/close buttons when workout not found in verify mode.""" + self.clear_container() + self._label( + "Workout Not 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_verify_workout_check, + width=12, + ).pack(side="left", padx=10) + self._button( + frame, + "Close", + bg="#aa0000", + command=self.close, + width=12, + ).pack(side="left", padx=10) + + # ------------------------------------------------------------------ + # Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping) + # ------------------------------------------------------------------ + + def _start_relaxed_day_flow(self) -> None: + """Show optional workout prompt for relaxed days (Tue-Thu). + + The screen is not locked — the user can skip freely or voluntarily + import a Stronglift workout that counts toward the weekly minimum. + """ + count = count_weekly_workouts(self.log_file) + self.clear_container() + self._label( + "Optional Day (Tue / Wed / Thu)", + font_size=30, + color="#ffaa00", + pady=20, + ) + self._text( + f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n" + "No penalty for skipping today.", + font_size=20, + color="#aaaaaa", + pady=10, + ) + frame = self._button_row() + self._button( + frame, + "Skip — No Penalty", + bg="#006600", + command=self.close, + width=18, + ).pack(side="left", padx=10) + self._button( + frame, + "Log Stronglift Workout", + bg="#0066cc", + command=self._start_relaxed_phone_check, + width=20, + ).pack(side="left", padx=10) + + def _start_relaxed_phone_check(self) -> None: + """Run Stronglift check in relaxed mode (no screen grab, no sick option).""" + self.clear_container() + self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30) + self._text("Looking for today's workout in StrongLifts...", font_size=18) + executor = ThreadPoolExecutor(max_workers=1) + self._phone_future = executor.submit(self._verify_phone_workout) + executor.shutdown(wait=False) + self._poll_relaxed_phone_check() + + def _poll_relaxed_phone_check(self) -> None: + """Poll background phone check in relaxed-day mode.""" + if self._phone_future is not None and self._phone_future.done(): + status, message = self._phone_future.result() + self._handle_relaxed_phone_result(status, message) + else: + self.root.after(500, self._poll_relaxed_phone_check) + + def _handle_relaxed_phone_result(self, status: str, message: str) -> None: + """Route phone check result in relaxed-day mode. + + On success saves the workout (counts toward weekly total) then closes. + On failure shows retry and close — no sick option since skipping is free. + """ + if status == "verified": + self.workout_data["type"] = "phone_verified" + self.workout_data["source"] = message + unlock_delay = 1500 if self.demo_mode else 2000 + self.root.after(unlock_delay, self.unlock_screen) + else: + self._show_relaxed_retry(message, status) + + def _show_relaxed_retry(self, message: str, status: str) -> None: + """Show retry and skip-close when workout not found in relaxed mode.""" + self.clear_container() + self._label("No Workout Found", font_size=36, color="#ff4444", pady=20) + self._text(f"❌ {message}\n\nReason: {status}", color="#ffaa00") + frame = self._button_row() + self._button( + frame, + "TRY AGAIN", + bg="#0066cc", + command=self._start_relaxed_phone_check, + width=12, + ).pack(side="left", padx=10) + self._button( + frame, + "Close (Skip)", + bg="#006600", + command=self.close, + width=14, + ).pack(side="left", padx=10) diff --git a/screen_locker/_ui_widgets.py b/screen_locker/_ui_widgets.py new file mode 100644 index 0000000..7a08fe3 --- /dev/null +++ b/screen_locker/_ui_widgets.py @@ -0,0 +1,83 @@ +"""UI widget helper methods mixin for the screen locker.""" + +from __future__ import annotations + +import tkinter as tk +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from collections.abc import Callable + + +class UIWidgetsMixin: + """Mixin providing low-level widget creation helpers.""" + + def clear_container(self) -> None: + """Remove all widgets from the main container.""" + for widget in self.container.winfo_children(): + widget.destroy() + + def _label( + self, + text: str, + *, + font_size: int = 36, + color: str = "white", + pady: int = 20, + ) -> tk.Label: + """Create and pack a bold label in the container.""" + label = tk.Label( + self.container, + text=text, + font=("Arial", font_size, "bold"), + fg=color, + bg="#1a1a1a", + ) + label.pack(pady=pady) + return label + + def _text( + self, + text: str, + *, + font_size: int = 18, + color: str = "white", + pady: int = 10, + ) -> tk.Label: + """Create and pack a non-bold text label in the container.""" + label = tk.Label( + self.container, + text=text, + font=("Arial", font_size), + fg=color, + bg="#1a1a1a", + ) + label.pack(pady=pady) + return label + + def _button( + self, + parent: tk.Widget, + text: str, + *, + bg: str, + command: Callable[[], None], + width: int = 10, + ) -> tk.Button: + """Create a styled button (caller must pack).""" + return tk.Button( + parent, + text=text, + font=("Arial", 24, "bold"), + bg=bg, + fg="white", + width=width, + command=command, + cursor="hand2" if self.demo_mode else "", + ) + + def _button_row(self) -> tk.Frame: + """Create and pack a horizontal button container.""" + frame = tk.Frame(self.container, bg="#1a1a1a") + frame.pack(pady=20) + return frame diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 7713cb0..4bab081 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -38,6 +38,8 @@ from screen_locker._phone_verification import PhoneVerificationMixin from screen_locker._shutdown import ShutdownMixin from screen_locker._sick_dialog import SickDialogMixin from screen_locker._ui_flows import UIFlowsMixin +from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin +from screen_locker._ui_widgets import UIWidgetsMixin from screen_locker._wake_state import has_workout_skip_today from screen_locker._weekly_check import ( WEEKLY_WORKOUT_MINIMUM, @@ -47,7 +49,6 @@ from screen_locker._weekly_check import ( from screen_locker._window_setup import WindowSetupMixin if TYPE_CHECKING: - from collections.abc import Callable from concurrent.futures import Future __all__ = [ @@ -91,6 +92,8 @@ class ScreenLocker( PhoneVerificationMixin, SickDialogMixin, UIFlowsMixin, + UIFlowsRelaxedMixin, + UIWidgetsMixin, ): """Screen locker that requires workout logging to unlock.""" @@ -227,80 +230,6 @@ class ScreenLocker( self.save_workout_log() return True - def clear_container(self) -> None: - """Remove all widgets from the main container.""" - for widget in self.container.winfo_children(): - widget.destroy() - - # ------------------------------------------------------------------ - # UI helper methods - # ------------------------------------------------------------------ - - def _label( - self, - text: str, - *, - font_size: int = 36, - color: str = "white", - pady: int = 20, - ) -> tk.Label: - """Create and pack a bold label in the container.""" - label = tk.Label( - self.container, - text=text, - font=("Arial", font_size, "bold"), - fg=color, - bg="#1a1a1a", - ) - label.pack(pady=pady) - return label - - def _text( - self, - text: str, - *, - font_size: int = 18, - color: str = "white", - pady: int = 10, - ) -> tk.Label: - """Create and pack a non-bold text label in the container.""" - label = tk.Label( - self.container, - text=text, - font=("Arial", font_size), - fg=color, - bg="#1a1a1a", - ) - label.pack(pady=pady) - return label - - def _button( - self, - parent: tk.Widget, - text: str, - *, - bg: str, - command: Callable[[], None], - width: int = 10, - ) -> tk.Button: - """Create a styled button (caller must pack).""" - return tk.Button( - parent, - text=text, - font=("Arial", 24, "bold"), - bg=bg, - fg="white", - width=width, - command=command, - cursor="hand2" if self.demo_mode else "", - ) - - def _button_row(self) -> tk.Frame: - """Create and pack a horizontal button container.""" - frame = tk.Frame(self.container, bg="#1a1a1a") - frame.pack(pady=20) - return frame - # ------------------------------------------------------------------ # Unlock, logging # ------------------------------------------------------------------ diff --git a/screen_locker/tests/test_adb_and_phone.py b/screen_locker/tests/test_adb_and_phone.py index e0caecd..947c714 100644 --- a/screen_locker/tests/test_adb_and_phone.py +++ b/screen_locker/tests/test_adb_and_phone.py @@ -3,9 +3,7 @@ from __future__ import annotations -import sqlite3 import subprocess -import time from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -363,114 +361,3 @@ class TestFindHealthConnectDb: shell_cmd = locker._adb_shell.call_args[0][0] assert STRONGLIFTS_DB_REMOTE in shell_cmd - - -class TestCountTodayWorkouts: - """Tests for _count_today_workouts method.""" - - def test_workouts_found_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test workouts found today.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "sl_test.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - # Insert a workout with today's timestamp (ms) - now_ms = int(time.time() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", now_ms, now_ms + 3600000), - ) - conn.commit() - conn.close() - - assert locker._count_today_workouts(db_file) == 1 - - def test_no_workouts_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test no workouts today.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "sl_test.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - # Insert a workout from yesterday (24h+ ago) - yesterday_ms = int((time.time() - 200000) * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", yesterday_ms, yesterday_ms + 3600000), - ) - conn.commit() - conn.close() - - assert not locker._count_today_workouts(db_file) - - def test_invalid_db_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0 for invalid database file.""" - locker = create_locker(mock_tk, tmp_path) - bad_file = tmp_path / "not_a_db.db" - bad_file.write_text("not a database") - - assert not locker._count_today_workouts(bad_file) - - def test_missing_table_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0 when workouts table doesn't exist.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "empty.db" - conn = sqlite3.connect(str(db_file)) - conn.execute("CREATE TABLE other (id TEXT)") - conn.commit() - conn.close() - - assert not locker._count_today_workouts(db_file) - - def test_multiple_workouts_today( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test counts multiple workouts today correctly.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "sl_test.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - now_ms = int(time.time() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", now_ms, now_ms + 3600000), - ) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w2", now_ms + 100000, now_ms + 3700000), - ) - conn.commit() - conn.close() - - assert locker._count_today_workouts(db_file) == 2 diff --git a/screen_locker/tests/test_adb_and_phone_part3.py b/screen_locker/tests/test_adb_and_phone_part3.py new file mode 100644 index 0000000..f737f07 --- /dev/null +++ b/screen_locker/tests/test_adb_and_phone_part3.py @@ -0,0 +1,125 @@ +"""Tests for _count_today_workouts and related database queries.""" +# pylint: disable=protected-access,unused-argument + +from __future__ import annotations + +import sqlite3 +import time +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestCountTodayWorkouts: + """Tests for _count_today_workouts method.""" + + def test_workouts_found_today( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test workouts found today.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "sl_test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", + ) + # Insert a workout with today's timestamp (ms) + now_ms = int(time.time() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", now_ms, now_ms + 3600000), + ) + conn.commit() + conn.close() + + assert locker._count_today_workouts(db_file) == 1 + + def test_no_workouts_today( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test no workouts today.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "sl_test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", + ) + # Insert a workout from yesterday (24h+ ago) + yesterday_ms = int((time.time() - 200000) * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", yesterday_ms, yesterday_ms + 3600000), + ) + conn.commit() + conn.close() + + assert not locker._count_today_workouts(db_file) + + def test_invalid_db_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0 for invalid database file.""" + locker = create_locker(mock_tk, tmp_path) + bad_file = tmp_path / "not_a_db.db" + bad_file.write_text("not a database") + + assert not locker._count_today_workouts(bad_file) + + def test_missing_table_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0 when workouts table doesn't exist.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "empty.db" + conn = sqlite3.connect(str(db_file)) + conn.execute("CREATE TABLE other (id TEXT)") + conn.commit() + conn.close() + + assert not locker._count_today_workouts(db_file) + + def test_multiple_workouts_today( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test counts multiple workouts today correctly.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "sl_test.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", + ) + now_ms = int(time.time() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", now_ms, now_ms + 3600000), + ) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w2", now_ms + 100000, now_ms + 3700000), + ) + conn.commit() + conn.close() + + assert locker._count_today_workouts(db_file) == 2 diff --git a/screen_locker/tests/test_early_bird.py b/screen_locker/tests/test_early_bird.py index 7a6be17..f323481 100644 --- a/screen_locker/tests/test_early_bird.py +++ b/screen_locker/tests/test_early_bird.py @@ -4,18 +4,18 @@ from __future__ import annotations from datetime import datetime, timezone import json -from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, patch -import pytest - -from screen_locker.screen_lock import ScreenLocker from screen_locker.tests.conftest import ( create_locker, - create_locker_early_bird, ) +if TYPE_CHECKING: + from pathlib import Path + + from screen_locker.screen_lock import ScreenLocker + class TestGetLocalTimeMinutes: """Tests for _get_local_time_minutes helper.""" @@ -232,199 +232,3 @@ class TestSaveEarlyBirdLog: data: dict[str, Any] = json.load(f) today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") assert data[today]["workout_data"]["type"] == "early_bird" - - -class TestTryAutoUpgradeEarlyBird: - """Tests for _try_auto_upgrade_early_bird method.""" - - def test_upgrade_succeeds_when_verified( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Returns True, saves phone_verified entry, adjusts shutdown.""" - log_file = tmp_path / "workout_log.json" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(return_value=("verified", "Workout verified! (67 min)")), - ) - object.__setattr__( - locker, - "_adjust_shutdown_time_later", - MagicMock(return_value=True), - ) - with patch( - "screen_locker.screen_lock.compute_entry_hmac", - return_value=None, - ): - result = locker._try_auto_upgrade_early_bird() - - assert result is True - with log_file.open() as f: - data: dict[str, Any] = json.load(f) - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - assert data[today]["workout_data"]["type"] == "phone_verified" - assert data[today]["workout_data"]["after_early_bird"] == "true" - - def test_upgrade_fails_when_not_verified( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Returns False when phone shows no workout.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(return_value=("no_phone", "No phone connected")), - ) - assert locker._try_auto_upgrade_early_bird() is False - - def test_upgrade_fails_on_os_error( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Returns False when _verify_phone_workout raises OSError.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(side_effect=OSError("adb fail")), - ) - assert locker._try_auto_upgrade_early_bird() is False - - def test_upgrade_fails_on_runtime_error( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Returns False when _verify_phone_workout raises RuntimeError.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock(side_effect=RuntimeError("unexpected")), - ) - assert locker._try_auto_upgrade_early_bird() is False - - -class TestHasLoggedTodayEarlyBird: - """Tests that has_logged_today returns False for early_bird entries.""" - - def test_early_bird_entry_not_counted_as_logged( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """early_bird entries must not satisfy has_logged_today.""" - log_file = tmp_path / "workout_log.json" - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - log_file.write_text( - json.dumps({today: {"workout_data": {"type": "early_bird"}}}) - ) - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - with patch( - "screen_locker.screen_lock.verify_entry_hmac", - return_value=True, - ): - assert locker.has_logged_today() is False - - -class TestInitEarlyBirdFlow: - """Integration tests for early bird branches in __init__.""" - - def test_init_saves_log_and_exits_during_early_bird_window( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """First login during 5-8:30 window: save early_bird log, exit.""" - mock_sys_exit.side_effect = SystemExit(0) - with ( - patch.object(Path, "resolve", return_value=tmp_path), - patch.object(ScreenLocker, "has_logged_today", return_value=False), - patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), - patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), - patch.object(ScreenLocker, "_is_early_bird_time", return_value=True), - patch.object( - ScreenLocker, - "_try_auto_upgrade_early_bird", - return_value=False, - ), - patch.object(ScreenLocker, "_save_early_bird_log") as mock_save, - patch.object(ScreenLocker, "_start_phone_check"), - patch.object(ScreenLocker, "_start_verify_workout_check"), - patch( - "screen_locker.screen_lock.has_workout_skip_today", - return_value=False, - ), - pytest.raises(SystemExit), - ): - ScreenLocker(demo_mode=True) - - mock_save.assert_called_once() - mock_sys_exit.assert_called_with(0) - - def test_init_exits_when_early_bird_log_still_in_window( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Early bird log exists and window still active: skip lock, exit.""" - mock_sys_exit.side_effect = SystemExit(0) - - with pytest.raises(SystemExit): - create_locker_early_bird(mock_tk, tmp_path, state="log_active") - - mock_sys_exit.assert_called_with(0) - - def test_init_exits_when_early_bird_log_upgrades_successfully( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Early bird log + past 8:30 + workout done: upgrade, exit.""" - mock_sys_exit.side_effect = SystemExit(0) - with ( - patch.object(Path, "resolve", return_value=tmp_path), - patch.object(ScreenLocker, "has_logged_today", return_value=False), - patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), - patch.object(ScreenLocker, "_is_early_bird_log", return_value=True), - patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), - patch.object( - ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True - ), - patch.object(ScreenLocker, "_start_phone_check"), - patch.object(ScreenLocker, "_start_verify_workout_check"), - pytest.raises(SystemExit), - ): - ScreenLocker(demo_mode=True) - - mock_sys_exit.assert_called_with(0) - - def test_init_shows_lock_when_early_bird_log_no_workout( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Early bird log + past 8:30 + no workout: show lock, no early exit.""" - locker = create_locker_early_bird(mock_tk, tmp_path, state="log_expired") - - # _try_auto_upgrade_early_bird returns False (default in create_locker) - # so __init__ falls through to show the lock without calling sys.exit - mock_sys_exit.assert_not_called() - assert locker.demo_mode is True diff --git a/screen_locker/tests/test_early_bird_part2.py b/screen_locker/tests/test_early_bird_part2.py new file mode 100644 index 0000000..3fbc3fc --- /dev/null +++ b/screen_locker/tests/test_early_bird_part2.py @@ -0,0 +1,213 @@ +"""Tests for early bird auto-upgrade, has_logged_today, and init flow.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +from pathlib import Path +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from screen_locker.screen_lock import ScreenLocker +from screen_locker.tests.conftest import ( + create_locker, + create_locker_early_bird, +) + + +class TestTryAutoUpgradeEarlyBird: + """Tests for _try_auto_upgrade_early_bird method.""" + + def test_upgrade_succeeds_when_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns True, saves phone_verified entry, adjusts shutdown.""" + log_file = tmp_path / "workout_log.json" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(return_value=("verified", "Workout verified! (67 min)")), + ) + object.__setattr__( + locker, + "_adjust_shutdown_time_later", + MagicMock(return_value=True), + ) + with patch( + "screen_locker.screen_lock.compute_entry_hmac", + return_value=None, + ): + result = locker._try_auto_upgrade_early_bird() + + assert result is True + with log_file.open() as f: + data: dict[str, Any] = json.load(f) + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + assert data[today]["workout_data"]["type"] == "phone_verified" + assert data[today]["workout_data"]["after_early_bird"] == "true" + + def test_upgrade_fails_when_not_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when phone shows no workout.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(return_value=("no_phone", "No phone connected")), + ) + assert locker._try_auto_upgrade_early_bird() is False + + def test_upgrade_fails_on_os_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when _verify_phone_workout raises OSError.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(side_effect=OSError("adb fail")), + ) + assert locker._try_auto_upgrade_early_bird() is False + + def test_upgrade_fails_on_runtime_error( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Returns False when _verify_phone_workout raises RuntimeError.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock(side_effect=RuntimeError("unexpected")), + ) + assert locker._try_auto_upgrade_early_bird() is False + + +class TestHasLoggedTodayEarlyBird: + """Tests that has_logged_today returns False for early_bird entries.""" + + def test_early_bird_entry_not_counted_as_logged( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """early_bird entries must not satisfy has_logged_today.""" + log_file = tmp_path / "workout_log.json" + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + log_file.write_text( + json.dumps({today: {"workout_data": {"type": "early_bird"}}}) + ) + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + with patch( + "screen_locker.screen_lock.verify_entry_hmac", + return_value=True, + ): + assert locker.has_logged_today() is False + + +class TestInitEarlyBirdFlow: + """Integration tests for early bird branches in __init__.""" + + def test_init_saves_log_and_exits_during_early_bird_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """First login during 5-8:30 window: save early_bird log, exit.""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object(Path, "resolve", return_value=tmp_path), + patch.object(ScreenLocker, "has_logged_today", return_value=False), + patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_time", return_value=True), + patch.object( + ScreenLocker, + "_try_auto_upgrade_early_bird", + return_value=False, + ), + patch.object(ScreenLocker, "_save_early_bird_log") as mock_save, + patch.object(ScreenLocker, "_start_phone_check"), + patch.object(ScreenLocker, "_start_verify_workout_check"), + patch( + "screen_locker.screen_lock.has_workout_skip_today", + return_value=False, + ), + pytest.raises(SystemExit), + ): + ScreenLocker(demo_mode=True) + + mock_save.assert_called_once() + mock_sys_exit.assert_called_with(0) + + def test_init_exits_when_early_bird_log_still_in_window( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Early bird log exists and window still active: skip lock, exit.""" + mock_sys_exit.side_effect = SystemExit(0) + + with pytest.raises(SystemExit): + create_locker_early_bird(mock_tk, tmp_path, state="log_active") + + mock_sys_exit.assert_called_with(0) + + def test_init_exits_when_early_bird_log_upgrades_successfully( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Early bird log + past 8:30 + workout done: upgrade, exit.""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object(Path, "resolve", return_value=tmp_path), + patch.object(ScreenLocker, "has_logged_today", return_value=False), + patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), + patch.object(ScreenLocker, "_is_early_bird_log", return_value=True), + patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), + patch.object( + ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True + ), + patch.object(ScreenLocker, "_start_phone_check"), + patch.object(ScreenLocker, "_start_verify_workout_check"), + pytest.raises(SystemExit), + ): + ScreenLocker(demo_mode=True) + + mock_sys_exit.assert_called_with(0) + + def test_init_shows_lock_when_early_bird_log_no_workout( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Early bird log + past 8:30 + no workout: show lock, no early exit.""" + locker = create_locker_early_bird(mock_tk, tmp_path, state="log_expired") + + # _try_auto_upgrade_early_bird returns False (default in create_locker) + # so __init__ falls through to show the lock without calling sys.exit + mock_sys_exit.assert_not_called() + assert locker.demo_mode is True diff --git a/screen_locker/tests/test_phone_check_unlock.py b/screen_locker/tests/test_phone_check_unlock.py index fc56db9..806b687 100644 --- a/screen_locker/tests/test_phone_check_unlock.py +++ b/screen_locker/tests/test_phone_check_unlock.py @@ -295,194 +295,3 @@ class TestVerifyPhoneWorkout: assert status == "no_exercises" assert "exercise" in message.lower() - - -class TestStartPhoneCheck: - """Tests for _start_phone_check and _handle_startup_phone_result.""" - - def test_start_phone_check_shows_checking_screen( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test _start_phone_check shows checking message and starts check.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "clear_container", MagicMock()) - object.__setattr__( - locker, - "_verify_phone_workout", - MagicMock( - return_value=("no_phone", "No phone"), - ), - ) - object.__setattr__(locker, "_poll_phone_check", MagicMock()) - - locker._start_phone_check() - - locker.clear_container.assert_called() - locker._poll_phone_check.assert_called_once() - assert locker._phone_future is not None - - def test_handle_startup_verified_unlocks_directly( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test verified result shows success screen then unlocks via after().""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "unlock_screen", MagicMock()) - object.__setattr__(locker.root, "after", MagicMock()) - - locker._handle_startup_phone_result("verified", "Workout verified! (1 session)") - - # unlock_screen is deferred via root.after, not called directly - locker.unlock_screen.assert_not_called() - assert locker.workout_data["type"] == "phone_verified" - locker.root.after.assert_called_once_with(1500, locker.unlock_screen) - - def test_handle_startup_not_verified_shows_retry_and_sick( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test not_verified result shows retry and sick buttons.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) - locker._handle_startup_phone_result( - "not_verified", "No workout found on phone today" - ) - - locker._show_retry_and_sick.assert_called_once() - - def test_handle_startup_too_short_shows_retry_and_sick( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test too_short result shows retry and sick buttons.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) - locker._handle_startup_phone_result( - "too_short", "Workout too short! 25 min logged, need at least 50 min." - ) - - locker._show_retry_and_sick.assert_called_once() - call_args = locker._show_retry_and_sick.call_args[0][0] - assert "too short" in call_args.lower() - - def test_handle_startup_stale_shows_retry_and_sick( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test stale result shows retry and sick buttons.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) - locker._handle_startup_phone_result("stale", "Workout too old") - - locker._show_retry_and_sick.assert_called_once() - call_args = locker._show_retry_and_sick.call_args[0][0] - assert "reason: stale" in call_args.lower() - - def test_handle_startup_no_exercises_shows_retry_and_sick( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test no_exercises result shows retry and sick buttons.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) - locker._handle_startup_phone_result("no_exercises", "No exercises found") - - locker._show_retry_and_sick.assert_called_once() - call_args = locker._show_retry_and_sick.call_args[0][0] - assert "reason: no_exercises" in call_args.lower() - - def test_handle_startup_clock_tampered_shows_retry_and_sick( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test clock_tampered result shows retry and sick buttons.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) - locker._handle_startup_phone_result( - "clock_tampered", - "System clock is 600s ahead", - ) - - locker._show_retry_and_sick.assert_called_once() - call_args = locker._show_retry_and_sick.call_args[0][0] - assert "clock" in call_args.lower() - - def test_handle_startup_no_phone_shows_penalty( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test no_phone result triggers penalty with default retry+sick callback.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_phone_penalty", MagicMock()) - - locker._handle_startup_phone_result("no_phone", "No phone") - - locker._show_phone_penalty.assert_called_once_with("No phone") - - def test_handle_startup_error_shows_penalty( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test error result triggers penalty with default retry+sick callback.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__(locker, "_show_phone_penalty", MagicMock()) - - locker._handle_startup_phone_result("error", "DB not found") - - locker._show_phone_penalty.assert_called_once_with("DB not found") - - def test_poll_phone_check_schedules_retry_when_pending( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test _poll_phone_check reschedules itself when future is not done.""" - locker = create_locker(mock_tk, tmp_path) - mock_future: MagicMock = MagicMock() - mock_future.done.return_value = False - object.__setattr__(locker, "_phone_future", mock_future) - object.__setattr__(locker.root, "after", MagicMock()) - - locker._poll_phone_check() - - locker.root.after.assert_called_once_with(500, locker._poll_phone_check) - - def test_poll_phone_check_routes_when_done( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test _poll_phone_check calls result handler when future is done.""" - locker = create_locker(mock_tk, tmp_path) - mock_future: MagicMock = MagicMock() - mock_future.done.return_value = True - mock_future.result.return_value = ("no_phone", "No phone") - object.__setattr__(locker, "_phone_future", mock_future) - object.__setattr__(locker, "_handle_startup_phone_result", MagicMock()) - - locker._poll_phone_check() - - locker._handle_startup_phone_result.assert_called_once_with( - "no_phone", "No phone" - ) diff --git a/screen_locker/tests/test_phone_check_unlock_part3.py b/screen_locker/tests/test_phone_check_unlock_part3.py new file mode 100644 index 0000000..03e9cb6 --- /dev/null +++ b/screen_locker/tests/test_phone_check_unlock_part3.py @@ -0,0 +1,203 @@ +"""Tests for _start_phone_check, polling, and startup result routing.""" +# pylint: disable=protected-access,unused-argument + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestStartPhoneCheck: + """Tests for _start_phone_check and _handle_startup_phone_result.""" + + def test_start_phone_check_shows_checking_screen( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test _start_phone_check shows checking message and starts check.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "clear_container", MagicMock()) + object.__setattr__( + locker, + "_verify_phone_workout", + MagicMock( + return_value=("no_phone", "No phone"), + ), + ) + object.__setattr__(locker, "_poll_phone_check", MagicMock()) + + locker._start_phone_check() + + locker.clear_container.assert_called() + locker._poll_phone_check.assert_called_once() + assert locker._phone_future is not None + + def test_handle_startup_verified_unlocks_directly( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test verified result shows success screen then unlocks via after().""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "unlock_screen", MagicMock()) + object.__setattr__(locker.root, "after", MagicMock()) + + locker._handle_startup_phone_result("verified", "Workout verified! (1 session)") + + # unlock_screen is deferred via root.after, not called directly + locker.unlock_screen.assert_not_called() + assert locker.workout_data["type"] == "phone_verified" + locker.root.after.assert_called_once_with(1500, locker.unlock_screen) + + def test_handle_startup_not_verified_shows_retry_and_sick( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test not_verified result shows retry and sick buttons.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) + locker._handle_startup_phone_result( + "not_verified", "No workout found on phone today" + ) + + locker._show_retry_and_sick.assert_called_once() + + def test_handle_startup_too_short_shows_retry_and_sick( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test too_short result shows retry and sick buttons.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) + locker._handle_startup_phone_result( + "too_short", "Workout too short! 25 min logged, need at least 50 min." + ) + + locker._show_retry_and_sick.assert_called_once() + call_args = locker._show_retry_and_sick.call_args[0][0] + assert "too short" in call_args.lower() + + def test_handle_startup_stale_shows_retry_and_sick( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test stale result shows retry and sick buttons.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) + locker._handle_startup_phone_result("stale", "Workout too old") + + locker._show_retry_and_sick.assert_called_once() + call_args = locker._show_retry_and_sick.call_args[0][0] + assert "reason: stale" in call_args.lower() + + def test_handle_startup_no_exercises_shows_retry_and_sick( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test no_exercises result shows retry and sick buttons.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) + locker._handle_startup_phone_result("no_exercises", "No exercises found") + + locker._show_retry_and_sick.assert_called_once() + call_args = locker._show_retry_and_sick.call_args[0][0] + assert "reason: no_exercises" in call_args.lower() + + def test_handle_startup_clock_tampered_shows_retry_and_sick( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test clock_tampered result shows retry and sick buttons.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) + locker._handle_startup_phone_result( + "clock_tampered", + "System clock is 600s ahead", + ) + + locker._show_retry_and_sick.assert_called_once() + call_args = locker._show_retry_and_sick.call_args[0][0] + assert "clock" in call_args.lower() + + def test_handle_startup_no_phone_shows_penalty( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test no_phone result triggers penalty with default retry+sick callback.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_phone_penalty", MagicMock()) + + locker._handle_startup_phone_result("no_phone", "No phone") + + locker._show_phone_penalty.assert_called_once_with("No phone") + + def test_handle_startup_error_shows_penalty( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test error result triggers penalty with default retry+sick callback.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__(locker, "_show_phone_penalty", MagicMock()) + + locker._handle_startup_phone_result("error", "DB not found") + + locker._show_phone_penalty.assert_called_once_with("DB not found") + + def test_poll_phone_check_schedules_retry_when_pending( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test _poll_phone_check reschedules itself when future is not done.""" + locker = create_locker(mock_tk, tmp_path) + mock_future: MagicMock = MagicMock() + mock_future.done.return_value = False + object.__setattr__(locker, "_phone_future", mock_future) + object.__setattr__(locker.root, "after", MagicMock()) + + locker._poll_phone_check() + + locker.root.after.assert_called_once_with(500, locker._poll_phone_check) + + def test_poll_phone_check_routes_when_done( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test _poll_phone_check calls result handler when future is done.""" + locker = create_locker(mock_tk, tmp_path) + mock_future: MagicMock = MagicMock() + mock_future.done.return_value = True + mock_future.result.return_value = ("no_phone", "No phone") + object.__setattr__(locker, "_phone_future", mock_future) + object.__setattr__(locker, "_handle_startup_phone_result", MagicMock()) + + locker._poll_phone_check() + + locker._handle_startup_phone_result.assert_called_once_with( + "no_phone", "No phone" + ) diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py index a19c00e..fc41f83 100644 --- a/screen_locker/tests/test_shutdown_part2.py +++ b/screen_locker/tests/test_shutdown_part2.py @@ -283,138 +283,3 @@ class TestSickModeUsedToday: ): state_file.write_text("not json{{{") assert locker._sick_mode_used_today() is False - - -class TestSaveSickDayState: - """Tests for _save_sick_day_state method.""" - - def test_saves_state_successfully( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test saves state file with correct content.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ): - result = locker._save_sick_day_state("2026-03-21", 21, 20) - assert result is True - data = json.loads(state_file.read_text()) - assert data["date"] == "2026-03-21" - assert data["original_mon_wed_hour"] == 21 - assert data["original_thu_sun_hour"] == 20 - - def test_returns_false_on_oserror( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when write fails.""" - locker = create_locker(mock_tk, tmp_path) - mock_path = MagicMock() - mock_path.open.side_effect = OSError("permission denied") - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - mock_path, - ): - result = locker._save_sick_day_state("2026-03-21", 21, 20) - assert result is False - - -class TestLoadSickDayState: - """Tests for _load_sick_day_state method.""" - - def test_loads_valid_state( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test loads state with all fields present.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text( - json.dumps( - { - "date": "2026-03-20", - "original_mon_wed_hour": 21, - "original_thu_sun_hour": 20, - } - ) - ) - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ): - result = locker._load_sick_day_state() - assert result == ("2026-03-20", 21, 20) - - def test_returns_none_when_fields_missing( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns None when required fields are missing.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text(json.dumps({"date": "2026-03-20"})) - with patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ): - result = locker._load_sick_day_state() - assert result is None - - -class TestWriteRestoredConfig: - """Tests for _write_restored_config method.""" - - def test_restores_config_and_removes_state( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test restores config values and deletes state file.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text("{}") - with ( - patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)), - patch.object( - locker, "_write_shutdown_config", return_value=True - ) as mock_write, - patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ), - ): - locker._write_restored_config(21, 20, "2026-03-20") - mock_write.assert_called_once_with(21, 20, 8, restore=True) - assert not state_file.exists() - - def test_still_removes_state_when_config_read_fails( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test removes state file even when config read returns None.""" - locker = create_locker(mock_tk, tmp_path) - state_file = tmp_path / "state.json" - state_file.write_text("{}") - with ( - patch.object(locker, "_read_shutdown_config", return_value=None), - patch( - "screen_locker._shutdown.SICK_DAY_STATE_FILE", - state_file, - ), - ): - locker._write_restored_config(21, 20, "2026-03-20") - assert not state_file.exists() diff --git a/screen_locker/tests/test_shutdown_part4.py b/screen_locker/tests/test_shutdown_part4.py new file mode 100644 index 0000000..b2a56d6 --- /dev/null +++ b/screen_locker/tests/test_shutdown_part4.py @@ -0,0 +1,147 @@ +"""Tests for sick-day state save/load and config restoration.""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSaveSickDayState: + """Tests for _save_sick_day_state method.""" + + def test_saves_state_successfully( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test saves state file with correct content.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + result = locker._save_sick_day_state("2026-03-21", 21, 20) + assert result is True + data = json.loads(state_file.read_text()) + assert data["date"] == "2026-03-21" + assert data["original_mon_wed_hour"] == 21 + assert data["original_thu_sun_hour"] == 20 + + def test_returns_false_on_oserror( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when write fails.""" + locker = create_locker(mock_tk, tmp_path) + mock_path = MagicMock() + mock_path.open.side_effect = OSError("permission denied") + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + mock_path, + ): + result = locker._save_sick_day_state("2026-03-21", 21, 20) + assert result is False + + +class TestLoadSickDayState: + """Tests for _load_sick_day_state method.""" + + def test_loads_valid_state( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test loads state with all fields present.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text( + json.dumps( + { + "date": "2026-03-20", + "original_mon_wed_hour": 21, + "original_thu_sun_hour": 20, + } + ) + ) + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + result = locker._load_sick_day_state() + assert result == ("2026-03-20", 21, 20) + + def test_returns_none_when_fields_missing( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns None when required fields are missing.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text(json.dumps({"date": "2026-03-20"})) + with patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ): + result = locker._load_sick_day_state() + assert result is None + + +class TestWriteRestoredConfig: + """Tests for _write_restored_config method.""" + + def test_restores_config_and_removes_state( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test restores config values and deletes state file.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text("{}") + with ( + patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)), + patch.object( + locker, "_write_shutdown_config", return_value=True + ) as mock_write, + patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + ): + locker._write_restored_config(21, 20, "2026-03-20") + mock_write.assert_called_once_with(21, 20, 8, restore=True) + assert not state_file.exists() + + def test_still_removes_state_when_config_read_fails( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test removes state file even when config read returns None.""" + locker = create_locker(mock_tk, tmp_path) + state_file = tmp_path / "state.json" + state_file.write_text("{}") + with ( + patch.object(locker, "_read_shutdown_config", return_value=None), + patch( + "screen_locker._shutdown.SICK_DAY_STATE_FILE", + state_file, + ), + ): + locker._write_restored_config(21, 20, "2026-03-20") + assert not state_file.exists() diff --git a/screen_locker/tests/test_sick_features.py b/screen_locker/tests/test_sick_features.py index 0589a14..d6264fa 100644 --- a/screen_locker/tests/test_sick_features.py +++ b/screen_locker/tests/test_sick_features.py @@ -299,151 +299,3 @@ class TestUpdateCommitmentForcedDelay: locker._sick_submit_button.config.assert_called_with( text="SUBMIT", state="normal" ) - - -class TestSubmitSickJustification: - """Tests for _submit_sick_justification validation + persistence.""" - - def _setup_locker( - self, - mock_tk: MagicMock, - tmp_path: Path, - *, - fields: dict[str, object] | None = None, - ) -> object: - defaults: dict[str, object] = { - "symptom": "fever", - "onset": "last night", - "severity": 7, - "text": "x" * 200, - } - if fields: - defaults.update(fields) - locker = create_locker(mock_tk, tmp_path) - locker._sick_history_cache = SickHistory() - locker._sick_symptom_var = MagicMock() - locker._sick_symptom_var.get.return_value = defaults["symptom"] - locker._sick_onset_var = MagicMock() - locker._sick_onset_var.get.return_value = defaults["onset"] - locker._sick_severity_var = MagicMock() - locker._sick_severity_var.get.return_value = defaults["severity"] - locker._sick_text_widget = MagicMock() - locker._sick_text_widget.get.return_value = defaults["text"] - locker._sick_error_label = MagicMock() - object.__setattr__(locker, "_proceed_to_sick_countdown", MagicMock()) - return locker - - def test_validation_failure_displays_error( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path, fields={"symptom": ""}) - locker._submit_sick_justification() - locker._sick_error_label.config.assert_called_once() - locker._proceed_to_sick_countdown.assert_not_called() - - def test_severity_tcl_error_treated_as_invalid( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - locker._sick_severity_var.get.side_effect = ValueError("bad") - locker._submit_sick_justification() - locker._sick_error_label.config.assert_called_once() - - def test_save_failure_displays_error( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "save_history", return_value=False): - locker._submit_sick_justification() - locker._sick_error_label.config.assert_called_once() - locker._proceed_to_sick_countdown.assert_not_called() - - def test_success_proceeds_to_countdown( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = self._setup_locker(mock_tk, tmp_path) - with patch.object(_sick_tracker, "save_history", return_value=True): - locker._submit_sick_justification() - locker._proceed_to_sick_countdown.assert_called_once() - - -class TestCommitmentPrompt: - """Tests for _show_commitment_prompt + _tick_commitment_timeout + answer.""" - - def test_show_prompt_renders_buttons( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._show_commitment_prompt(on_done=on_done) - assert locker._commitment_done_fn is on_done - assert locker._commitment_remaining > 0 - - def test_tick_decrements( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - locker._commitment_remaining = 2 - locker._commitment_timer_label = MagicMock() - locker._tick_commitment_timeout() - assert locker._commitment_remaining == 1 - locker.root.after.assert_called() - - def test_tick_zero_auto_answers_no( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._commitment_done_fn = on_done - locker._commitment_remaining = 0 - locker._commitment_timer_label = MagicMock() - locker._tick_commitment_timeout() - on_done.assert_called_once() - - def test_answer_yes_persists_commitment( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._commitment_done_fn = on_done - history = SickHistory() - with ( - patch.object(_sick_tracker, "load_history", return_value=history), - patch.object(_sick_tracker, "save_history", return_value=True) as mock_save, - ): - locker._answer_commitment(commit=True) - mock_save.assert_called_once() - on_done.assert_called_once() - assert locker._commitment_done_fn is None - - def test_answer_no_skips_persistence( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - on_done = MagicMock() - locker._commitment_done_fn = on_done - with patch.object(_sick_tracker, "save_history") as mock_save: - locker._answer_commitment(commit=False) - mock_save.assert_not_called() - on_done.assert_called_once() - - def test_answer_with_no_done_fn_is_safe( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - locker = create_locker(mock_tk, tmp_path) - # No _commitment_done_fn attribute set. - locker._answer_commitment(commit=False) - - -class TestDisablePaste: - """Tests for the _disable_paste helper.""" - - def test_swallows_tcl_error(self) -> None: - from screen_locker._sick_dialog import _disable_paste - - widget = MagicMock() - import tkinter as tk - - widget.bind.side_effect = tk.TclError("nope") - # Should not raise. - _disable_paste(widget) diff --git a/screen_locker/tests/test_sick_features_part2.py b/screen_locker/tests/test_sick_features_part2.py new file mode 100644 index 0000000..55245c6 --- /dev/null +++ b/screen_locker/tests/test_sick_features_part2.py @@ -0,0 +1,162 @@ +"""Tests for sick justification submission, commitment prompt, and paste disable.""" +# pylint: disable=protected-access + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker import _sick_tracker +from screen_locker._sick_tracker import SickHistory +from screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestSubmitSickJustification: + """Tests for _submit_sick_justification validation + persistence.""" + + def _setup_locker( + self, + mock_tk: MagicMock, + tmp_path: Path, + *, + fields: dict[str, object] | None = None, + ) -> object: + defaults: dict[str, object] = { + "symptom": "fever", + "onset": "last night", + "severity": 7, + "text": "x" * 200, + } + if fields: + defaults.update(fields) + locker = create_locker(mock_tk, tmp_path) + locker._sick_history_cache = SickHistory() + locker._sick_symptom_var = MagicMock() + locker._sick_symptom_var.get.return_value = defaults["symptom"] + locker._sick_onset_var = MagicMock() + locker._sick_onset_var.get.return_value = defaults["onset"] + locker._sick_severity_var = MagicMock() + locker._sick_severity_var.get.return_value = defaults["severity"] + locker._sick_text_widget = MagicMock() + locker._sick_text_widget.get.return_value = defaults["text"] + locker._sick_error_label = MagicMock() + object.__setattr__(locker, "_proceed_to_sick_countdown", MagicMock()) + return locker + + def test_validation_failure_displays_error( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path, fields={"symptom": ""}) + locker._submit_sick_justification() + locker._sick_error_label.config.assert_called_once() + locker._proceed_to_sick_countdown.assert_not_called() + + def test_severity_tcl_error_treated_as_invalid( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path) + locker._sick_severity_var.get.side_effect = ValueError("bad") + locker._submit_sick_justification() + locker._sick_error_label.config.assert_called_once() + + def test_save_failure_displays_error( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path) + with patch.object(_sick_tracker, "save_history", return_value=False): + locker._submit_sick_justification() + locker._sick_error_label.config.assert_called_once() + locker._proceed_to_sick_countdown.assert_not_called() + + def test_success_proceeds_to_countdown( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = self._setup_locker(mock_tk, tmp_path) + with patch.object(_sick_tracker, "save_history", return_value=True): + locker._submit_sick_justification() + locker._proceed_to_sick_countdown.assert_called_once() + + +class TestCommitmentPrompt: + """Tests for _show_commitment_prompt + _tick_commitment_timeout + answer.""" + + def test_show_prompt_renders_buttons( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + on_done = MagicMock() + locker._show_commitment_prompt(on_done=on_done) + assert locker._commitment_done_fn is on_done + assert locker._commitment_remaining > 0 + + def test_tick_decrements( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + locker._commitment_remaining = 2 + locker._commitment_timer_label = MagicMock() + locker._tick_commitment_timeout() + assert locker._commitment_remaining == 1 + locker.root.after.assert_called() + + def test_tick_zero_auto_answers_no( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + on_done = MagicMock() + locker._commitment_done_fn = on_done + locker._commitment_remaining = 0 + locker._commitment_timer_label = MagicMock() + locker._tick_commitment_timeout() + on_done.assert_called_once() + + def test_answer_yes_persists_commitment( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + on_done = MagicMock() + locker._commitment_done_fn = on_done + history = SickHistory() + with ( + patch.object(_sick_tracker, "load_history", return_value=history), + patch.object(_sick_tracker, "save_history", return_value=True) as mock_save, + ): + locker._answer_commitment(commit=True) + mock_save.assert_called_once() + on_done.assert_called_once() + assert locker._commitment_done_fn is None + + def test_answer_no_skips_persistence( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + on_done = MagicMock() + locker._commitment_done_fn = on_done + with patch.object(_sick_tracker, "save_history") as mock_save: + locker._answer_commitment(commit=False) + mock_save.assert_not_called() + on_done.assert_called_once() + + def test_answer_with_no_done_fn_is_safe( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + locker = create_locker(mock_tk, tmp_path) + # No _commitment_done_fn attribute set. + locker._answer_commitment(commit=False) + + +class TestDisablePaste: + """Tests for the _disable_paste helper.""" + + def test_swallows_tcl_error(self) -> None: + import tkinter as tk + + from screen_locker._sick_dialog import _disable_paste + + widget = MagicMock() + widget.bind.side_effect = tk.TclError("nope") + # Should not raise. + _disable_paste(widget) diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index 08ac985..884244f 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -347,252 +347,3 @@ class TestStartRelaxedPhoneCheck: with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle: locker._poll_relaxed_phone_check() mock_handle.assert_not_called() - - -class TestHandleRelaxedPhoneResult: - def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: - return create_locker(mock_tk, tmp_path) - - def test_verified_calls_unlock_screen( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with patch.object(locker, "unlock_screen"): - locker._handle_relaxed_phone_result("verified", "StrongLifts sync OK") - - assert locker.workout_data["type"] == "phone_verified" - assert locker.workout_data["source"] == "StrongLifts sync OK" - locker.root.after.assert_called() - - def test_not_verified_shows_relaxed_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with patch.object(locker, "_show_relaxed_retry") as mock_retry: - locker._handle_relaxed_phone_result("not_verified", "no workout today") - - mock_retry.assert_called_once_with("no workout today", "not_verified") - - def test_too_short_shows_relaxed_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with patch.object(locker, "_show_relaxed_retry") as mock_retry: - locker._handle_relaxed_phone_result("too_short", "only 20 min") - - mock_retry.assert_called_once_with("only 20 min", "too_short") - - def test_no_phone_shows_relaxed_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with patch.object(locker, "_show_relaxed_retry") as mock_retry: - locker._handle_relaxed_phone_result("no_phone", "ADB not found") - - mock_retry.assert_called_once_with("ADB not found", "no_phone") - - -class TestShowRelaxedRetry: - def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: - return create_locker(mock_tk, tmp_path) - - def test_shows_try_again_and_close_buttons( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_button") as mock_button, - patch.object(locker, "_label"), - patch.object(locker, "_text"), - patch.object(locker, "_button_row", return_value=MagicMock()), - patch.object(locker, "clear_container"), - ): - locker._show_relaxed_retry("msg", "not_verified") - - button_texts = " ".join(str(c.args) for c in mock_button.call_args_list) - assert "TRY AGAIN" in button_texts - assert "Close" in button_texts - - def test_no_sick_button( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_button") as mock_button, - patch.object(locker, "_label"), - patch.object(locker, "_text"), - patch.object(locker, "_button_row", return_value=MagicMock()), - patch.object(locker, "clear_container"), - ): - locker._show_relaxed_retry("msg", "not_verified") - - button_texts = " ".join(str(c.args) for c in mock_button.call_args_list) - assert "sick" not in button_texts.lower() - - -# --------------------------------------------------------------------------- -# _check_today_state_exits: return True/False branches -# --------------------------------------------------------------------------- - - -class TestCheckTodayStateExits: - """Cover all return True/False paths in _check_today_state_exits. - - sys.exit is mocked without side_effect so execution continues past it - and the 'return True' statements are reachable. - """ - - def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: - return create_locker(mock_tk, tmp_path) - - def test_early_bird_upgrade_success_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=True), - patch.object(locker, "_is_early_bird_time", return_value=False), - patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True), - ): - result = locker._check_today_state_exits() - assert result is True - - def test_early_bird_upgrade_fail_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=True), - patch.object(locker, "_is_early_bird_time", return_value=False), - patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False), - ): - result = locker._check_today_state_exits() - assert result is False - - def test_early_bird_window_active_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=True), - patch.object(locker, "_is_early_bird_time", return_value=True), - ): - result = locker._check_today_state_exits() - assert result is True - - def test_sick_day_auto_upgrade_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=False), - patch.object(locker, "_is_sick_day_log", return_value=True), - patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True), - ): - result = locker._check_today_state_exits() - assert result is True - - def test_workout_skip_today_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=False), - patch.object(locker, "_is_sick_day_log", return_value=False), - patch.object(locker, "has_logged_today", return_value=False), - patch( - "screen_locker.screen_lock.has_workout_skip_today", - return_value=True, - ), - ): - result = locker._check_today_state_exits() - assert result is True - - def test_early_bird_time_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=False), - patch.object(locker, "_is_sick_day_log", return_value=False), - patch.object(locker, "has_logged_today", return_value=False), - patch( - "screen_locker.screen_lock.has_workout_skip_today", - return_value=False, - ), - patch.object(locker, "_is_early_bird_time", return_value=True), - patch.object(locker, "_save_early_bird_log"), - ): - result = locker._check_today_state_exits() - assert result is True - - def test_no_exit_conditions_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = self._make_locker(mock_tk, tmp_path) - with ( - patch.object(locker, "_is_early_bird_log", return_value=False), - patch.object(locker, "_is_sick_day_log", return_value=False), - patch.object(locker, "has_logged_today", return_value=False), - patch( - "screen_locker.screen_lock.has_workout_skip_today", - return_value=False, - ), - patch.object(locker, "_is_early_bird_time", return_value=False), - ): - result = locker._check_today_state_exits() - assert result is False - - -class TestCheckNonVerifyExitsScheduledSkip: - """Cover the return after scheduled-skip sys.exit in _check_non_verify_exits.""" - - def test_scheduled_skip_return_reached( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - locker = create_locker(mock_tk, tmp_path) - with patch.object(locker, "_is_scheduled_skip_today", return_value=True): - locker._check_non_verify_exits() - mock_sys_exit.assert_called_once_with(0) diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py new file mode 100644 index 0000000..7c86ddc --- /dev/null +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -0,0 +1,158 @@ +"""Tests for _check_today_state_exits and scheduled-skip branches.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import MagicMock, patch + +from screen_locker.screen_lock import ScreenLocker +from screen_locker.tests.conftest import create_locker + +# --------------------------------------------------------------------------- +# _check_today_state_exits: return True/False branches +# --------------------------------------------------------------------------- + + +class TestCheckTodayStateExits: + """Cover all return True/False paths in _check_today_state_exits. + + sys.exit is mocked without side_effect so execution continues past it + and the 'return True' statements are reachable. + """ + + def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker: + return create_locker(mock_tk, tmp_path) + + def test_early_bird_upgrade_success_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=True), + patch.object(locker, "_is_early_bird_time", return_value=False), + patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True), + ): + result = locker._check_today_state_exits() + assert result is True + + def test_early_bird_upgrade_fail_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=True), + patch.object(locker, "_is_early_bird_time", return_value=False), + patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False), + ): + result = locker._check_today_state_exits() + assert result is False + + def test_early_bird_window_active_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=True), + patch.object(locker, "_is_early_bird_time", return_value=True), + ): + result = locker._check_today_state_exits() + assert result is True + + def test_sick_day_auto_upgrade_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=False), + patch.object(locker, "_is_sick_day_log", return_value=True), + patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True), + ): + result = locker._check_today_state_exits() + assert result is True + + def test_workout_skip_today_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=False), + patch.object(locker, "_is_sick_day_log", return_value=False), + patch.object(locker, "has_logged_today", return_value=False), + patch( + "screen_locker.screen_lock.has_workout_skip_today", + return_value=True, + ), + ): + result = locker._check_today_state_exits() + assert result is True + + def test_early_bird_time_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=False), + patch.object(locker, "_is_sick_day_log", return_value=False), + patch.object(locker, "has_logged_today", return_value=False), + patch( + "screen_locker.screen_lock.has_workout_skip_today", + return_value=False, + ), + patch.object(locker, "_is_early_bird_time", return_value=True), + patch.object(locker, "_save_early_bird_log"), + ): + result = locker._check_today_state_exits() + assert result is True + + def test_no_exit_conditions_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = self._make_locker(mock_tk, tmp_path) + with ( + patch.object(locker, "_is_early_bird_log", return_value=False), + patch.object(locker, "_is_sick_day_log", return_value=False), + patch.object(locker, "has_logged_today", return_value=False), + patch( + "screen_locker.screen_lock.has_workout_skip_today", + return_value=False, + ), + patch.object(locker, "_is_early_bird_time", return_value=False), + ): + result = locker._check_today_state_exits() + assert result is False + + +class TestCheckNonVerifyExitsScheduledSkip: + """Cover the return after scheduled-skip sys.exit in _check_non_verify_exits.""" + + def test_scheduled_skip_return_reached( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + locker = create_locker(mock_tk, tmp_path) + with patch.object(locker, "_is_scheduled_skip_today", return_value=True): + locker._check_non_verify_exits() + mock_sys_exit.assert_called_once_with(0) diff --git a/scripts/check_file_length.py b/scripts/check_file_length.py new file mode 100644 index 0000000..6893f42 --- /dev/null +++ b/scripts/check_file_length.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +"""Pre-commit hook: fail if any file exceeds MAX_LINES lines.""" + +import sys + +MAX_LINES = 400 + + +def main() -> int: + """Return 1 if any file exceeds the line limit, else 0.""" + failed = False + for filepath in sys.argv[1:]: + try: + with open(filepath, encoding="utf-8", errors="replace") as fh: + count = sum(1 for _ in fh) + except OSError as exc: + print(f"ERROR reading {filepath}: {exc}", file=sys.stderr) + failed = True + continue + if count > MAX_LINES: + print(f"{filepath}: {count} lines (max {MAX_LINES})") + failed = True + return 1 if failed else 0 + + +if __name__ == "__main__": + sys.exit(main())