mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 13:43:37 +02:00
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:
parent
6c25a36820
commit
e34b513ced
72
screen_locker/_early_bird.py
Normal file
72
screen_locker/_early_bird.py
Normal 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
|
||||
80
screen_locker/_window_setup.py
Normal file
80
screen_locker/_window_setup.py
Normal 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()
|
||||
@ -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():
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
394
screen_locker/tests/test_adb_and_phone_part2.py
Normal file
394
screen_locker/tests/test_adb_and_phone_part2.py
Normal 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
|
||||
@ -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
|
||||
|
||||
241
screen_locker/tests/test_init_and_log_part2.py
Normal file
241
screen_locker/tests/test_init_and_log_part2.py
Normal 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
|
||||
@ -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()
|
||||
|
||||
180
screen_locker/tests/test_phone_check_unlock_part2.py
Normal file
180
screen_locker/tests/test_phone_check_unlock_part2.py
Normal 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()
|
||||
@ -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()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user