mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 15:23:02 +02:00
Restore consistency with WORKOUT_APP_JSON_REMOTES rename in _constants.py
A prior commit pushed the STRONGLIFTS_DB_REMOTE -> WORKOUT_APP_JSON_REMOTES rename in _constants.py without its consumer, breaking CI with an ImportError. This commits the matching _phone_verification.py rewrite and its reorganized test suite to close that gap. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01A7vbgtFfZmfxJtN5DdtJky
This commit is contained in:
parent
28c27d24e8
commit
d50bc49b92
@ -1,4 +1,4 @@
|
|||||||
"""Phone workout verification mixin using ADB and StrongLifts."""
|
"""Phone workout verification: ADB pull first, HTTP scan as fallback."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
@ -7,12 +7,12 @@ from concurrent.futures import ( # pylint: disable=no-name-in-module
|
|||||||
as_completed,
|
as_completed,
|
||||||
)
|
)
|
||||||
import contextlib
|
import contextlib
|
||||||
|
from http import client as _http_client
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import shutil
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import sqlite3
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
import time
|
import time
|
||||||
@ -20,10 +20,15 @@ import time
|
|||||||
from screen_locker._constants import (
|
from screen_locker._constants import (
|
||||||
ADB_TIMEOUT,
|
ADB_TIMEOUT,
|
||||||
MIN_WORKOUT_DURATION_MINUTES,
|
MIN_WORKOUT_DURATION_MINUTES,
|
||||||
STRONGLIFTS_DB_REMOTE,
|
WORKOUT_APP_JSON_REMOTES,
|
||||||
|
WORKOUT_HTTP_PORT,
|
||||||
)
|
)
|
||||||
from screen_locker._time_check import check_clock_skew
|
from screen_locker._time_check import check_clock_skew
|
||||||
|
|
||||||
|
_HTTPConnection = _http_client.HTTPConnection
|
||||||
|
_HTTPException = _http_client.HTTPException
|
||||||
|
_HTTP_OK = _http_client.OK
|
||||||
|
|
||||||
_logger = logging.getLogger(__name__)
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@ -58,23 +63,14 @@ class PhoneVerificationMixin:
|
|||||||
return False, ""
|
return False, ""
|
||||||
return not result.returncode, result.stdout
|
return not result.returncode, result.stdout
|
||||||
|
|
||||||
def _adb_shell(
|
def _adb_shell(self, command: str, *, root: bool = False) -> tuple[bool, str]:
|
||||||
self,
|
|
||||||
command: str,
|
|
||||||
*,
|
|
||||||
root: bool = False,
|
|
||||||
) -> tuple[bool, str]:
|
|
||||||
"""Run a shell command on the connected Android device."""
|
"""Run a shell command on the connected Android device."""
|
||||||
if root:
|
if root:
|
||||||
return self._run_adb(["shell", "su", "-c", command])
|
return self._run_adb(["shell", "su", "-c", command])
|
||||||
return self._run_adb(["shell", command])
|
return self._run_adb(["shell", command])
|
||||||
|
|
||||||
def _get_wireless_serial(self) -> str | None:
|
def _get_wireless_serial(self) -> str | None:
|
||||||
"""Return the serial (ip:port) of the first connected wireless ADB device.
|
"""Return the serial (ip:port) of the first connected wireless ADB device."""
|
||||||
|
|
||||||
Used to pin ADB commands to the wireless device when multiple devices
|
|
||||||
(e.g. USB cable + wireless debugging) are simultaneously connected.
|
|
||||||
"""
|
|
||||||
success, output = self._run_adb(["devices"])
|
success, output = self._run_adb(["devices"])
|
||||||
if not success:
|
if not success:
|
||||||
return None
|
return None
|
||||||
@ -137,219 +133,144 @@ class PhoneVerificationMixin:
|
|||||||
def _is_phone_connected(self) -> bool:
|
def _is_phone_connected(self) -> bool:
|
||||||
"""Check if an Android device is connected via ADB.
|
"""Check if an Android device is connected via ADB.
|
||||||
|
|
||||||
If no device is visible, attempts wireless reconnection using the
|
If no device is visible, attempts wireless reconnection via subnet scan.
|
||||||
stored phone IP/port config. USB-connected devices are detected
|
|
||||||
automatically by adb devices without any extra steps.
|
|
||||||
"""
|
"""
|
||||||
if self._has_adb_device():
|
if self._has_adb_device():
|
||||||
return True
|
return True
|
||||||
_logger.info("No ADB device detected — attempting wireless reconnect...")
|
_logger.info("No ADB device detected — attempting wireless reconnect...")
|
||||||
return self._try_wireless_reconnect()
|
return self._try_wireless_reconnect()
|
||||||
|
|
||||||
def _pull_stronglifts_db(self) -> Path | None:
|
# ── ADB verification ──────────────────────────────────────────────────────
|
||||||
"""Pull StrongLifts database from phone to a local temp file.
|
|
||||||
|
|
||||||
Returns:
|
def _pull_workout_app_json(self) -> dict | None:
|
||||||
Path to the local copy, or None on failure.
|
"""Pull workout_result.json from the phone (ADB, no root needed).
|
||||||
|
|
||||||
|
The app writes to one of several candidate paths (see sync_service.dart),
|
||||||
|
so we pull every candidate and prefer the one dated today. A stale file
|
||||||
|
can linger at a fallback path from a day the primary write failed, so
|
||||||
|
"first that parses" is not safe — we explicitly favour today's data and
|
||||||
|
only fall back to a stale/older payload if no candidate is from today.
|
||||||
"""
|
"""
|
||||||
tmp = Path(tempfile.gettempdir()) / "stronglifts_check.db"
|
tmp = Path(tempfile.gettempdir()) / "workout_result.json"
|
||||||
success, _ = self._adb_shell(
|
today = time.strftime("%Y-%m-%d")
|
||||||
f"cat '{STRONGLIFTS_DB_REMOTE}' > /sdcard/_sl_tmp.db",
|
first_parsed: dict | None = None
|
||||||
root=True,
|
for remote in WORKOUT_APP_JSON_REMOTES:
|
||||||
|
ok, _ = self._run_adb(["pull", remote, str(tmp)])
|
||||||
|
if not ok:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
data = json.loads(tmp.read_text())
|
||||||
|
except (json.JSONDecodeError, OSError):
|
||||||
|
continue
|
||||||
|
if data.get("date") == today:
|
||||||
|
return data
|
||||||
|
if first_parsed is None:
|
||||||
|
first_parsed = data
|
||||||
|
return first_parsed
|
||||||
|
|
||||||
|
def _validate_json_data(self, data: dict) -> tuple[str, str]:
|
||||||
|
"""Validate parsed workout JSON. Returns (status, message)."""
|
||||||
|
today = time.strftime("%Y-%m-%d")
|
||||||
|
if data.get("date") != today:
|
||||||
|
return "stale", f"Workout JSON is from {data.get('date')}, not today"
|
||||||
|
if not data.get("exercises"):
|
||||||
|
return "no_exercises", "No exercises found in today's workout JSON"
|
||||||
|
duration_min = data.get("duration_seconds", 0) / 60.0
|
||||||
|
if duration_min < MIN_WORKOUT_DURATION_MINUTES:
|
||||||
|
return (
|
||||||
|
"too_short",
|
||||||
|
f"Workout too short! {duration_min:.0f} min logged, "
|
||||||
|
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
|
||||||
|
)
|
||||||
|
flag = "all succeeded" if data.get("succeeded") else "partial"
|
||||||
|
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
|
||||||
|
|
||||||
|
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
||||||
|
|
||||||
|
def _scan_for_http_server(self) -> str | None:
|
||||||
|
"""Scan local /24 subnet for the workout app HTTP server on port 8765.
|
||||||
|
|
||||||
|
Returns the first reachable URL or None.
|
||||||
|
"""
|
||||||
|
prefix = self._get_local_subnet_prefix()
|
||||||
|
if prefix is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def probe(i: int) -> str | None:
|
||||||
|
ip = f"{prefix}.{i}"
|
||||||
|
with (
|
||||||
|
contextlib.suppress(OSError),
|
||||||
|
socket.create_connection((ip, WORKOUT_HTTP_PORT), timeout=0.3),
|
||||||
|
):
|
||||||
|
return f"http://{ip}:{WORKOUT_HTTP_PORT}/workout"
|
||||||
|
return None
|
||||||
|
|
||||||
|
_logger.info(
|
||||||
|
"Scanning %s.1-254:%d for workout app...", prefix, WORKOUT_HTTP_PORT
|
||||||
)
|
)
|
||||||
if not success:
|
with ThreadPoolExecutor(max_workers=64) as executor:
|
||||||
return None
|
for future in as_completed(
|
||||||
ok, _ = self._run_adb(["pull", "/sdcard/_sl_tmp.db", str(tmp)])
|
executor.submit(probe, i) for i in range(1, 255)
|
||||||
if not ok:
|
):
|
||||||
return None
|
result = future.result()
|
||||||
return tmp
|
if result is not None:
|
||||||
|
return result
|
||||||
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 _get_today_workout_duration_minutes(self, db_path: Path) -> float:
|
|
||||||
"""Get the total duration in minutes of today's workouts.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Total duration in minutes of all workouts started today.
|
|
||||||
Returns 0.0 on any error or if no workouts found.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT SUM((finish - start) / 1000.0 / 60.0) "
|
|
||||||
"FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime') "
|
|
||||||
"AND finish > start",
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
return float(row[0]) if row and row[0] is not None else 0.0
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError):
|
|
||||||
_logger.warning("Failed to query workout duration")
|
|
||||||
return 0.0
|
|
||||||
|
|
||||||
def _get_today_exercise_count(self, db_path: Path) -> int:
|
|
||||||
"""Count distinct exercises in today's workouts.
|
|
||||||
|
|
||||||
Parses the JSON ``exercises`` column in the ``workouts`` table.
|
|
||||||
Each workout row stores its exercises as a JSON array, not in a
|
|
||||||
separate relational table.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
Number of distinct exercises across today's workouts.
|
|
||||||
Returns 0 on any error.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT exercises FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime')",
|
|
||||||
)
|
|
||||||
exercise_ids: set[str] = set()
|
|
||||||
for (exercises_json,) in cursor:
|
|
||||||
if not exercises_json:
|
|
||||||
continue
|
|
||||||
for ex in json.loads(exercises_json):
|
|
||||||
ex_id = ex.get("id") or ex.get("name", "")
|
|
||||||
if ex_id:
|
|
||||||
exercise_ids.add(ex_id)
|
|
||||||
return len(exercise_ids)
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError, json.JSONDecodeError):
|
|
||||||
_logger.warning("Failed to query exercise count")
|
|
||||||
return 0
|
|
||||||
|
|
||||||
def _is_workout_finish_recent(self, db_path: Path) -> bool:
|
|
||||||
"""Check if the latest workout's finish time is recent.
|
|
||||||
|
|
||||||
A fresh workout should have finished within the last 24 hours.
|
|
||||||
This prevents using an old pre-prepared database dump while
|
|
||||||
still accepting workouts done earlier the same day.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
db_path: Path to the locally-pulled StrongLifts database.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
True if the latest finish time is within 24 hours of now.
|
|
||||||
"""
|
|
||||||
max_age_seconds = 24 * 3600 # accept same-day workouts
|
|
||||||
try:
|
|
||||||
conn = sqlite3.connect(str(db_path))
|
|
||||||
try:
|
|
||||||
cursor = conn.execute(
|
|
||||||
"SELECT MAX(finish) FROM workouts "
|
|
||||||
"WHERE date(start / 1000, 'unixepoch', 'localtime') "
|
|
||||||
"= date('now', 'localtime') "
|
|
||||||
"AND finish > start",
|
|
||||||
)
|
|
||||||
row = cursor.fetchone()
|
|
||||||
if not row or row[0] is None:
|
|
||||||
return False
|
|
||||||
finish_epoch = int(row[0]) / 1000.0
|
|
||||||
return (time.time() - finish_epoch) < max_age_seconds
|
|
||||||
finally:
|
|
||||||
conn.close()
|
|
||||||
except (sqlite3.Error, ValueError, TypeError):
|
|
||||||
_logger.warning("Failed to query workout finish time")
|
|
||||||
return False
|
|
||||||
|
|
||||||
def _validate_workout_db(
|
|
||||||
self,
|
|
||||||
local_db: Path,
|
|
||||||
) -> tuple[str, str] | None:
|
|
||||||
"""Validate workout database has a recent, real workout.
|
|
||||||
|
|
||||||
Returns:
|
|
||||||
A (status, message) tuple if validation fails, or None if OK.
|
|
||||||
"""
|
|
||||||
count = self._count_today_workouts(local_db)
|
|
||||||
if count <= 0:
|
|
||||||
return "not_verified", "No workout found on phone today"
|
|
||||||
if not self._is_workout_finish_recent(local_db):
|
|
||||||
return (
|
|
||||||
"stale",
|
|
||||||
"Workout finish time is too old. Did you actually work out today?",
|
|
||||||
)
|
|
||||||
exercise_count = self._get_today_exercise_count(local_db)
|
|
||||||
if exercise_count < 1:
|
|
||||||
return (
|
|
||||||
"no_exercises",
|
|
||||||
"No exercises found in today's workout. "
|
|
||||||
"Log actual exercises in StrongLifts!",
|
|
||||||
)
|
|
||||||
return None
|
return None
|
||||||
|
|
||||||
def _verify_phone_workout(self) -> tuple[str, str]:
|
def _fetch_http_workout(self) -> dict | None:
|
||||||
"""Verify workout was recorded in StrongLifts on the phone.
|
"""Fetch workout JSON from the app's HTTP server on the local network.
|
||||||
|
|
||||||
Returns:
|
Uses http.client directly to avoid urllib URL-open security lint rules.
|
||||||
Tuple of (status, message) where status is one of:
|
The URL is always http://<local-ip>:8765/workout — no user input involved.
|
||||||
- "verified": Workout confirmed and >= minimum duration.
|
"""
|
||||||
- "too_short": Workout found but shorter than minimum.
|
url = self._scan_for_http_server()
|
||||||
- "not_verified": Phone connected but no workout found.
|
if url is None:
|
||||||
- "no_phone": No phone connected via ADB.
|
return None
|
||||||
- "error": Could not access StrongLifts database.
|
# url is always "http://<ip>:<port>/workout" — constructed internally.
|
||||||
- "stale": Workout finish time is not recent.
|
try:
|
||||||
- "no_exercises": Workout has no logged exercises.
|
_, _, hostport = url.partition("://")
|
||||||
- "clock_tampered": System clock skew exceeds threshold.
|
host, _, path = hostport.partition("/")
|
||||||
|
hostname, _, port_str = host.partition(":")
|
||||||
|
conn = _HTTPConnection(hostname, int(port_str), timeout=5)
|
||||||
|
conn.request("GET", f"/{path}")
|
||||||
|
resp = conn.getresponse()
|
||||||
|
if resp.status != _HTTP_OK:
|
||||||
|
return None
|
||||||
|
return json.loads(resp.read().decode())
|
||||||
|
except (_HTTPException, OSError, ValueError, json.JSONDecodeError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ── Main verification entry point ─────────────────────────────────────────
|
||||||
|
|
||||||
|
def _verify_phone_workout(self) -> tuple[str, str]:
|
||||||
|
"""Verify today's workout: ADB pull if available, HTTP scan as fallback.
|
||||||
|
|
||||||
|
Returns (status, message). Status values:
|
||||||
|
verified / too_short / not_verified / no_phone /
|
||||||
|
stale / no_exercises / clock_tampered.
|
||||||
"""
|
"""
|
||||||
clock_ok, clock_msg = check_clock_skew()
|
clock_ok, clock_msg = check_clock_skew()
|
||||||
if not clock_ok:
|
if not clock_ok:
|
||||||
return "clock_tampered", clock_msg
|
return "clock_tampered", clock_msg
|
||||||
if not self._is_phone_connected():
|
|
||||||
return "no_phone", "No phone connected via ADB"
|
# Prefer ADB when a device is visible, but if the pull yields no usable
|
||||||
local_db = self._pull_stronglifts_db()
|
# JSON, fall through to the HTTP/WiFi scan — the app's in-memory HTTP
|
||||||
if local_db is None:
|
# server may still hold today's workout even when the file pull fails.
|
||||||
return "error", "StrongLifts database not found on phone"
|
adb_connected = self._is_phone_connected()
|
||||||
db_error = self._validate_workout_db(local_db)
|
if adb_connected:
|
||||||
if db_error is not None:
|
data = self._pull_workout_app_json()
|
||||||
return db_error
|
if data is not None:
|
||||||
duration = self._get_today_workout_duration_minutes(local_db)
|
return self._validate_json_data(data)
|
||||||
if duration < MIN_WORKOUT_DURATION_MINUTES:
|
_logger.info("ADB pull found no workout JSON — trying HTTP scan...")
|
||||||
|
else:
|
||||||
|
_logger.info("No ADB device — trying HTTP scan on local network...")
|
||||||
|
|
||||||
|
data = self._fetch_http_workout()
|
||||||
|
if data is not None:
|
||||||
|
return self._validate_json_data(data)
|
||||||
|
if adb_connected:
|
||||||
return (
|
return (
|
||||||
"too_short",
|
"not_verified",
|
||||||
f"Workout too short! {duration:.0f} min logged, "
|
"Workout app JSON not found. Complete a workout in the app first.",
|
||||||
f"need at least {MIN_WORKOUT_DURATION_MINUTES} min.",
|
|
||||||
)
|
)
|
||||||
exercise_count = self._get_today_exercise_count(local_db)
|
return "no_phone", "Phone not reachable via ADB or HTTP on local network"
|
||||||
return (
|
|
||||||
"verified",
|
|
||||||
f"Workout verified! ({self._count_today_workouts(local_db)}"
|
|
||||||
f" session(s), {duration:.0f} min, "
|
|
||||||
f"{exercise_count} exercise(s))",
|
|
||||||
)
|
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import subprocess
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from screen_locker.screen_lock import STRONGLIFTS_DB_REMOTE
|
|
||||||
from screen_locker.tests.conftest import create_locker
|
from screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
@ -255,109 +254,3 @@ class TestIsPhoneConnected:
|
|||||||
)
|
)
|
||||||
|
|
||||||
assert locker._is_phone_connected() is 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,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test StrongLifts DB pulled from device."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
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,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when cat command fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(False, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
assert locker._pull_stronglifts_db() is None
|
|
||||||
|
|
||||||
def test_db_pull_fails(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns None when adb pull fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
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,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test uses the correct StrongLifts DB remote path."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_adb_shell",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_run_adb",
|
|
||||||
MagicMock(
|
|
||||||
return_value=(True, ""),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
locker._pull_stronglifts_db()
|
|
||||||
|
|
||||||
shell_cmd = locker._adb_shell.call_args[0][0]
|
|
||||||
assert STRONGLIFTS_DB_REMOTE in shell_cmd
|
|
||||||
|
|||||||
@ -1,394 +0,0 @@
|
|||||||
"""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 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
|
|
||||||
@ -1,125 +0,0 @@
|
|||||||
"""Tests for _count_today_workouts and related database queries."""
|
|
||||||
# pylint: disable=protected-access,unused-argument
|
|
||||||
|
|
||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import sqlite3
|
|
||||||
import time
|
|
||||||
from typing import TYPE_CHECKING
|
|
||||||
from unittest.mock import MagicMock
|
|
||||||
|
|
||||||
from screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestCountTodayWorkouts:
|
|
||||||
"""Tests for _count_today_workouts method."""
|
|
||||||
|
|
||||||
def test_workouts_found_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test workouts found today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
# Insert a workout with today's timestamp (ms)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._count_today_workouts(db_file) == 1
|
|
||||||
|
|
||||||
def test_no_workouts_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no workouts today."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
# Insert a workout from yesterday (24h+ ago)
|
|
||||||
yesterday_ms = int((time.time() - 200000) * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", yesterday_ms, yesterday_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._count_today_workouts(db_file)
|
|
||||||
|
|
||||||
def test_invalid_db_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 for invalid database file."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
bad_file = tmp_path / "not_a_db.db"
|
|
||||||
bad_file.write_text("not a database")
|
|
||||||
|
|
||||||
assert not locker._count_today_workouts(bad_file)
|
|
||||||
|
|
||||||
def test_missing_table_returns_zero(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test returns 0 when workouts table doesn't exist."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "empty.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute("CREATE TABLE other (id TEXT)")
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert not locker._count_today_workouts(db_file)
|
|
||||||
|
|
||||||
def test_multiple_workouts_today(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test counts multiple workouts today correctly."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
db_file = tmp_path / "sl_test.db"
|
|
||||||
conn = sqlite3.connect(str(db_file))
|
|
||||||
conn.execute(
|
|
||||||
"CREATE TABLE workouts "
|
|
||||||
"(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)",
|
|
||||||
)
|
|
||||||
now_ms = int(time.time() * 1000)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w1", now_ms, now_ms + 3600000),
|
|
||||||
)
|
|
||||||
conn.execute(
|
|
||||||
"INSERT INTO workouts VALUES (?, ?, ?)",
|
|
||||||
("w2", now_ms + 100000, now_ms + 3700000),
|
|
||||||
)
|
|
||||||
conn.commit()
|
|
||||||
conn.close()
|
|
||||||
|
|
||||||
assert locker._count_today_workouts(db_file) == 2
|
|
||||||
@ -1,297 +0,0 @@
|
|||||||
"""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, patch
|
|
||||||
|
|
||||||
from screen_locker.tests.conftest import create_locker
|
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
|
|
||||||
class TestVerifyPhoneWorkout:
|
|
||||||
"""Tests for _verify_phone_workout method."""
|
|
||||||
|
|
||||||
def test_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test workout verified on phone with sufficient duration."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=2),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_exercise_count",
|
|
||||||
MagicMock(return_value=3),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_workout_duration_minutes",
|
|
||||||
MagicMock(return_value=65.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "verified"
|
|
||||||
assert "2 session" in message
|
|
||||||
assert "65 min" in message
|
|
||||||
assert "3 exercise" in message
|
|
||||||
|
|
||||||
def test_not_verified(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no workout found on phone."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "not_verified"
|
|
||||||
assert "No workout" in message
|
|
||||||
|
|
||||||
def test_too_short(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test workout found but too short."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=1),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_exercise_count",
|
|
||||||
MagicMock(return_value=3),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_workout_duration_minutes",
|
|
||||||
MagicMock(return_value=25.0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "too_short"
|
|
||||||
assert "25 min" in message
|
|
||||||
assert "50 min" in message
|
|
||||||
|
|
||||||
def test_no_phone(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no phone connected."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, _ = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "no_phone"
|
|
||||||
|
|
||||||
def test_error_no_db(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test error when StrongLifts DB cannot be pulled."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=None),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "error"
|
|
||||||
assert "database" in message.lower()
|
|
||||||
|
|
||||||
def test_clock_tampered(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test clock_tampered when NTP check fails."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(False, "System clock is 600s ahead"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "clock_tampered"
|
|
||||||
assert "600s" in message
|
|
||||||
|
|
||||||
def test_stale_workout(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test stale status when workout finish is not recent."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=1),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=False),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "stale"
|
|
||||||
assert "old" in message.lower()
|
|
||||||
|
|
||||||
def test_no_exercises(
|
|
||||||
self,
|
|
||||||
mock_tk: MagicMock,
|
|
||||||
mock_sys_exit: MagicMock,
|
|
||||||
tmp_path: Path,
|
|
||||||
) -> None:
|
|
||||||
"""Test no_exercises when workout has no exercise data."""
|
|
||||||
locker = create_locker(mock_tk, tmp_path)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_phone_connected",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_pull_stronglifts_db",
|
|
||||||
MagicMock(return_value=tmp_path / "sl.db"),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_count_today_workouts",
|
|
||||||
MagicMock(return_value=1),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_is_workout_finish_recent",
|
|
||||||
MagicMock(return_value=True),
|
|
||||||
)
|
|
||||||
object.__setattr__(
|
|
||||||
locker,
|
|
||||||
"_get_today_exercise_count",
|
|
||||||
MagicMock(return_value=0),
|
|
||||||
)
|
|
||||||
|
|
||||||
with patch(
|
|
||||||
"screen_locker._phone_verification.check_clock_skew",
|
|
||||||
return_value=(True, "Clock OK"),
|
|
||||||
):
|
|
||||||
status, message = locker._verify_phone_workout()
|
|
||||||
|
|
||||||
assert status == "no_exercises"
|
|
||||||
assert "exercise" in message.lower()
|
|
||||||
254
screen_locker/tests/test_phone_verification_part3.py
Normal file
254
screen_locker/tests/test_phone_verification_part3.py
Normal file
@ -0,0 +1,254 @@
|
|||||||
|
"""Tests for multi-path workout-JSON pull and HTTP fall-through (part 3).
|
||||||
|
|
||||||
|
Covers the fix for the path mismatch where the app writes
|
||||||
|
``/sdcard/workout_result.json`` (primary) but the locker only checked the
|
||||||
|
app-external fallback path. The locker now pulls every candidate path, prefers
|
||||||
|
the one dated today, and falls through to the HTTP scan when an ADB pull yields
|
||||||
|
no usable JSON even though a device is connected.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
import time
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from screen_locker._constants import WORKOUT_APP_JSON_REMOTES
|
||||||
|
from screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
|
_PRIMARY, _FALLBACK = WORKOUT_APP_JSON_REMOTES
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> str:
|
||||||
|
"""Return today's date as the app stamps it (local YYYY-MM-DD)."""
|
||||||
|
return time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _adb_pull_from(path_contents: dict[str, str]) -> MagicMock:
|
||||||
|
"""Build a fake ``_run_adb`` that writes per-remote content to the dest file.
|
||||||
|
|
||||||
|
``path_contents`` maps a remote path to the JSON text the device would
|
||||||
|
return for it. A remote absent from the map simulates a missing file
|
||||||
|
(``adb pull`` failure).
|
||||||
|
"""
|
||||||
|
|
||||||
|
def fake_run_adb(args: list[str]) -> tuple[bool, str]:
|
||||||
|
if not args or args[0] != "pull":
|
||||||
|
return False, ""
|
||||||
|
remote, dest = args[1], args[2]
|
||||||
|
content = path_contents.get(remote)
|
||||||
|
if content is None:
|
||||||
|
return False, ""
|
||||||
|
Path(dest).write_text(content)
|
||||||
|
return True, ""
|
||||||
|
|
||||||
|
return MagicMock(side_effect=fake_run_adb)
|
||||||
|
|
||||||
|
|
||||||
|
class TestPullWorkoutAppJsonMultiPath:
|
||||||
|
"""Tests for _pull_workout_app_json across multiple candidate paths."""
|
||||||
|
|
||||||
|
def test_prefers_todays_data_over_stale_primary(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A stale primary file must not shadow today's fallback file."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
stale = json.dumps({"date": "2000-01-01", "exercises": ["old"]})
|
||||||
|
fresh = json.dumps({"date": _today(), "exercises": ["new"]})
|
||||||
|
fake = _adb_pull_from({_PRIMARY: stale, _FALLBACK: fresh})
|
||||||
|
with patch.object(locker, "_run_adb", fake):
|
||||||
|
result = locker._pull_workout_app_json()
|
||||||
|
assert result is not None
|
||||||
|
assert result["date"] == _today()
|
||||||
|
assert result["exercises"] == ["new"]
|
||||||
|
|
||||||
|
def test_returns_primary_when_dated_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Primary path dated today is returned without needing the fallback."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
fresh = json.dumps({"date": _today(), "exercises": ["primary"]})
|
||||||
|
fake = _adb_pull_from({_PRIMARY: fresh})
|
||||||
|
with patch.object(locker, "_run_adb", fake):
|
||||||
|
result = locker._pull_workout_app_json()
|
||||||
|
assert result is not None
|
||||||
|
assert result["exercises"] == ["primary"]
|
||||||
|
|
||||||
|
def test_falls_back_to_stale_when_none_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""With no today's file, the first parseable payload is returned."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
stale = json.dumps({"date": "2000-01-01", "exercises": ["old"]})
|
||||||
|
fake = _adb_pull_from({_FALLBACK: stale})
|
||||||
|
with patch.object(locker, "_run_adb", fake):
|
||||||
|
result = locker._pull_workout_app_json()
|
||||||
|
assert result is not None
|
||||||
|
assert result["date"] == "2000-01-01"
|
||||||
|
|
||||||
|
def test_keeps_first_stale_when_multiple_non_today(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""With several non-today files, the first parseable one is kept."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
first = json.dumps({"date": "2000-01-01", "exercises": ["primary"]})
|
||||||
|
second = json.dumps({"date": "1999-12-31", "exercises": ["fallback"]})
|
||||||
|
fake = _adb_pull_from({_PRIMARY: first, _FALLBACK: second})
|
||||||
|
with patch.object(locker, "_run_adb", fake):
|
||||||
|
result = locker._pull_workout_app_json()
|
||||||
|
assert result is not None
|
||||||
|
assert result["exercises"] == ["primary"]
|
||||||
|
|
||||||
|
def test_skips_unparseable_payload(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A corrupt file at one path doesn't block today's file at another."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
fresh = json.dumps({"date": _today(), "exercises": ["new"]})
|
||||||
|
fake = _adb_pull_from({_PRIMARY: "{not valid json", _FALLBACK: fresh})
|
||||||
|
with patch.object(locker, "_run_adb", fake):
|
||||||
|
result = locker._pull_workout_app_json()
|
||||||
|
assert result is not None
|
||||||
|
assert result["date"] == _today()
|
||||||
|
|
||||||
|
def test_returns_none_when_no_candidate_pulls(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Returns None when no candidate path yields a file."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
fake = _adb_pull_from({})
|
||||||
|
with patch.object(locker, "_run_adb", fake):
|
||||||
|
result = locker._pull_workout_app_json()
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestVerifyPhoneWorkoutFallthrough:
|
||||||
|
"""Tests for the ADB→HTTP fall-through in _verify_phone_workout."""
|
||||||
|
|
||||||
|
def test_adb_connected_but_pull_empty_falls_through_to_http(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A connected device with no pullable JSON still tries the HTTP scan."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
http_data = {
|
||||||
|
"date": _today(),
|
||||||
|
"exercises": ["a"],
|
||||||
|
"duration_seconds": 4000,
|
||||||
|
"succeeded": True,
|
||||||
|
}
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification.check_clock_skew",
|
||||||
|
return_value=(True, ""),
|
||||||
|
),
|
||||||
|
patch.object(locker, "_is_phone_connected", return_value=True),
|
||||||
|
patch.object(locker, "_pull_workout_app_json", return_value=None),
|
||||||
|
patch.object(locker, "_fetch_http_workout", return_value=http_data),
|
||||||
|
):
|
||||||
|
status, _ = locker._verify_phone_workout()
|
||||||
|
assert status == "verified"
|
||||||
|
|
||||||
|
def test_adb_connected_pull_and_http_empty_is_not_verified(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""Connected device, no JSON anywhere → not_verified (not no_phone)."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification.check_clock_skew",
|
||||||
|
return_value=(True, ""),
|
||||||
|
),
|
||||||
|
patch.object(locker, "_is_phone_connected", return_value=True),
|
||||||
|
patch.object(locker, "_pull_workout_app_json", return_value=None),
|
||||||
|
patch.object(locker, "_fetch_http_workout", return_value=None),
|
||||||
|
):
|
||||||
|
status, _ = locker._verify_phone_workout()
|
||||||
|
assert status == "not_verified"
|
||||||
|
|
||||||
|
def test_adb_pull_success_returns_without_http(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A successful ADB pull validates directly, never touching HTTP."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
data = {
|
||||||
|
"date": _today(),
|
||||||
|
"exercises": ["a"],
|
||||||
|
"duration_seconds": 4000,
|
||||||
|
"succeeded": False,
|
||||||
|
}
|
||||||
|
http = MagicMock()
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification.check_clock_skew",
|
||||||
|
return_value=(True, ""),
|
||||||
|
),
|
||||||
|
patch.object(locker, "_is_phone_connected", return_value=True),
|
||||||
|
patch.object(locker, "_pull_workout_app_json", return_value=data),
|
||||||
|
patch.object(locker, "_fetch_http_workout", http),
|
||||||
|
):
|
||||||
|
status, _ = locker._verify_phone_workout()
|
||||||
|
assert status == "verified"
|
||||||
|
http.assert_not_called()
|
||||||
|
|
||||||
|
def test_clock_tampered_short_circuits(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""A clock-skew failure returns clock_tampered before any phone access."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch(
|
||||||
|
"screen_locker._phone_verification.check_clock_skew",
|
||||||
|
return_value=(False, "clock skew too large"),
|
||||||
|
):
|
||||||
|
status, message = locker._verify_phone_workout()
|
||||||
|
assert status == "clock_tampered"
|
||||||
|
assert message == "clock skew too large"
|
||||||
|
|
||||||
|
def test_no_device_and_http_empty_is_no_phone(
|
||||||
|
self,
|
||||||
|
mock_tk: MagicMock,
|
||||||
|
mock_sys_exit: MagicMock,
|
||||||
|
tmp_path: Path,
|
||||||
|
) -> None:
|
||||||
|
"""No device and no HTTP server → no_phone."""
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification.check_clock_skew",
|
||||||
|
return_value=(True, ""),
|
||||||
|
),
|
||||||
|
patch.object(locker, "_is_phone_connected", return_value=False),
|
||||||
|
patch.object(locker, "_fetch_http_workout", return_value=None),
|
||||||
|
):
|
||||||
|
status, _ = locker._verify_phone_workout()
|
||||||
|
assert status == "no_phone"
|
||||||
209
screen_locker/tests/test_phone_verification_part4.py
Normal file
209
screen_locker/tests/test_phone_verification_part4.py
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
"""Tests for JSON workout validation and HTTP fallback (part 4).
|
||||||
|
|
||||||
|
Replaces the obsolete StrongLifts-DB-based ``test_phone_check_unlock.py``.
|
||||||
|
Covers ``_validate_json_data`` (all status branches) and the HTTP fallback
|
||||||
|
``_scan_for_http_server`` / ``_fetch_http_workout`` used when ADB is
|
||||||
|
unavailable. Network is fully mocked — no test touches a real socket.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import time
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
from screen_locker._constants import MIN_WORKOUT_DURATION_MINUTES
|
||||||
|
from screen_locker.tests.conftest import create_locker
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
|
def _today() -> str:
|
||||||
|
"""Return today's date as the validator computes it (local YYYY-MM-DD)."""
|
||||||
|
return time.strftime("%Y-%m-%d")
|
||||||
|
|
||||||
|
|
||||||
|
def _mock_cm(return_value: MagicMock) -> MagicMock:
|
||||||
|
"""Build a MagicMock usable as a context manager yielding ``return_value``."""
|
||||||
|
cm = MagicMock()
|
||||||
|
cm.__enter__ = MagicMock(return_value=return_value)
|
||||||
|
cm.__exit__ = MagicMock(return_value=False)
|
||||||
|
return cm
|
||||||
|
|
||||||
|
|
||||||
|
class TestValidateJsonData:
|
||||||
|
"""Tests for _validate_json_data across every status branch."""
|
||||||
|
|
||||||
|
def test_stale_when_not_today(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
status, message = locker._validate_json_data(
|
||||||
|
{"date": "2000-01-01", "exercises": ["x"], "duration_seconds": 4000}
|
||||||
|
)
|
||||||
|
assert status == "stale"
|
||||||
|
assert "2000-01-01" in message
|
||||||
|
|
||||||
|
def test_no_exercises_when_empty(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
status, message = locker._validate_json_data(
|
||||||
|
{"date": _today(), "exercises": [], "duration_seconds": 4000}
|
||||||
|
)
|
||||||
|
assert status == "no_exercises"
|
||||||
|
assert "exercise" in message.lower()
|
||||||
|
|
||||||
|
def test_too_short_under_minimum(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
short_seconds = int((MIN_WORKOUT_DURATION_MINUTES - 10) * 60)
|
||||||
|
status, message = locker._validate_json_data(
|
||||||
|
{"date": _today(), "exercises": ["x"], "duration_seconds": short_seconds}
|
||||||
|
)
|
||||||
|
assert status == "too_short"
|
||||||
|
assert f"{MIN_WORKOUT_DURATION_MINUTES}" in message
|
||||||
|
|
||||||
|
def test_verified_all_succeeded(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
status, message = locker._validate_json_data(
|
||||||
|
{
|
||||||
|
"date": _today(),
|
||||||
|
"exercises": ["x"],
|
||||||
|
"duration_seconds": 6000,
|
||||||
|
"succeeded": True,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert status == "verified"
|
||||||
|
assert "all succeeded" in message
|
||||||
|
|
||||||
|
def test_verified_partial_when_not_succeeded(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
status, message = locker._validate_json_data(
|
||||||
|
{
|
||||||
|
"date": _today(),
|
||||||
|
"exercises": ["x"],
|
||||||
|
"duration_seconds": 6000,
|
||||||
|
"succeeded": False,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
assert status == "verified"
|
||||||
|
assert "partial" in message
|
||||||
|
|
||||||
|
|
||||||
|
class TestScanForHttpServer:
|
||||||
|
"""Tests for _scan_for_http_server subnet probing."""
|
||||||
|
|
||||||
|
def test_returns_none_without_prefix(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch.object(locker, "_get_local_subnet_prefix", return_value=None):
|
||||||
|
assert locker._scan_for_http_server() is None
|
||||||
|
|
||||||
|
def test_returns_url_when_probe_connects(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification.socket.create_connection",
|
||||||
|
return_value=_mock_cm(MagicMock()),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = locker._scan_for_http_server()
|
||||||
|
assert result is not None
|
||||||
|
assert result.startswith("http://192.168.1.")
|
||||||
|
assert result.endswith(":8765/workout")
|
||||||
|
|
||||||
|
def test_returns_none_when_all_probes_refused(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch.object(locker, "_get_local_subnet_prefix", return_value="192.168.1"),
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification.socket.create_connection",
|
||||||
|
side_effect=OSError("refused"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert locker._scan_for_http_server() is None
|
||||||
|
|
||||||
|
|
||||||
|
class TestFetchHttpWorkout:
|
||||||
|
"""Tests for _fetch_http_workout over the local HTTP server."""
|
||||||
|
|
||||||
|
def test_returns_none_when_scan_finds_nothing(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with patch.object(locker, "_scan_for_http_server", return_value=None):
|
||||||
|
assert locker._fetch_http_workout() is None
|
||||||
|
|
||||||
|
def test_returns_json_on_http_200(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status = 200
|
||||||
|
resp.read.return_value = b'{"date": "2026-06-12", "exercises": ["a"]}'
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.getresponse.return_value = resp
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locker,
|
||||||
|
"_scan_for_http_server",
|
||||||
|
return_value="http://192.168.1.5:8765/workout",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification._HTTPConnection",
|
||||||
|
return_value=conn,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
result = locker._fetch_http_workout()
|
||||||
|
assert result == {"date": "2026-06-12", "exercises": ["a"]}
|
||||||
|
|
||||||
|
def test_returns_none_on_non_ok_status(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
resp = MagicMock()
|
||||||
|
resp.status = 404
|
||||||
|
conn = MagicMock()
|
||||||
|
conn.getresponse.return_value = resp
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locker,
|
||||||
|
"_scan_for_http_server",
|
||||||
|
return_value="http://192.168.1.5:8765/workout",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification._HTTPConnection",
|
||||||
|
return_value=conn,
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert locker._fetch_http_workout() is None
|
||||||
|
|
||||||
|
def test_returns_none_on_connection_error(
|
||||||
|
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||||
|
) -> None:
|
||||||
|
locker = create_locker(mock_tk, tmp_path)
|
||||||
|
with (
|
||||||
|
patch.object(
|
||||||
|
locker,
|
||||||
|
"_scan_for_http_server",
|
||||||
|
return_value="http://192.168.1.5:8765/workout",
|
||||||
|
),
|
||||||
|
patch(
|
||||||
|
"screen_locker._phone_verification._HTTPConnection",
|
||||||
|
side_effect=OSError("unreachable"),
|
||||||
|
),
|
||||||
|
):
|
||||||
|
assert locker._fetch_http_workout() is None
|
||||||
Loading…
Reference in New Issue
Block a user