Split modules, fix tests, fix pre-commit batching

- steam_backlog_enforcer: extract _hltb_search.py and _scanning_confidence.py;
  split oversized test files into *_part2/3/4.py
- screen_locker: extract _early_bird.py and _window_setup.py from screen_lock.py;
  fix patch targets in tests (screen_lock.* -> _window_setup.*)
- wake_alarm: use shutil.which('xset') to avoid S607; add TestDisplayHelpers tests
- linux_configuration/usage_report: split into _parsing.py and _types.py;
  add bin/__init__.py (INP001); fix RUF002 (× -> x)
- pre-commit: add require_serial: true to pytest-coverage hook to prevent
  file batching across 24 CPU cores (was causing 12 parallel partial-coverage runs)
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-22 22:48:28 +02:00
parent 6c25a36820
commit e34b513ced
11 changed files with 976 additions and 898 deletions

View File

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

View File

@ -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()

View File

@ -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():

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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()

View File

@ -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()

View File

@ -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()