diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index 03fa8f7..6d5d6d2 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -2,8 +2,12 @@ from __future__ import annotations -from concurrent.futures import ThreadPoolExecutor, as_completed +from concurrent.futures import ( # pylint: disable=no-name-in-module + ThreadPoolExecutor, + as_completed, +) import contextlib +import json import logging from pathlib import Path import shutil @@ -52,7 +56,7 @@ class PhoneVerificationMixin: except subprocess.TimeoutExpired: _logger.warning("ADB command timed out: %s", args) return False, "" - return result.returncode == 0, result.stdout + return not result.returncode, result.stdout def _adb_shell( self, @@ -216,31 +220,37 @@ class PhoneVerificationMixin: def _get_today_exercise_count(self, db_path: Path) -> int: """Count distinct exercises in today's workouts. - Uses the StrongLifts 'exercises' table joined with 'workouts' to - verify that actual exercises were logged, not just empty sessions. + 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 in today's workouts. + 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 COUNT(DISTINCT e.exercise) " - "FROM exercises e " - "JOIN workouts w ON e.workout = w.id " - "WHERE date(w.start / 1000, 'unixepoch', 'localtime') " + "SELECT exercises FROM workouts " + "WHERE date(start / 1000, 'unixepoch', 'localtime') " "= date('now', 'localtime')", ) - row = cursor.fetchone() - return int(row[0]) if row else 0 + 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): + except (sqlite3.Error, ValueError, TypeError, json.JSONDecodeError): _logger.warning("Failed to query exercise count") return 0 diff --git a/screen_locker/_ui_flows.py b/screen_locker/_ui_flows.py index 489e44b..37f01cb 100644 --- a/screen_locker/_ui_flows.py +++ b/screen_locker/_ui_flows.py @@ -2,7 +2,7 @@ from __future__ import annotations -from concurrent.futures import ThreadPoolExecutor +from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module from typing import TYPE_CHECKING from python_pkg.screen_locker._constants import ( @@ -79,9 +79,7 @@ class UIFlowsMixin: ) elif status in ("stale", "no_exercises"): self._show_retry_and_sick( - f"\u274c {message}\n\n" - "The workout data looks suspicious.\n" - "Make sure you did a real workout today.", + f"\u274c {message}\n\nReason: {status}", ) elif status == "clock_tampered": self._show_retry_and_sick( diff --git a/screen_locker/tests/test_adb_and_phone.py b/screen_locker/tests/test_adb_and_phone.py index 7b79b5d..17cef56 100644 --- a/screen_locker/tests/test_adb_and_phone.py +++ b/screen_locker/tests/test_adb_and_phone.py @@ -1,7 +1,9 @@ """Tests for ADB commands, phone connection, and database operations.""" +# pylint: disable=protected-access,unused-argument from __future__ import annotations +import json import sqlite3 import subprocess import time @@ -71,7 +73,7 @@ class TestRunAdb: success, output = locker._run_adb(["devices"]) assert success is False - assert output == "" + assert not output def test_run_adb_oserror( self, @@ -88,7 +90,7 @@ class TestRunAdb: success, output = locker._run_adb(["devices"]) assert success is False - assert output == "" + assert not output def test_run_adb_timeout( self, @@ -105,7 +107,7 @@ class TestRunAdb: success, output = locker._run_adb(["devices"]) assert success is False - assert output == "" + assert not output class TestAdbShell: @@ -417,7 +419,7 @@ class TestCountTodayWorkouts: conn.commit() conn.close() - assert locker._count_today_workouts(db_file) == 0 + assert not locker._count_today_workouts(db_file) def test_invalid_db_returns_zero( self, @@ -430,7 +432,7 @@ class TestCountTodayWorkouts: bad_file = tmp_path / "not_a_db.db" bad_file.write_text("not a database") - assert locker._count_today_workouts(bad_file) == 0 + assert not locker._count_today_workouts(bad_file) def test_missing_table_returns_zero( self, @@ -446,7 +448,7 @@ class TestCountTodayWorkouts: conn.commit() conn.close() - assert locker._count_today_workouts(db_file) == 0 + assert not locker._count_today_workouts(db_file) def test_multiple_workouts_today( self, @@ -528,7 +530,7 @@ class TestGetTodayWorkoutDurationMinutes: conn.commit() conn.close() - assert locker._get_today_workout_duration_minutes(db_file) == 0.0 + assert not locker._get_today_workout_duration_minutes(db_file) def test_sums_multiple_workouts( self, @@ -583,7 +585,7 @@ class TestGetTodayWorkoutDurationMinutes: conn.commit() conn.close() - assert locker._get_today_workout_duration_minutes(db_file) == 0.0 + assert not locker._get_today_workout_duration_minutes(db_file) def test_invalid_db_returns_zero( self, @@ -596,7 +598,7 @@ class TestGetTodayWorkoutDurationMinutes: bad_file = tmp_path / "not_a_db.db" bad_file.write_text("not a database") - assert locker._get_today_workout_duration_minutes(bad_file) == 0.0 + assert not locker._get_today_workout_duration_minutes(bad_file) def test_missing_table_returns_zero( self, @@ -612,7 +614,7 @@ class TestGetTodayWorkoutDurationMinutes: conn.commit() conn.close() - assert locker._get_today_workout_duration_minutes(db_file) == 0.0 + assert not locker._get_today_workout_duration_minutes(db_file) class TestGetTodayExerciseCount: @@ -630,27 +632,20 @@ class TestGetTodayExerciseCount: conn = sqlite3.connect(str(db_file)) conn.execute( "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - conn.execute( - "CREATE TABLE exercises (id TEXT, workout TEXT, exercise TEXT)", + "(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), + exercises_json = json.dumps( + [ + {"id": "squat", "name": "Squat"}, + {"id": "bench_press", "name": "Bench Press"}, + {"id": "squat", "name": "Squat"}, + {"category": "WARMUP"}, + ] ) conn.execute( - "INSERT INTO exercises VALUES (?, ?, ?)", - ("e1", "w1", "squat"), - ) - conn.execute( - "INSERT INTO exercises VALUES (?, ?, ?)", - ("e2", "w1", "bench_press"), - ) - conn.execute( - "INSERT INTO exercises VALUES (?, ?, ?)", - ("e3", "w1", "squat"), + "INSERT INTO workouts VALUES (?, ?, ?, ?)", + ("w1", now_ms, now_ms + 3600000, exercises_json), ) conn.commit() conn.close() @@ -669,20 +664,17 @@ class TestGetTodayExerciseCount: conn = sqlite3.connect(str(db_file)) conn.execute( "CREATE TABLE workouts " - "(id TEXT PRIMARY KEY, start INTEGER, finish INTEGER)", - ) - conn.execute( - "CREATE TABLE exercises (id TEXT, workout TEXT, exercise TEXT)", + "(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), + "INSERT INTO workouts VALUES (?, ?, ?, ?)", + ("w1", now_ms, now_ms + 3600000, "[]"), ) conn.commit() conn.close() - assert locker._get_today_exercise_count(db_file) == 0 + assert not locker._get_today_exercise_count(db_file) def test_invalid_db_returns_zero( self, @@ -695,15 +687,15 @@ class TestGetTodayExerciseCount: bad_file = tmp_path / "bad.db" bad_file.write_text("not a db") - assert locker._get_today_exercise_count(bad_file) == 0 + assert not locker._get_today_exercise_count(bad_file) - def test_missing_table_returns_zero_exercises( + def test_missing_exercises_column_returns_zero( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: - """Test returns 0 when exercises table doesn't exist.""" + """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)) @@ -711,10 +703,63 @@ class TestGetTodayExerciseCount: "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 locker._get_today_exercise_count(db_file) == 0 + 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: diff --git a/screen_locker/tests/test_phone_check_unlock.py b/screen_locker/tests/test_phone_check_unlock.py index 7ac871c..1619097 100644 --- a/screen_locker/tests/test_phone_check_unlock.py +++ b/screen_locker/tests/test_phone_check_unlock.py @@ -1,4 +1,5 @@ """Tests for phone workout verification, phone check, and unlock operations.""" +# pylint: disable=protected-access,unused-argument from __future__ import annotations @@ -390,7 +391,7 @@ class TestStartPhoneCheck: locker._show_retry_and_sick.assert_called_once() call_args = locker._show_retry_and_sick.call_args[0][0] - assert "suspicious" in call_args.lower() + assert "reason: stale" in call_args.lower() def test_handle_startup_no_exercises_shows_retry_and_sick( self, @@ -405,7 +406,7 @@ class TestStartPhoneCheck: locker._show_retry_and_sick.assert_called_once() call_args = locker._show_retry_and_sick.call_args[0][0] - assert "suspicious" in call_args.lower() + assert "reason: no_exercises" in call_args.lower() def test_handle_startup_clock_tampered_shows_retry_and_sick( self,