mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 15:23:02 +02:00
feat: screen locker made even stronger
This commit is contained in:
parent
825a380b00
commit
542ba928d9
@ -11,8 +11,11 @@ from datetime import datetime, timezone
|
|||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import shutil
|
||||||
|
import sqlite3
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
import tempfile
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
@ -30,6 +33,14 @@ MAX_SETS = 20
|
|||||||
MAX_REPS = 100
|
MAX_REPS = 100
|
||||||
MAX_WEIGHT_KG = 500
|
MAX_WEIGHT_KG = 500
|
||||||
SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick
|
SICK_LOCKOUT_SECONDS = 120 # 2 minutes wait when sick
|
||||||
|
SUBMIT_DELAY_DEMO = 30
|
||||||
|
SUBMIT_DELAY_PRODUCTION = 180
|
||||||
|
PHONE_PENALTY_DELAY_DEMO = 10
|
||||||
|
PHONE_PENALTY_DELAY_PRODUCTION = 600
|
||||||
|
ADB_TIMEOUT = 15
|
||||||
|
STRONGLIFTS_DB_REMOTE = (
|
||||||
|
"/data/data/com.stronglifts.app/databases/StrongLifts-Database-3"
|
||||||
|
)
|
||||||
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
SHUTDOWN_CONFIG_FILE = Path("/etc/shutdown-schedule.conf")
|
||||||
# Helper script path (relative to this file)
|
# Helper script path (relative to this file)
|
||||||
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
ADJUST_SHUTDOWN_SCRIPT = Path(__file__).resolve().parent / "adjust_shutdown_schedule.sh"
|
||||||
@ -65,7 +76,7 @@ class ScreenLocker:
|
|||||||
self._setup_demo_close_button()
|
self._setup_demo_close_button()
|
||||||
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
self.container = tk.Frame(self.root, bg="#1a1a1a")
|
||||||
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
self.container.place(relx=0.5, rely=0.5, anchor="center")
|
||||||
self.ask_workout_done()
|
self._start_phone_check()
|
||||||
self._grab_input()
|
self._grab_input()
|
||||||
|
|
||||||
def _setup_window(self) -> None:
|
def _setup_window(self) -> None:
|
||||||
@ -232,7 +243,9 @@ class ScreenLocker:
|
|||||||
self.timer_label = self._text("", font_size=16, color="#ffaa00")
|
self.timer_label = self._text("", font_size=16, color="#ffaa00")
|
||||||
self.submit_btn = self._disabled_submit_button()
|
self.submit_btn = self._disabled_submit_button()
|
||||||
self._back_button(back_command)
|
self._back_button(back_command)
|
||||||
self.submit_unlock_time = 30
|
self.submit_unlock_time = (
|
||||||
|
SUBMIT_DELAY_DEMO if self.demo_mode else SUBMIT_DELAY_PRODUCTION
|
||||||
|
)
|
||||||
self.entries_to_check = entries
|
self.entries_to_check = entries
|
||||||
self.submit_command = verify_command
|
self.submit_command = verify_command
|
||||||
self.update_submit_timer()
|
self.update_submit_timer()
|
||||||
@ -259,6 +272,52 @@ class ScreenLocker:
|
|||||||
command=self.ask_if_sick,
|
command=self.ask_if_sick,
|
||||||
).pack(side="left", padx=20)
|
).pack(side="left", padx=20)
|
||||||
|
|
||||||
|
def _start_phone_check(self) -> None:
|
||||||
|
"""Check phone for today's workout immediately at startup."""
|
||||||
|
self.clear_container()
|
||||||
|
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
|
||||||
|
self._text("Looking for today's workout in StrongLifts...", font_size=18)
|
||||||
|
self.root.after(100, self._handle_startup_phone_result)
|
||||||
|
|
||||||
|
def _handle_startup_phone_result(self) -> None:
|
||||||
|
"""Route to appropriate screen based on startup phone check result."""
|
||||||
|
status, message = self._verify_phone_workout()
|
||||||
|
if status == "verified":
|
||||||
|
self.clear_container()
|
||||||
|
self._label(
|
||||||
|
"\u2713 Workout Verified!", font_size=36, color="#00cc00", pady=20
|
||||||
|
)
|
||||||
|
self._text(message, color="#88ff88")
|
||||||
|
self._text("\nLog the details below to unlock.", font_size=18)
|
||||||
|
self.root.after(1500, self.ask_workout_type)
|
||||||
|
elif status == "not_verified":
|
||||||
|
self.clear_container()
|
||||||
|
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
|
||||||
|
self._text(
|
||||||
|
f"\u274c {message}\n\n"
|
||||||
|
"StrongLifts shows no workout today.\n"
|
||||||
|
"Go do your workout first!",
|
||||||
|
color="#ffaa00",
|
||||||
|
)
|
||||||
|
frame = self._button_row()
|
||||||
|
self._button(
|
||||||
|
frame,
|
||||||
|
"TRY AGAIN",
|
||||||
|
bg="#0066cc",
|
||||||
|
command=self._start_phone_check,
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=10)
|
||||||
|
self._button(
|
||||||
|
frame,
|
||||||
|
"I'm sick",
|
||||||
|
bg="#cc6600",
|
||||||
|
command=self.ask_if_sick,
|
||||||
|
width=12,
|
||||||
|
).pack(side="left", padx=10)
|
||||||
|
else:
|
||||||
|
# no_phone or error — penalty timer, then proceed to logging form
|
||||||
|
self._show_phone_penalty(message, on_done=self.ask_workout_done)
|
||||||
|
|
||||||
def ask_if_sick(self) -> None:
|
def ask_if_sick(self) -> None:
|
||||||
"""Display sick day question dialog."""
|
"""Display sick day question dialog."""
|
||||||
self.clear_container()
|
self.clear_container()
|
||||||
@ -694,7 +753,7 @@ class ScreenLocker:
|
|||||||
self.workout_data["distance_km"] = str(distance)
|
self.workout_data["distance_km"] = str(distance)
|
||||||
self.workout_data["time_minutes"] = str(time_mins)
|
self.workout_data["time_minutes"] = str(time_mins)
|
||||||
self.workout_data["pace_min_per_km"] = str(pace)
|
self.workout_data["pace_min_per_km"] = str(pace)
|
||||||
self.unlock_screen()
|
self._attempt_unlock()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Strength workout
|
# Strength workout
|
||||||
@ -852,8 +911,165 @@ class ScreenLocker:
|
|||||||
self.show_error(total_err)
|
self.show_error(total_err)
|
||||||
return
|
return
|
||||||
self._store_strength_data(exercises, sets, reps, weights, total_weight)
|
self._store_strength_data(exercises, sets, reps, weights, total_weight)
|
||||||
|
self._attempt_unlock()
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Phone workout verification via ADB + StrongLifts DB
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _run_adb(self, args: list[str]) -> tuple[bool, str]:
|
||||||
|
"""Run an ADB command and return success flag and stdout."""
|
||||||
|
adb = shutil.which("adb") or "adb"
|
||||||
|
try:
|
||||||
|
result = subprocess.run(
|
||||||
|
[adb, *args],
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
timeout=ADB_TIMEOUT,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
except (FileNotFoundError, OSError) as exc:
|
||||||
|
_logger.warning("ADB not available: %s", exc)
|
||||||
|
return False, ""
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
_logger.warning("ADB command timed out: %s", args)
|
||||||
|
return False, ""
|
||||||
|
return result.returncode == 0, result.stdout
|
||||||
|
|
||||||
|
def _adb_shell(
|
||||||
|
self,
|
||||||
|
command: str,
|
||||||
|
*,
|
||||||
|
root: bool = False,
|
||||||
|
) -> tuple[bool, str]:
|
||||||
|
"""Run a shell command on the connected Android device."""
|
||||||
|
if root:
|
||||||
|
return self._run_adb(["shell", "su", "-c", command])
|
||||||
|
return self._run_adb(["shell", command])
|
||||||
|
|
||||||
|
def _is_phone_connected(self) -> bool:
|
||||||
|
"""Check if an Android device is connected via ADB."""
|
||||||
|
success, output = self._run_adb(["devices"])
|
||||||
|
if not success:
|
||||||
|
return False
|
||||||
|
lines = output.strip().split("\n")[1:]
|
||||||
|
return any("device" in line and "offline" not in line for line in lines)
|
||||||
|
|
||||||
|
def _pull_stronglifts_db(self) -> Path | None:
|
||||||
|
"""Pull StrongLifts database from phone to a local temp file.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Path to the local copy, or None on failure.
|
||||||
|
"""
|
||||||
|
tmp = Path(tempfile.gettempdir()) / "stronglifts_check.db"
|
||||||
|
success, _ = self._adb_shell(
|
||||||
|
f"cat '{STRONGLIFTS_DB_REMOTE}' > /sdcard/_sl_tmp.db",
|
||||||
|
root=True,
|
||||||
|
)
|
||||||
|
if not success:
|
||||||
|
return None
|
||||||
|
ok, _ = self._run_adb(["pull", "/sdcard/_sl_tmp.db", str(tmp)])
|
||||||
|
if not ok:
|
||||||
|
return None
|
||||||
|
return tmp
|
||||||
|
|
||||||
|
def _count_today_workouts(self, db_path: Path) -> int:
|
||||||
|
"""Count today's workouts in a local copy of StrongLifts DB.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
db_path: Path to the locally-pulled StrongLifts database.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of workouts started today (local time).
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
conn = sqlite3.connect(str(db_path))
|
||||||
|
try:
|
||||||
|
cursor = conn.execute(
|
||||||
|
"SELECT COUNT(*) FROM workouts "
|
||||||
|
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
||||||
|
"= date('now', 'localtime')",
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return int(row[0]) if row else 0
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
except (sqlite3.Error, ValueError, TypeError):
|
||||||
|
_logger.warning("Failed to query StrongLifts database")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
def _verify_phone_workout(self) -> tuple[str, str]:
|
||||||
|
"""Verify workout was recorded in StrongLifts on the phone.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Tuple of (status, message) where status is one of:
|
||||||
|
- "verified": Workout confirmed on phone.
|
||||||
|
- "not_verified": Phone connected but no workout found.
|
||||||
|
- "no_phone": No phone connected via ADB.
|
||||||
|
- "error": Could not access StrongLifts database.
|
||||||
|
"""
|
||||||
|
if not self._is_phone_connected():
|
||||||
|
return "no_phone", "No phone connected via ADB"
|
||||||
|
local_db = self._pull_stronglifts_db()
|
||||||
|
if local_db is None:
|
||||||
|
return "error", "StrongLifts database not found on phone"
|
||||||
|
count = self._count_today_workouts(local_db)
|
||||||
|
if count > 0:
|
||||||
|
return (
|
||||||
|
"verified",
|
||||||
|
f"Workout verified! ({count} session(s) found on phone)",
|
||||||
|
)
|
||||||
|
return "not_verified", "No workout found on phone today"
|
||||||
|
|
||||||
|
def _attempt_unlock(self) -> None:
|
||||||
|
"""Unlock screen after workout form submission."""
|
||||||
self.unlock_screen()
|
self.unlock_screen()
|
||||||
|
|
||||||
|
def _show_phone_penalty(
|
||||||
|
self, message: str, *, on_done: Callable[[], None] | None = None
|
||||||
|
) -> None:
|
||||||
|
"""Show penalty countdown when phone verification is unavailable."""
|
||||||
|
self.clear_container()
|
||||||
|
self._phone_penalty_done_fn: Callable[[], None] = (
|
||||||
|
on_done if on_done is not None else self.unlock_screen
|
||||||
|
)
|
||||||
|
delay = (
|
||||||
|
PHONE_PENALTY_DELAY_DEMO
|
||||||
|
if self.demo_mode
|
||||||
|
else PHONE_PENALTY_DELAY_PRODUCTION
|
||||||
|
)
|
||||||
|
self._label(
|
||||||
|
"Cannot Verify Workout",
|
||||||
|
font_size=36,
|
||||||
|
color="#ff8800",
|
||||||
|
pady=20,
|
||||||
|
)
|
||||||
|
self._text(message, color="#ffaa00")
|
||||||
|
self._text(
|
||||||
|
"Connect phone via ADB to skip this wait,\n"
|
||||||
|
"or wait for the penalty timer.\n\n"
|
||||||
|
"Note: Phone must be rooted and StrongLifts installed.",
|
||||||
|
font_size=18,
|
||||||
|
)
|
||||||
|
self.phone_penalty_remaining = delay
|
||||||
|
self.phone_penalty_label = self._label(
|
||||||
|
str(delay),
|
||||||
|
font_size=80,
|
||||||
|
pady=20,
|
||||||
|
)
|
||||||
|
self._update_phone_penalty()
|
||||||
|
|
||||||
|
def _update_phone_penalty(self) -> None:
|
||||||
|
"""Update phone penalty countdown."""
|
||||||
|
if self.phone_penalty_remaining > 0:
|
||||||
|
self.phone_penalty_label.config(
|
||||||
|
text=str(self.phone_penalty_remaining),
|
||||||
|
)
|
||||||
|
self.phone_penalty_remaining -= 1
|
||||||
|
self.root.after(1000, self._update_phone_penalty)
|
||||||
|
else:
|
||||||
|
self._phone_penalty_done_fn()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Submit timer and entry checking
|
# Submit timer and entry checking
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|||||||
@ -13,6 +13,8 @@ from __future__ import annotations
|
|||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
import sqlite3
|
||||||
|
import subprocess
|
||||||
import tkinter as tk
|
import tkinter as tk
|
||||||
from typing import TYPE_CHECKING, Any, NamedTuple
|
from typing import TYPE_CHECKING, Any, NamedTuple
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
@ -27,6 +29,11 @@ from python_pkg.screen_locker.screen_lock import (
|
|||||||
MAX_TIME_MINUTES,
|
MAX_TIME_MINUTES,
|
||||||
MAX_WEIGHT_KG,
|
MAX_WEIGHT_KG,
|
||||||
MIN_EXERCISE_NAME_LEN,
|
MIN_EXERCISE_NAME_LEN,
|
||||||
|
PHONE_PENALTY_DELAY_DEMO,
|
||||||
|
PHONE_PENALTY_DELAY_PRODUCTION,
|
||||||
|
STRONGLIFTS_DB_REMOTE,
|
||||||
|
SUBMIT_DELAY_DEMO,
|
||||||
|
SUBMIT_DELAY_PRODUCTION,
|
||||||
ScreenLocker,
|
ScreenLocker,
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -365,16 +372,16 @@ class TestVerifyRunningData:
|
|||||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test valid running data unlocks screen."""
|
"""Test valid running data triggers unlock attempt."""
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
setup_running_entries(locker, RunningData("5", "25", "5"))
|
setup_running_entries(locker, RunningData("5", "25", "5"))
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
locker.workout_data = {"type": "running"}
|
locker.workout_data = {"type": "running"}
|
||||||
locker.unlock_screen = MagicMock() # type: ignore[method-assign]
|
locker._attempt_unlock = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
locker.verify_running_data()
|
locker.verify_running_data()
|
||||||
|
|
||||||
locker.unlock_screen.assert_called_once()
|
locker._attempt_unlock.assert_called_once()
|
||||||
|
|
||||||
def test_invalid_distance_zero(
|
def test_invalid_distance_zero(
|
||||||
self,
|
self,
|
||||||
@ -515,16 +522,16 @@ class TestVerifyStrengthData:
|
|||||||
mock_sys_exit: MagicMock, # noqa: ARG002
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test valid strength data unlocks screen."""
|
"""Test valid strength data triggers unlock attempt."""
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
setup_strength_entries(locker, StrengthData("Squat", "3", "10", "50", "1500"))
|
setup_strength_entries(locker, StrengthData("Squat", "3", "10", "50", "1500"))
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
locker.workout_data = {"type": "strength"}
|
locker.workout_data = {"type": "strength"}
|
||||||
locker.unlock_screen = MagicMock() # type: ignore[method-assign]
|
locker._attempt_unlock = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
locker.verify_strength_data()
|
locker.verify_strength_data()
|
||||||
|
|
||||||
locker.unlock_screen.assert_called_once()
|
locker._attempt_unlock.assert_called_once()
|
||||||
|
|
||||||
def test_valid_multiple_exercises(
|
def test_valid_multiple_exercises(
|
||||||
self,
|
self,
|
||||||
@ -540,11 +547,11 @@ class TestVerifyStrengthData:
|
|||||||
)
|
)
|
||||||
locker.log_file = tmp_path / "workout_log.json"
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
locker.workout_data = {"type": "strength"}
|
locker.workout_data = {"type": "strength"}
|
||||||
locker.unlock_screen = MagicMock() # type: ignore[method-assign]
|
locker._attempt_unlock = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
locker.verify_strength_data()
|
locker.verify_strength_data()
|
||||||
|
|
||||||
locker.unlock_screen.assert_called_once()
|
locker._attempt_unlock.assert_called_once()
|
||||||
|
|
||||||
def test_mismatched_list_lengths(
|
def test_mismatched_list_lengths(
|
||||||
self,
|
self,
|
||||||
@ -1081,7 +1088,7 @@ class TestAskRunningDetails:
|
|||||||
|
|
||||||
locker.ask_running_details()
|
locker.ask_running_details()
|
||||||
|
|
||||||
assert locker.submit_unlock_time == 30
|
assert locker.submit_unlock_time == SUBMIT_DELAY_DEMO
|
||||||
locker.update_submit_timer.assert_called_once()
|
locker.update_submit_timer.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
@ -1138,9 +1145,24 @@ class TestAskStrengthDetails:
|
|||||||
|
|
||||||
locker.ask_strength_details()
|
locker.ask_strength_details()
|
||||||
|
|
||||||
assert locker.submit_unlock_time == 30
|
assert locker.submit_unlock_time == SUBMIT_DELAY_DEMO
|
||||||
locker.update_submit_timer.assert_called_once()
|
locker.update_submit_timer.assert_called_once()
|
||||||
|
|
||||||
|
def test_ask_strength_details_production_timer(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test production mode uses longer submit delay."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
||||||
|
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||||
|
locker.update_submit_timer = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
locker.ask_strength_details()
|
||||||
|
|
||||||
|
assert locker.submit_unlock_time == SUBMIT_DELAY_PRODUCTION
|
||||||
|
|
||||||
|
|
||||||
class TestAskWorkoutDone:
|
class TestAskWorkoutDone:
|
||||||
"""Tests for ask_workout_done method."""
|
"""Tests for ask_workout_done method."""
|
||||||
@ -1239,6 +1261,666 @@ class TestAdjustShutdownTimeLater:
|
|||||||
assert result is False
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestRunAdb:
|
||||||
|
"""Tests for _run_adb ADB command execution."""
|
||||||
|
|
||||||
|
def test_run_adb_success(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test successful ADB command."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
mock_result = MagicMock(returncode=0, stdout="ok\n")
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.subprocess.run",
|
||||||
|
return_value=mock_result,
|
||||||
|
) as mock_run:
|
||||||
|
success, output = locker._run_adb(["devices"])
|
||||||
|
|
||||||
|
assert success is True
|
||||||
|
assert output == "ok\n"
|
||||||
|
mock_run.assert_called_once()
|
||||||
|
|
||||||
|
def test_run_adb_failure(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test failed ADB command."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
mock_result = MagicMock(returncode=1, stdout="")
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.subprocess.run",
|
||||||
|
return_value=mock_result,
|
||||||
|
):
|
||||||
|
success, output = locker._run_adb(["devices"])
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
|
||||||
|
def test_run_adb_not_found(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test ADB binary not found."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.subprocess.run",
|
||||||
|
side_effect=FileNotFoundError("adb not found"),
|
||||||
|
):
|
||||||
|
success, output = locker._run_adb(["devices"])
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert output == ""
|
||||||
|
|
||||||
|
def test_run_adb_oserror(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test ADB OSError."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.subprocess.run",
|
||||||
|
side_effect=OSError("permission denied"),
|
||||||
|
):
|
||||||
|
success, output = locker._run_adb(["devices"])
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert output == ""
|
||||||
|
|
||||||
|
def test_run_adb_timeout(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test ADB command timeout."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch(
|
||||||
|
"python_pkg.screen_locker.screen_lock.subprocess.run",
|
||||||
|
side_effect=subprocess.TimeoutExpired("adb", 15),
|
||||||
|
):
|
||||||
|
success, output = locker._run_adb(["devices"])
|
||||||
|
|
||||||
|
assert success is False
|
||||||
|
assert output == ""
|
||||||
|
|
||||||
|
|
||||||
|
class TestAdbShell:
|
||||||
|
"""Tests for _adb_shell method."""
|
||||||
|
|
||||||
|
def test_adb_shell_no_root(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test ADB shell without root."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, "output"),
|
||||||
|
)
|
||||||
|
|
||||||
|
success, output = locker._adb_shell("ls /sdcard")
|
||||||
|
|
||||||
|
locker._run_adb.assert_called_once_with(["shell", "ls /sdcard"])
|
||||||
|
assert success is True
|
||||||
|
assert output == "output"
|
||||||
|
|
||||||
|
def test_adb_shell_with_root(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test ADB shell with root."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, "output"),
|
||||||
|
)
|
||||||
|
|
||||||
|
success, output = locker._adb_shell("ls /data", root=True)
|
||||||
|
|
||||||
|
locker._run_adb.assert_called_once_with(
|
||||||
|
["shell", "su", "-c", "ls /data"],
|
||||||
|
)
|
||||||
|
assert success is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestIsPhoneConnected:
|
||||||
|
"""Tests for _is_phone_connected method."""
|
||||||
|
|
||||||
|
def test_phone_connected(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test phone detected as connected."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(
|
||||||
|
True,
|
||||||
|
"List of devices attached\nABC123\tdevice\n\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert locker._is_phone_connected() is True
|
||||||
|
|
||||||
|
def test_phone_not_connected(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test no phone connected."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, "List of devices attached\n\n"),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert locker._is_phone_connected() is False
|
||||||
|
|
||||||
|
def test_phone_offline(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test phone connected but offline."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(
|
||||||
|
True,
|
||||||
|
"List of devices attached\nABC123\toffline\n\n",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert locker._is_phone_connected() is False
|
||||||
|
|
||||||
|
def test_adb_command_fails(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test ADB command failure."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(False, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert locker._is_phone_connected() is False
|
||||||
|
|
||||||
|
|
||||||
|
class TestFindHealthConnectDb:
|
||||||
|
"""Tests for _pull_stronglifts_db method."""
|
||||||
|
|
||||||
|
def test_db_pulled_successfully(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test StrongLifts DB pulled from device."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._adb_shell = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, ""),
|
||||||
|
)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
result = locker._pull_stronglifts_db()
|
||||||
|
|
||||||
|
assert result is not None
|
||||||
|
locker._adb_shell.assert_called_once()
|
||||||
|
locker._run_adb.assert_called_once()
|
||||||
|
call_args = locker._run_adb.call_args[0][0]
|
||||||
|
assert call_args[0] == "pull"
|
||||||
|
|
||||||
|
def test_db_cat_fails(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test returns None when cat command fails."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._adb_shell = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(False, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert locker._pull_stronglifts_db() is None
|
||||||
|
|
||||||
|
def test_db_pull_fails(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test returns None when adb pull fails."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._adb_shell = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, ""),
|
||||||
|
)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(False, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
assert locker._pull_stronglifts_db() is None
|
||||||
|
|
||||||
|
def test_db_uses_correct_remote_path(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test uses the correct StrongLifts DB remote path."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._adb_shell = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, ""),
|
||||||
|
)
|
||||||
|
locker._run_adb = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=(True, ""),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._pull_stronglifts_db()
|
||||||
|
|
||||||
|
shell_cmd = locker._adb_shell.call_args[0][0]
|
||||||
|
assert STRONGLIFTS_DB_REMOTE in shell_cmd
|
||||||
|
|
||||||
|
|
||||||
|
class TestCountTodayWorkouts:
|
||||||
|
"""Tests for _count_today_workouts method."""
|
||||||
|
|
||||||
|
def test_workouts_found_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test workouts found today."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
db_file = tmp_path / "sl_test.db"
|
||||||
|
conn = sqlite3.connect(str(db_file))
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE workouts "
|
||||||
|
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
||||||
|
)
|
||||||
|
# Insert a workout with today's timestamp (ms)
|
||||||
|
import time
|
||||||
|
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workouts VALUES (?, ?, ?)",
|
||||||
|
("w1", now_ms, now_ms + 3600000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert locker._count_today_workouts(db_file) == 1
|
||||||
|
|
||||||
|
def test_no_workouts_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test no workouts today."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
db_file = tmp_path / "sl_test.db"
|
||||||
|
conn = sqlite3.connect(str(db_file))
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE workouts "
|
||||||
|
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
||||||
|
)
|
||||||
|
# Insert a workout from yesterday (24h+ ago)
|
||||||
|
import time
|
||||||
|
|
||||||
|
yesterday_ms = int((time.time() - 200000) * 1000)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workouts VALUES (?, ?, ?)",
|
||||||
|
("w1", yesterday_ms, yesterday_ms + 3600000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert locker._count_today_workouts(db_file) == 0
|
||||||
|
|
||||||
|
def test_invalid_db_returns_zero(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test returns 0 for invalid database file."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
bad_file = tmp_path / "not_a_db.db"
|
||||||
|
bad_file.write_text("not a database")
|
||||||
|
|
||||||
|
assert locker._count_today_workouts(bad_file) == 0
|
||||||
|
|
||||||
|
def test_missing_table_returns_zero(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test returns 0 when workouts table doesn't exist."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
db_file = tmp_path / "empty.db"
|
||||||
|
conn = sqlite3.connect(str(db_file))
|
||||||
|
conn.execute("CREATE TABLE other (id TEXT)")
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert locker._count_today_workouts(db_file) == 0
|
||||||
|
|
||||||
|
def test_multiple_workouts_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test counts multiple workouts today correctly."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
db_file = tmp_path / "sl_test.db"
|
||||||
|
conn = sqlite3.connect(str(db_file))
|
||||||
|
conn.execute(
|
||||||
|
"CREATE TABLE workouts "
|
||||||
|
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
||||||
|
)
|
||||||
|
import time
|
||||||
|
|
||||||
|
now_ms = int(time.time() * 1000)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workouts VALUES (?, ?, ?)",
|
||||||
|
("w1", now_ms, now_ms + 3600000),
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workouts VALUES (?, ?, ?)",
|
||||||
|
("w2", now_ms + 100000, now_ms + 3700000),
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
assert locker._count_today_workouts(db_file) == 2
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyPhoneWorkout:
|
||||||
|
"""Tests for _verify_phone_workout method."""
|
||||||
|
|
||||||
|
def test_verified(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test workout verified on phone."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._is_phone_connected = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=tmp_path / "sl.db",
|
||||||
|
)
|
||||||
|
locker._count_today_workouts = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=2,
|
||||||
|
)
|
||||||
|
|
||||||
|
status, message = locker._verify_phone_workout()
|
||||||
|
|
||||||
|
assert status == "verified"
|
||||||
|
assert "2 session" in message
|
||||||
|
|
||||||
|
def test_not_verified(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test no workout found on phone."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._is_phone_connected = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=tmp_path / "sl.db",
|
||||||
|
)
|
||||||
|
locker._count_today_workouts = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=0,
|
||||||
|
)
|
||||||
|
|
||||||
|
status, message = locker._verify_phone_workout()
|
||||||
|
|
||||||
|
assert status == "not_verified"
|
||||||
|
assert "No workout" in message
|
||||||
|
|
||||||
|
def test_no_phone(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test no phone connected."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._is_phone_connected = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=False,
|
||||||
|
)
|
||||||
|
|
||||||
|
status, _ = locker._verify_phone_workout()
|
||||||
|
|
||||||
|
assert status == "no_phone"
|
||||||
|
|
||||||
|
def test_error_no_db(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test error when StrongLifts DB cannot be pulled."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._is_phone_connected = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=True,
|
||||||
|
)
|
||||||
|
locker._pull_stronglifts_db = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
status, message = locker._verify_phone_workout()
|
||||||
|
|
||||||
|
assert status == "error"
|
||||||
|
assert "database" in message.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestStartPhoneCheck:
|
||||||
|
"""Tests for _start_phone_check and _handle_startup_phone_result."""
|
||||||
|
|
||||||
|
def test_start_phone_check_shows_checking_screen(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test _start_phone_check shows checking message and schedules check."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
locker._start_phone_check()
|
||||||
|
|
||||||
|
locker.clear_container.assert_called()
|
||||||
|
locker.root.after.assert_called() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def test_handle_startup_verified_shows_success_then_form(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test verified result shows success and schedules form."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||||
|
locker._verify_phone_workout = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=("verified", "Workout verified! (1 session)"),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._handle_startup_phone_result()
|
||||||
|
|
||||||
|
locker.clear_container.assert_called()
|
||||||
|
locker.root.after.assert_called() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def test_handle_startup_not_verified_shows_block(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test not_verified result shows blocking screen with buttons."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||||
|
locker._verify_phone_workout = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=("not_verified", "No workout found on phone today"),
|
||||||
|
)
|
||||||
|
|
||||||
|
locker._handle_startup_phone_result()
|
||||||
|
|
||||||
|
locker.clear_container.assert_called()
|
||||||
|
|
||||||
|
def test_handle_startup_no_phone_shows_penalty(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test no_phone result triggers penalty with ask_workout_done as callback."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._verify_phone_workout = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=("no_phone", "No phone"),
|
||||||
|
)
|
||||||
|
locker._show_phone_penalty = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
locker._handle_startup_phone_result()
|
||||||
|
|
||||||
|
locker._show_phone_penalty.assert_called_once()
|
||||||
|
_, kwargs = locker._show_phone_penalty.call_args
|
||||||
|
assert kwargs["on_done"] == locker.ask_workout_done
|
||||||
|
|
||||||
|
def test_handle_startup_error_shows_penalty(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test error result triggers penalty with ask_workout_done as callback."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker._verify_phone_workout = MagicMock( # type: ignore[method-assign]
|
||||||
|
return_value=("error", "DB not found"),
|
||||||
|
)
|
||||||
|
locker._show_phone_penalty = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
locker._handle_startup_phone_result()
|
||||||
|
|
||||||
|
locker._show_phone_penalty.assert_called_once()
|
||||||
|
_, kwargs = locker._show_phone_penalty.call_args
|
||||||
|
assert kwargs["on_done"] == locker.ask_workout_done
|
||||||
|
|
||||||
|
|
||||||
|
class TestAttemptUnlock:
|
||||||
|
"""Tests for _attempt_unlock method."""
|
||||||
|
|
||||||
|
def test_attempt_unlock_calls_unlock_screen(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test _attempt_unlock calls unlock_screen directly."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
|
locker.workout_data = {"type": "strength"}
|
||||||
|
locker.unlock_screen = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
locker._attempt_unlock()
|
||||||
|
|
||||||
|
locker.unlock_screen.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
|
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, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test demo mode uses short penalty delay."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path, demo_mode=True)
|
||||||
|
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
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, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test production mode uses long penalty delay."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path, demo_mode=False)
|
||||||
|
locker.clear_container = MagicMock() # type: ignore[method-assign]
|
||||||
|
|
||||||
|
locker._show_phone_penalty("test message")
|
||||||
|
|
||||||
|
assert locker.phone_penalty_remaining == PHONE_PENALTY_DELAY_PRODUCTION - 1
|
||||||
|
|
||||||
|
def test_update_phone_penalty_countdown(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
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() # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
def test_update_phone_penalty_at_zero(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock, # noqa: ARG002
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Test phone penalty unlocks when timer reaches zero."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
locker.log_file = tmp_path / "workout_log.json"
|
||||||
|
locker.workout_data = {"type": "strength"}
|
||||||
|
locker.phone_penalty_remaining = 0
|
||||||
|
locker.phone_penalty_label = MagicMock()
|
||||||
|
locker.unlock_screen = MagicMock() # type: ignore[method-assign]
|
||||||
|
locker._phone_penalty_done_fn = locker.unlock_screen # type: ignore[attr-defined]
|
||||||
|
|
||||||
|
locker._update_phone_penalty()
|
||||||
|
|
||||||
|
locker.unlock_screen.assert_called_once()
|
||||||
|
|
||||||
|
|
||||||
class TestUnlockScreenShutdownAdjustment:
|
class TestUnlockScreenShutdownAdjustment:
|
||||||
"""Tests for unlock_screen shutdown time adjustment."""
|
"""Tests for unlock_screen shutdown time adjustment."""
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user