diff --git a/screen_locker/_early_bird.py b/screen_locker/_early_bird.py new file mode 100644 index 0000000..6f6b748 --- /dev/null +++ b/screen_locker/_early_bird.py @@ -0,0 +1,72 @@ +"""Early bird window detection and log helpers for ScreenLocker.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import logging + +from python_pkg.screen_locker._constants import ( + EARLY_BIRD_END_HOUR, + EARLY_BIRD_END_MINUTE, + EARLY_BIRD_START_HOUR, +) + +_logger = logging.getLogger(__name__) + + +class EarlyBirdMixin: + """Mixin providing early-bird time window checks and log helpers.""" + + def _get_local_time_minutes(self) -> int: + """Return current local time as minutes from midnight.""" + now = datetime.now(tz=timezone.utc).astimezone() + return now.hour * 60 + now.minute + + def _is_early_bird_time(self) -> bool: + """Return True if current local time is in the early bird window.""" + minutes = self._get_local_time_minutes() + start = EARLY_BIRD_START_HOUR * 60 + end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE + return start <= minutes < end + + def _is_early_bird_log(self) -> bool: + """Check if today's workout log entry is an early_bird provisional entry.""" + if not self.log_file.exists(): + return False + try: + with self.log_file.open() as f: + logs = json.load(f) + except (OSError, json.JSONDecodeError): + return False + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + entry = logs.get(today) + if entry is None: + return False + return entry.get("workout_data", {}).get("type") == "early_bird" + + def _save_early_bird_log(self) -> None: + """Save an early_bird provisional entry to the workout log.""" + self.workout_data = {"type": "early_bird"} + self.save_workout_log() + + def _try_auto_upgrade_early_bird(self) -> bool: + """Silently upgrade today's early_bird entry if phone shows a workout.""" + try: + status, message = self._verify_phone_workout() + except (OSError, RuntimeError) as exc: + _logger.info("Early bird upgrade phone check failed: %s", exc) + return False + if status != "verified": + _logger.info( + "Early bird upgrade skipped (phone status=%s): %s", + status, + message, + ) + return False + self.workout_data["type"] = "phone_verified" + self.workout_data["source"] = message + self.workout_data["after_early_bird"] = "true" + self._adjust_shutdown_time_later() + self.save_workout_log() + return True diff --git a/screen_locker/_window_setup.py b/screen_locker/_window_setup.py new file mode 100644 index 0000000..3da88ce --- /dev/null +++ b/screen_locker/_window_setup.py @@ -0,0 +1,80 @@ +"""Window configuration and input-grab helpers for ScreenLocker.""" + +from __future__ import annotations + +import contextlib +import logging +import shutil +import subprocess +import tkinter as tk + +_logger = logging.getLogger(__name__) + + +class WindowSetupMixin: + """Mixin providing window setup, VT switching control, and input-grab helpers.""" + + def _disable_vt_switching(self) -> None: + """Disable VT switching in X11 while the lock is active. + + Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn. + Best-effort: silently ignored if setxkbmap is unavailable. + """ + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + _logger.warning("setxkbmap not found; VT switching will not be disabled") + return + subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) + + def _restore_vt_switching(self) -> None: + """Restore VT switching after the lock is dismissed.""" + setxkbmap = shutil.which("setxkbmap") + if setxkbmap is None: + return + subprocess.run([setxkbmap, "-option", ""], check=False) + + def _setup_window(self) -> None: + """Configure the window for fullscreen lock.""" + screen_w = self.root.winfo_screenwidth() + screen_h = self.root.winfo_screenheight() + self.root.overrideredirect(boolean=True) + self.root.geometry(f"{screen_w}x{screen_h}+0+0") + self.root.attributes(fullscreen=True) + self.root.attributes(topmost=True) + self.root.configure(bg="#1a1a1a", cursor="arrow") + if not self.demo_mode: + self._disable_vt_switching() + + def _setup_verify_window(self) -> None: + """Configure window for post-sick-day workout verification.""" + self.root.geometry("600x400") + self.root.configure(bg="#1a1a1a", cursor="arrow") + self.root.protocol("WM_DELETE_WINDOW", self.close) + + def _setup_demo_close_button(self) -> None: + """Add close button for demo mode.""" + close_btn = tk.Button( + self.root, + text="✕ Close Demo", + font=("Arial", 12), + bg="#ff4444", + fg="white", + command=self.close, + cursor="hand2", + ) + close_btn.place(x=10, y=10) + + def _grab_input(self) -> None: + """Force input focus to the locker window.""" + self.root.update_idletasks() + self.root.focus_force() + if self.demo_mode: + with contextlib.suppress(tk.TclError): + self.root.grab_set() + else: + try: + self.root.grab_set_global() + except tk.TclError: + _logger.warning("Global grab failed, falling back to local grab") + with contextlib.suppress(tk.TclError): + self.root.grab_set() diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index a395575..e9af2e4 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -6,13 +6,10 @@ Requires user to log their workout to unlock the screen. from __future__ import annotations -import contextlib from datetime import datetime, timezone import json import logging from pathlib import Path -import shutil -import subprocess import sys import tkinter as tk from typing import TYPE_CHECKING @@ -31,6 +28,7 @@ from python_pkg.screen_locker._constants import ( SICK_LOCKOUT_SECONDS, STRONGLIFTS_DB_REMOTE, ) +from python_pkg.screen_locker._early_bird import EarlyBirdMixin from python_pkg.screen_locker._log_integrity import ( _load_hmac_key, compute_entry_hmac, @@ -40,6 +38,7 @@ from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin from python_pkg.screen_locker._shutdown import ShutdownMixin from python_pkg.screen_locker._sick_dialog import SickDialogMixin from python_pkg.screen_locker._ui_flows import UIFlowsMixin +from python_pkg.screen_locker._window_setup import WindowSetupMixin from python_pkg.wake_alarm._state import has_workout_skip_today if TYPE_CHECKING: @@ -80,6 +79,8 @@ def _assert_not_under_pytest() -> None: class ScreenLocker( + EarlyBirdMixin, + WindowSetupMixin, ShutdownMixin, PhoneVerificationMixin, SickDialogMixin, @@ -122,43 +123,6 @@ class ScreenLocker( self._start_phone_check() self._grab_input() - def _disable_vt_switching(self) -> None: - """Disable VT switching in X11 while the lock is active. - - Prevents bypassing the lock by switching to a TTY with Ctrl+Alt+Fn. - Best-effort: silently ignored if setxkbmap is unavailable. - """ - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is None: - _logger.warning("setxkbmap not found; VT switching will not be disabled") - return - subprocess.run([setxkbmap, "-option", "srvrkeys:none"], check=False) - - def _restore_vt_switching(self) -> None: - """Restore VT switching after the lock is dismissed.""" - setxkbmap = shutil.which("setxkbmap") - if setxkbmap is None: - return - subprocess.run([setxkbmap, "-option", ""], check=False) - - def _setup_window(self) -> None: - """Configure the window for fullscreen lock.""" - screen_w = self.root.winfo_screenwidth() - screen_h = self.root.winfo_screenheight() - self.root.overrideredirect(boolean=True) - self.root.geometry(f"{screen_w}x{screen_h}+0+0") - self.root.attributes(fullscreen=True) - self.root.attributes(topmost=True) - self.root.configure(bg="#1a1a1a", cursor="arrow") - if not self.demo_mode: - self._disable_vt_switching() - - def _setup_verify_window(self) -> None: - """Configure window for post-sick-day workout verification.""" - self.root.geometry("600x400") - self.root.configure(bg="#1a1a1a", cursor="arrow") - self.root.protocol("WM_DELETE_WINDOW", self.close) - def _is_sick_day_log(self) -> bool: """Check if today's workout log is a sick day (not yet verified).""" if not self.log_file.exists(): @@ -219,59 +183,6 @@ class ScreenLocker( ) sys.exit(0) - def _get_local_time_minutes(self) -> int: - """Return current local time as minutes from midnight.""" - now = datetime.now(tz=timezone.utc).astimezone() - return now.hour * 60 + now.minute - - def _is_early_bird_time(self) -> bool: - """Return True if current local time is in the early bird window.""" - minutes = self._get_local_time_minutes() - start = EARLY_BIRD_START_HOUR * 60 - end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE - return start <= minutes < end - - def _is_early_bird_log(self) -> bool: - """Check if today's workout log entry is an early_bird provisional entry.""" - if not self.log_file.exists(): - return False - try: - with self.log_file.open() as f: - logs = json.load(f) - except (OSError, json.JSONDecodeError): - return False - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - entry = logs.get(today) - if entry is None: - return False - return entry.get("workout_data", {}).get("type") == "early_bird" - - def _save_early_bird_log(self) -> None: - """Save an early_bird provisional entry to the workout log.""" - self.workout_data = {"type": "early_bird"} - self.save_workout_log() - - def _try_auto_upgrade_early_bird(self) -> bool: - """Silently upgrade today's early_bird entry if phone shows a workout.""" - try: - status, message = self._verify_phone_workout() - except (OSError, RuntimeError) as exc: - _logger.info("Early bird upgrade phone check failed: %s", exc) - return False - if status != "verified": - _logger.info( - "Early bird upgrade skipped (phone status=%s): %s", - status, - message, - ) - return False - self.workout_data["type"] = "phone_verified" - self.workout_data["source"] = message - self.workout_data["after_early_bird"] = "true" - self._adjust_shutdown_time_later() - self.save_workout_log() - return True - def _try_auto_upgrade_sick_day(self) -> bool: """Silently upgrade today's sick_day entry if phone shows a workout.""" try: @@ -293,34 +204,6 @@ class ScreenLocker( self.save_workout_log() return True - def _setup_demo_close_button(self) -> None: - """Add close button for demo mode.""" - close_btn = tk.Button( - self.root, - text="✕ Close Demo", - font=("Arial", 12), - bg="#ff4444", - fg="white", - command=self.close, - cursor="hand2", - ) - close_btn.place(x=10, y=10) - - def _grab_input(self) -> None: - """Force input focus to the locker window.""" - self.root.update_idletasks() - self.root.focus_force() - if self.demo_mode: - with contextlib.suppress(tk.TclError): - self.root.grab_set() - else: - try: - self.root.grab_set_global() - except tk.TclError: - _logger.warning("Global grab failed, falling back to local grab") - with contextlib.suppress(tk.TclError): - self.root.grab_set() - def clear_container(self) -> None: """Remove all widgets from the main container.""" for widget in self.container.winfo_children(): diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 43818f9..32e1380 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -70,10 +70,10 @@ def mock_subprocess_run() -> Generator[MagicMock]: """ with ( patch( - "python_pkg.screen_locker.screen_lock.shutil.which", + "python_pkg.screen_locker._window_setup.shutil.which", return_value="/usr/bin/setxkbmap", ), - patch("python_pkg.screen_locker.screen_lock.subprocess.run") as mock, + patch("python_pkg.screen_locker._window_setup.subprocess.run") as mock, ): yield mock diff --git a/screen_locker/tests/test_adb_and_phone.py b/screen_locker/tests/test_adb_and_phone.py index b8b76fa..0e8f7e0 100644 --- a/screen_locker/tests/test_adb_and_phone.py +++ b/screen_locker/tests/test_adb_and_phone.py @@ -3,16 +3,12 @@ from __future__ import annotations -import datetime -import json import sqlite3 import subprocess import time from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -import pytest - from python_pkg.screen_locker.screen_lock import STRONGLIFTS_DB_REMOTE from python_pkg.screen_locker.tests.conftest import create_locker @@ -478,379 +474,3 @@ class TestCountTodayWorkouts: conn.close() assert locker._count_today_workouts(db_file) == 2 - - -class TestGetTodayWorkoutDurationMinutes: - """Tests for _get_today_workout_duration_minutes method.""" - - def test_returns_duration_for_today_workout( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns correct duration for a 60-minute workout.""" - 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) - duration_ms = 60 * 60 * 1000 # 60 minutes - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", now_ms, now_ms + duration_ms), - ) - conn.commit() - conn.close() - - result = locker._get_today_workout_duration_minutes(db_file) - assert result == pytest.approx(60.0, abs=1.0) - - def test_returns_zero_for_no_workouts( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0.0 when 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)", - ) - 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._get_today_workout_duration_minutes(db_file) - - def test_sums_multiple_workouts( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test sums durations of multiple 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)", - ) - now_ms = int(time.time() * 1000) - # 30 min + 25 min = 55 min total - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", now_ms, now_ms + 30 * 60 * 1000), - ) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w2", now_ms + 31 * 60 * 1000, now_ms + 56 * 60 * 1000), - ) - conn.commit() - conn.close() - - result = locker._get_today_workout_duration_minutes(db_file) - assert result == pytest.approx(55.0, abs=1.0) - - def test_ignores_invalid_finish( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test ignores workouts where finish <= start.""" - 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) - # finish == start (zero duration - should be excluded by WHERE) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", now_ms, now_ms), - ) - conn.commit() - conn.close() - - assert not locker._get_today_workout_duration_minutes(db_file) - - def test_invalid_db_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0.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._get_today_workout_duration_minutes(bad_file) - - def test_missing_table_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0.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._get_today_workout_duration_minutes(db_file) - - -class TestGetTodayExerciseCount: - """Tests for _get_today_exercise_count method.""" - - def test_counts_exercises( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test counts distinct exercises in today's workouts.""" - 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, exercises TEXT)", - ) - now_ms = int(time.time() * 1000) - exercises_json = json.dumps( - [ - {"id": "squat", "name": "Squat"}, - {"id": "bench_press", "name": "Bench Press"}, - {"id": "squat", "name": "Squat"}, - {"category": "WARMUP"}, - ] - ) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?, ?)", - ("w1", now_ms, now_ms + 3600000, exercises_json), - ) - conn.commit() - conn.close() - - assert locker._get_today_exercise_count(db_file) == 2 - - def test_no_exercises_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0 when no exercises exist.""" - 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, exercises TEXT)", - ) - now_ms = int(time.time() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?, ?)", - ("w1", now_ms, now_ms + 3600000, "[]"), - ) - conn.commit() - conn.close() - - assert not locker._get_today_exercise_count(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 / "bad.db" - bad_file.write_text("not a db") - - assert not locker._get_today_exercise_count(bad_file) - - def test_missing_exercises_column_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0 when workouts table has no exercises column.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "empty.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.commit() - conn.close() - - assert not locker._get_today_exercise_count(db_file) - - def test_null_exercises_json_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0 when exercises JSON is NULL.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "null_ex.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)", - ) - now_ms = int(time.time() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?, ?)", - ("w1", now_ms, now_ms + 3600000, None), - ) - conn.commit() - conn.close() - - assert not locker._get_today_exercise_count(db_file) - - def test_malformed_exercises_json_returns_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns 0 when exercises JSON is malformed.""" - locker = create_locker(mock_tk, tmp_path) - db_file = tmp_path / "bad_json.db" - conn = sqlite3.connect(str(db_file)) - conn.execute( - "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)", - ) - now_ms = int(time.time() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?, ?)", - ("w1", now_ms, now_ms + 3600000, "not valid json"), - ) - conn.commit() - conn.close() - - assert not locker._get_today_exercise_count(db_file) - - -class TestIsWorkoutFinishRecent: - """Tests for _is_workout_finish_recent method.""" - - def test_recent_workout_returns_true( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns True for workout that finished recently.""" - 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)", - ) - # Anchor to local noon to avoid midnight boundary issues: the SQL - # date() filter requires start and now to share the same local date. - local_noon = ( - datetime.datetime.now(tz=datetime.timezone.utc) - .astimezone() - .replace(hour=12, minute=0, second=0, microsecond=0) - ) - local_noon_ms = int(local_noon.timestamp() * 1000) - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", local_noon_ms, local_noon_ms + 3_600_000), - ) - conn.commit() - conn.close() - - assert locker._is_workout_finish_recent(db_file) is True - - def test_old_workout_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False for workout that finished >24 hours ago.""" - 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)", - ) - # Finished 25 hours ago (not "today" in local time either) - now_ms = int(time.time() * 1000) - old_finish = now_ms - 25 * 3600 * 1000 # beyond 24h window - conn.execute( - "INSERT INTO workouts VALUES (?, ?, ?)", - ("w1", old_finish - 3600000, old_finish), - ) - conn.commit() - conn.close() - - assert locker._is_workout_finish_recent(db_file) is False - - def test_no_workouts_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False when no workouts exist.""" - 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)", - ) - conn.commit() - conn.close() - - assert locker._is_workout_finish_recent(db_file) is False - - def test_invalid_db_returns_false( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test returns False for invalid database file.""" - locker = create_locker(mock_tk, tmp_path) - bad_file = tmp_path / "bad.db" - bad_file.write_text("not a db") - - assert locker._is_workout_finish_recent(bad_file) is False diff --git a/screen_locker/tests/test_adb_and_phone_part2.py b/screen_locker/tests/test_adb_and_phone_part2.py new file mode 100644 index 0000000..5e38be1 --- /dev/null +++ b/screen_locker/tests/test_adb_and_phone_part2.py @@ -0,0 +1,394 @@ +"""Tests for ADB commands, phone connection, and database operations.""" +# pylint: disable=protected-access,unused-argument + +from __future__ import annotations + +import datetime +import json +import sqlite3 +import time +from typing import TYPE_CHECKING + +import pytest + +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + from unittest.mock import MagicMock + + +class TestGetTodayWorkoutDurationMinutes: + """Tests for _get_today_workout_duration_minutes method.""" + + def test_returns_duration_for_today_workout( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns correct duration for a 60-minute workout.""" + 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) + duration_ms = 60 * 60 * 1000 # 60 minutes + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", now_ms, now_ms + duration_ms), + ) + conn.commit() + conn.close() + + result = locker._get_today_workout_duration_minutes(db_file) + assert result == pytest.approx(60.0, abs=1.0) + + def test_returns_zero_for_no_workouts( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0.0 when 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)", + ) + 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._get_today_workout_duration_minutes(db_file) + + def test_sums_multiple_workouts( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test sums durations of multiple 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)", + ) + now_ms = int(time.time() * 1000) + # 30 min + 25 min = 55 min total + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", now_ms, now_ms + 30 * 60 * 1000), + ) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w2", now_ms + 31 * 60 * 1000, now_ms + 56 * 60 * 1000), + ) + conn.commit() + conn.close() + + result = locker._get_today_workout_duration_minutes(db_file) + assert result == pytest.approx(55.0, abs=1.0) + + def test_ignores_invalid_finish( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test ignores workouts where finish <= start.""" + 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) + # finish == start (zero duration - should be excluded by WHERE) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", now_ms, now_ms), + ) + conn.commit() + conn.close() + + assert not locker._get_today_workout_duration_minutes(db_file) + + def test_invalid_db_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0.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._get_today_workout_duration_minutes(bad_file) + + def test_missing_table_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0.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._get_today_workout_duration_minutes(db_file) + + +class TestGetTodayExerciseCount: + """Tests for _get_today_exercise_count method.""" + + def test_counts_exercises( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test counts distinct exercises in today's workouts.""" + 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, exercises TEXT)", + ) + now_ms = int(time.time() * 1000) + exercises_json = json.dumps( + [ + {"id": "squat", "name": "Squat"}, + {"id": "bench_press", "name": "Bench Press"}, + {"id": "squat", "name": "Squat"}, + {"category": "WARMUP"}, + ] + ) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?, ?)", + ("w1", now_ms, now_ms + 3600000, exercises_json), + ) + conn.commit() + conn.close() + + assert locker._get_today_exercise_count(db_file) == 2 + + def test_no_exercises_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0 when no exercises exist.""" + 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, exercises TEXT)", + ) + now_ms = int(time.time() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?, ?)", + ("w1", now_ms, now_ms + 3600000, "[]"), + ) + conn.commit() + conn.close() + + assert not locker._get_today_exercise_count(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 / "bad.db" + bad_file.write_text("not a db") + + assert not locker._get_today_exercise_count(bad_file) + + def test_missing_exercises_column_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0 when workouts table has no exercises column.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "empty.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.commit() + conn.close() + + assert not locker._get_today_exercise_count(db_file) + + def test_null_exercises_json_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0 when exercises JSON is NULL.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "null_ex.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)", + ) + now_ms = int(time.time() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?, ?)", + ("w1", now_ms, now_ms + 3600000, None), + ) + conn.commit() + conn.close() + + assert not locker._get_today_exercise_count(db_file) + + def test_malformed_exercises_json_returns_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns 0 when exercises JSON is malformed.""" + locker = create_locker(mock_tk, tmp_path) + db_file = tmp_path / "bad_json.db" + conn = sqlite3.connect(str(db_file)) + conn.execute( + "CREATE TABLE workouts " + "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER, exercises TEXT)", + ) + now_ms = int(time.time() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?, ?)", + ("w1", now_ms, now_ms + 3600000, "not valid json"), + ) + conn.commit() + conn.close() + + assert not locker._get_today_exercise_count(db_file) + + +class TestIsWorkoutFinishRecent: + """Tests for _is_workout_finish_recent method.""" + + def test_recent_workout_returns_true( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns True for workout that finished recently.""" + 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)", + ) + # Anchor to local noon to avoid midnight boundary issues: the SQL + # date() filter requires start and now to share the same local date. + local_noon = ( + datetime.datetime.now(tz=datetime.timezone.utc) + .astimezone() + .replace(hour=12, minute=0, second=0, microsecond=0) + ) + local_noon_ms = int(local_noon.timestamp() * 1000) + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", local_noon_ms, local_noon_ms + 3_600_000), + ) + conn.commit() + conn.close() + + assert locker._is_workout_finish_recent(db_file) is True + + def test_old_workout_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False for workout that finished >24 hours ago.""" + 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)", + ) + # Finished 25 hours ago (not "today" in local time either) + now_ms = int(time.time() * 1000) + old_finish = now_ms - 25 * 3600 * 1000 # beyond 24h window + conn.execute( + "INSERT INTO workouts VALUES (?, ?, ?)", + ("w1", old_finish - 3600000, old_finish), + ) + conn.commit() + conn.close() + + assert locker._is_workout_finish_recent(db_file) is False + + def test_no_workouts_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False when no workouts exist.""" + 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)", + ) + conn.commit() + conn.close() + + assert locker._is_workout_finish_recent(db_file) is False + + def test_invalid_db_returns_false( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test returns False for invalid database file.""" + locker = create_locker(mock_tk, tmp_path) + bad_file = tmp_path / "bad.db" + bad_file.write_text("not a db") + + assert locker._is_workout_finish_recent(bad_file) is False diff --git a/screen_locker/tests/test_init_and_log.py b/screen_locker/tests/test_init_and_log.py index 229eaf3..fbeb824 100644 --- a/screen_locker/tests/test_init_and_log.py +++ b/screen_locker/tests/test_init_and_log.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch import pytest -from python_pkg.screen_locker.screen_lock import ScreenLocker, _assert_not_under_pytest +from python_pkg.screen_locker.screen_lock import _assert_not_under_pytest from python_pkg.screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -340,227 +340,3 @@ class TestSaveWorkoutLog: ): # Should not raise, just log warning locker.save_workout_log() - - -class TestRun: - """Tests for run method.""" - - def test_run_starts_mainloop( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test run starts the tkinter mainloop.""" - locker = create_locker(mock_tk, tmp_path) - - locker.run() - - locker.root.mainloop.assert_called_once() - - -class TestAutoUpgradeSickDay: - """Tests for sick_day → phone_verified silent upgrade helpers.""" - - def test_upgrade_succeeds_when_phone_verified( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Verified phone workout overwrites today's sick_day entry.""" - log_file = tmp_path / "workout_log.json" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = log_file - with ( - patch.object( - locker, - "_verify_phone_workout", - return_value=("verified", "Workout verified! (1 session)"), - ), - patch.object( - locker, - "_adjust_shutdown_time_later", - return_value=True, - ) as mock_adjust, - patch( - "python_pkg.screen_locker.screen_lock.compute_entry_hmac", - return_value="sig", - ), - ): - assert locker._try_auto_upgrade_sick_day() is True - mock_adjust.assert_called_once() - - today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") - with log_file.open() as f: - data: dict[str, Any] = json.load(f) - assert data[today]["workout_data"]["type"] == "phone_verified" - assert data[today]["workout_data"]["after_sick_day"] == "true" - - def test_upgrade_skipped_when_not_verified( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Non-verified statuses leave the sick_day entry untouched.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object( - locker, - "_verify_phone_workout", - return_value=("no_phone", "No phone connected"), - ): - assert locker._try_auto_upgrade_sick_day() is False - assert locker.workout_data == {} - - def test_upgrade_skipped_on_exception( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Transient OSError/RuntimeError during check is non-fatal.""" - locker = create_locker(mock_tk, tmp_path) - with patch.object( - locker, - "_verify_phone_workout", - side_effect=OSError("transient"), - ): - assert locker._try_auto_upgrade_sick_day() is False - - def test_init_exits_when_sick_day_upgrade_succeeds( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Startup exits 0 after a successful silent sick_day upgrade.""" - mock_sys_exit.side_effect = SystemExit(0) - with ( - patch.object( - ScreenLocker, - "_try_auto_upgrade_sick_day", - return_value=True, - ) as mock_upgrade, - pytest.raises(SystemExit), - ): - create_locker(mock_tk, tmp_path, is_sick_day_log=True) - mock_upgrade.assert_called_once() - mock_sys_exit.assert_called_once_with(0) - - -class TestMainEntry: - """Tests for main entry point.""" - - def test_main_demo_mode_default( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test main defaults to demo mode.""" - locker = create_locker(mock_tk, tmp_path, demo_mode=True) - - assert locker.demo_mode is True - - def test_main_production_mode_flag( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test main with --production flag.""" - locker = create_locker(mock_tk, tmp_path, demo_mode=False) - - assert locker.demo_mode is False - - -class TestAdjustShutdownTimeLater: - """Tests for _adjust_shutdown_time_later method.""" - - def test_adjust_shutdown_time_later_success( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test _adjust_shutdown_time_later adds hours successfully.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, "_read_shutdown_config", MagicMock(return_value=(21, 22, 8)) - ) - object.__setattr__( - locker, "_write_shutdown_config", MagicMock(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, - tmp_path: Path, - ) -> None: - """Test _adjust_shutdown_time_later caps hours at 23.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, "_read_shutdown_config", MagicMock(return_value=(22, 23, 8)) - ) - object.__setattr__( - locker, "_write_shutdown_config", MagicMock(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, - tmp_path: Path, - ) -> None: - """Test _adjust_shutdown_time_later returns False if config missing.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, "_read_shutdown_config", MagicMock(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, - tmp_path: Path, - ) -> None: - """Test _adjust_shutdown_time_later handles OSError.""" - locker = create_locker(mock_tk, tmp_path) - object.__setattr__( - locker, - "_read_shutdown_config", - MagicMock(side_effect=OSError("permission denied")), - ) - - result = locker._adjust_shutdown_time_later() - - assert result is False - - -class TestGrabInput: - """Tests for _grab_input method.""" - - def test_production_global_grab_tcl_error( - self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path - ) -> None: - """Test production mode falls back when global grab fails.""" - mock_tk.Tk.return_value.grab_set_global.side_effect = tk.TclError("grab failed") - locker = create_locker(mock_tk, tmp_path, demo_mode=False) - assert locker.demo_mode is False diff --git a/screen_locker/tests/test_init_and_log_part2.py b/screen_locker/tests/test_init_and_log_part2.py new file mode 100644 index 0000000..f6d08c3 --- /dev/null +++ b/screen_locker/tests/test_init_and_log_part2.py @@ -0,0 +1,241 @@ +"""Tests for screen_locker initialization, logging, and basic operations.""" + +from __future__ import annotations + +from datetime import datetime, timezone +import json +import tkinter as tk +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import pytest + +from python_pkg.screen_locker.screen_lock import ScreenLocker +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestRun: + """Tests for run method.""" + + def test_run_starts_mainloop( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test run starts the tkinter mainloop.""" + locker = create_locker(mock_tk, tmp_path) + + locker.run() + + locker.root.mainloop.assert_called_once() + + +class TestAutoUpgradeSickDay: + """Tests for sick_day → phone_verified silent upgrade helpers.""" + + def test_upgrade_succeeds_when_phone_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Verified phone workout overwrites today's sick_day entry.""" + log_file = tmp_path / "workout_log.json" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = log_file + with ( + patch.object( + locker, + "_verify_phone_workout", + return_value=("verified", "Workout verified! (1 session)"), + ), + patch.object( + locker, + "_adjust_shutdown_time_later", + return_value=True, + ) as mock_adjust, + patch( + "python_pkg.screen_locker.screen_lock.compute_entry_hmac", + return_value="sig", + ), + ): + assert locker._try_auto_upgrade_sick_day() is True + mock_adjust.assert_called_once() + + today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") + with log_file.open() as f: + data: dict[str, Any] = json.load(f) + assert data[today]["workout_data"]["type"] == "phone_verified" + assert data[today]["workout_data"]["after_sick_day"] == "true" + + def test_upgrade_skipped_when_not_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Non-verified statuses leave the sick_day entry untouched.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, + "_verify_phone_workout", + return_value=("no_phone", "No phone connected"), + ): + assert locker._try_auto_upgrade_sick_day() is False + assert locker.workout_data == {} + + def test_upgrade_skipped_on_exception( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Transient OSError/RuntimeError during check is non-fatal.""" + locker = create_locker(mock_tk, tmp_path) + with patch.object( + locker, + "_verify_phone_workout", + side_effect=OSError("transient"), + ): + assert locker._try_auto_upgrade_sick_day() is False + + def test_init_exits_when_sick_day_upgrade_succeeds( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Startup exits 0 after a successful silent sick_day upgrade.""" + mock_sys_exit.side_effect = SystemExit(0) + with ( + patch.object( + ScreenLocker, + "_try_auto_upgrade_sick_day", + return_value=True, + ) as mock_upgrade, + pytest.raises(SystemExit), + ): + create_locker(mock_tk, tmp_path, is_sick_day_log=True) + mock_upgrade.assert_called_once() + mock_sys_exit.assert_called_once_with(0) + + +class TestMainEntry: + """Tests for main entry point.""" + + def test_main_demo_mode_default( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test main defaults to demo mode.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=True) + + assert locker.demo_mode is True + + def test_main_production_mode_flag( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test main with --production flag.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + + assert locker.demo_mode is False + + +class TestAdjustShutdownTimeLater: + """Tests for _adjust_shutdown_time_later method.""" + + def test_adjust_shutdown_time_later_success( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later adds hours successfully.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, "_read_shutdown_config", MagicMock(return_value=(21, 22, 8)) + ) + object.__setattr__( + locker, "_write_shutdown_config", MagicMock(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, + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later caps hours at 23.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, "_read_shutdown_config", MagicMock(return_value=(22, 23, 8)) + ) + object.__setattr__( + locker, "_write_shutdown_config", MagicMock(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, + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later returns False if config missing.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, "_read_shutdown_config", MagicMock(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, + tmp_path: Path, + ) -> None: + """Test _adjust_shutdown_time_later handles OSError.""" + locker = create_locker(mock_tk, tmp_path) + object.__setattr__( + locker, + "_read_shutdown_config", + MagicMock(side_effect=OSError("permission denied")), + ) + + result = locker._adjust_shutdown_time_later() + + assert result is False + + +class TestGrabInput: + """Tests for _grab_input method.""" + + def test_production_global_grab_tcl_error( + self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path + ) -> None: + """Test production mode falls back when global grab fails.""" + mock_tk.Tk.return_value.grab_set_global.side_effect = tk.TclError("grab failed") + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + assert locker.demo_mode is False diff --git a/screen_locker/tests/test_phone_check_unlock.py b/screen_locker/tests/test_phone_check_unlock.py index f739fac..f99a0f4 100644 --- a/screen_locker/tests/test_phone_check_unlock.py +++ b/screen_locker/tests/test_phone_check_unlock.py @@ -6,11 +6,6 @@ from __future__ import annotations from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch -from python_pkg.screen_locker._constants import NO_PHONE_EXTRA_LOCKOUT_SECONDS -from python_pkg.screen_locker.screen_lock import ( - PHONE_PENALTY_DELAY_DEMO, - PHONE_PENALTY_DELAY_PRODUCTION, -) from python_pkg.screen_locker.tests.conftest import create_locker if TYPE_CHECKING: @@ -491,166 +486,3 @@ class TestStartPhoneCheck: locker._handle_startup_phone_result.assert_called_once_with( "no_phone", "No phone" ) - - -class TestShowPhonePenalty: - """Tests for _show_phone_penalty and _update_phone_penalty methods.""" - - def test_show_phone_penalty_demo_delay( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test demo mode uses short penalty delay.""" - locker = create_locker(mock_tk, tmp_path, demo_mode=True) - object.__setattr__(locker, "clear_container", MagicMock()) - - locker._show_phone_penalty("test message") - - # _update_phone_penalty is called once, decrementing by 1 - assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_DEMO - 1 - - def test_show_phone_penalty_production_delay( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test production mode uses long penalty delay (base + no-phone bump).""" - locker = create_locker(mock_tk, tmp_path, demo_mode=False) - object.__setattr__(locker, "clear_container", MagicMock()) - - locker._show_phone_penalty("test message") - - expected = PHONE_PENALTY_DELAY_PRODUCTION + NO_PHONE_EXTRA_LOCKOUT_SECONDS - 1 - assert locker.phone_penalty_remaining == expected - - def test_update_phone_penalty_countdown( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test phone penalty countdown decrements.""" - locker = create_locker(mock_tk, tmp_path) - locker.phone_penalty_remaining = 5 - locker.phone_penalty_label = MagicMock() - - locker._update_phone_penalty() - - assert locker.phone_penalty_remaining == 4 - locker.phone_penalty_label.config.assert_called_once_with(text="5") - locker.root.after.assert_called() - - def test_update_phone_penalty_at_zero( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test phone penalty calls done function when timer reaches zero.""" - locker = create_locker(mock_tk, tmp_path) - locker.phone_penalty_remaining = 0 - locker.phone_penalty_label = MagicMock() - mock_done = MagicMock() - locker._phone_penalty_done_fn = mock_done - - locker._update_phone_penalty() - - mock_done.assert_called_once() - - def test_show_phone_penalty_default_callback_shows_retry( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test default phone penalty callback shows retry+sick screen.""" - locker = create_locker(mock_tk, tmp_path, demo_mode=True) - object.__setattr__(locker, "clear_container", MagicMock()) - object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) - - locker._show_phone_penalty("No phone connected") - - # Simulate timer reaching zero by calling the done function - locker._phone_penalty_done_fn() - locker._show_retry_and_sick.assert_called_once_with("No phone connected") - - -class TestUnlockScreenShutdownAdjustment: - """Tests for unlock_screen shutdown time adjustment.""" - - def test_unlock_screen_adjusts_for_phone_verified( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - tmp_path: Path, - ) -> None: - """Test unlock_screen adjusts shutdown for phone-verified workout.""" - locker = create_locker(mock_tk, tmp_path) - locker.log_file = tmp_path / "workout_log.json" - locker.workout_data = {"type": "phone_verified"} - object.__setattr__( - locker, "_adjust_shutdown_time_later", MagicMock(return_value=True) - ) - - locker.unlock_screen() - - locker._adjust_shutdown_time_later.assert_called_once() - - def test_unlock_screen_skips_adjustment_for_sick_day( - self, - mock_tk: MagicMock, - mock_sys_exit: MagicMock, - 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"} - object.__setattr__( - locker, "_adjust_shutdown_time_later", MagicMock(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, - 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 = {} - object.__setattr__( - locker, "_adjust_shutdown_time_later", MagicMock(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, - 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": "phone_verified"} - object.__setattr__( - locker, "_adjust_shutdown_time_later", MagicMock(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() diff --git a/screen_locker/tests/test_phone_check_unlock_part2.py b/screen_locker/tests/test_phone_check_unlock_part2.py new file mode 100644 index 0000000..1035ce3 --- /dev/null +++ b/screen_locker/tests/test_phone_check_unlock_part2.py @@ -0,0 +1,180 @@ +"""Tests for phone workout verification, phone check, and unlock operations.""" +# pylint: disable=protected-access,unused-argument + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock + +from python_pkg.screen_locker._constants import NO_PHONE_EXTRA_LOCKOUT_SECONDS +from python_pkg.screen_locker.screen_lock import ( + PHONE_PENALTY_DELAY_DEMO, + PHONE_PENALTY_DELAY_PRODUCTION, +) +from python_pkg.screen_locker.tests.conftest import create_locker + +if TYPE_CHECKING: + from pathlib import Path + + +class TestShowPhonePenalty: + """Tests for _show_phone_penalty and _update_phone_penalty methods.""" + + def test_show_phone_penalty_demo_delay( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test demo mode uses short penalty delay.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=True) + object.__setattr__(locker, "clear_container", MagicMock()) + + locker._show_phone_penalty("test message") + + # _update_phone_penalty is called once, decrementing by 1 + assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_DEMO - 1 + + def test_show_phone_penalty_production_delay( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test production mode uses long penalty delay (base + no-phone bump).""" + locker = create_locker(mock_tk, tmp_path, demo_mode=False) + object.__setattr__(locker, "clear_container", MagicMock()) + + locker._show_phone_penalty("test message") + + expected = PHONE_PENALTY_DELAY_PRODUCTION + NO_PHONE_EXTRA_LOCKOUT_SECONDS - 1 + assert locker.phone_penalty_remaining == expected + + def test_update_phone_penalty_countdown( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test phone penalty countdown decrements.""" + locker = create_locker(mock_tk, tmp_path) + locker.phone_penalty_remaining = 5 + locker.phone_penalty_label = MagicMock() + + locker._update_phone_penalty() + + assert locker.phone_penalty_remaining == 4 + locker.phone_penalty_label.config.assert_called_once_with(text="5") + locker.root.after.assert_called() + + def test_update_phone_penalty_at_zero( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test phone penalty calls done function when timer reaches zero.""" + locker = create_locker(mock_tk, tmp_path) + locker.phone_penalty_remaining = 0 + locker.phone_penalty_label = MagicMock() + mock_done = MagicMock() + locker._phone_penalty_done_fn = mock_done + + locker._update_phone_penalty() + + mock_done.assert_called_once() + + def test_show_phone_penalty_default_callback_shows_retry( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test default phone penalty callback shows retry+sick screen.""" + locker = create_locker(mock_tk, tmp_path, demo_mode=True) + object.__setattr__(locker, "clear_container", MagicMock()) + object.__setattr__(locker, "_show_retry_and_sick", MagicMock()) + + locker._show_phone_penalty("No phone connected") + + # Simulate timer reaching zero by calling the done function + locker._phone_penalty_done_fn() + locker._show_retry_and_sick.assert_called_once_with("No phone connected") + + +class TestUnlockScreenShutdownAdjustment: + """Tests for unlock_screen shutdown time adjustment.""" + + def test_unlock_screen_adjusts_for_phone_verified( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + tmp_path: Path, + ) -> None: + """Test unlock_screen adjusts shutdown for phone-verified workout.""" + locker = create_locker(mock_tk, tmp_path) + locker.log_file = tmp_path / "workout_log.json" + locker.workout_data = {"type": "phone_verified"} + object.__setattr__( + locker, "_adjust_shutdown_time_later", MagicMock(return_value=True) + ) + + locker.unlock_screen() + + locker._adjust_shutdown_time_later.assert_called_once() + + def test_unlock_screen_skips_adjustment_for_sick_day( + self, + mock_tk: MagicMock, + mock_sys_exit: MagicMock, + 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"} + object.__setattr__( + locker, "_adjust_shutdown_time_later", MagicMock(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, + 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 = {} + object.__setattr__( + locker, "_adjust_shutdown_time_later", MagicMock(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, + 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": "phone_verified"} + object.__setattr__( + locker, "_adjust_shutdown_time_later", MagicMock(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() diff --git a/screen_locker/tests/test_vt_switching.py b/screen_locker/tests/test_vt_switching.py index af7e4d1..8001f80 100644 --- a/screen_locker/tests/test_vt_switching.py +++ b/screen_locker/tests/test_vt_switching.py @@ -109,7 +109,7 @@ class TestVTSwitching: ) -> None: """No crash and no subprocess call when setxkbmap is not installed.""" with patch( - "python_pkg.screen_locker.screen_lock.shutil.which", + "python_pkg.screen_locker._window_setup.shutil.which", return_value=None, ): create_locker(mock_tk, tmp_path, demo_mode=False) @@ -128,7 +128,7 @@ class TestVTSwitching: mock_subprocess_run.reset_mock() with patch( - "python_pkg.screen_locker.screen_lock.shutil.which", + "python_pkg.screen_locker._window_setup.shutil.which", return_value=None, ): locker.close()