"""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 from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch import pytest from python_pkg.screen_locker.screen_lock import STRONGLIFTS_DB_REMOTE from python_pkg.screen_locker.tests.conftest import create_locker if TYPE_CHECKING: from pathlib import Path class TestRunAdb: """Tests for _run_adb ADB command execution.""" def test_run_adb_success( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, 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._phone_verification.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, 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._phone_verification.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, tmp_path: Path, ) -> None: """Test ADB binary not found.""" locker = create_locker(mock_tk, tmp_path) with patch( "python_pkg.screen_locker._phone_verification.subprocess.run", side_effect=FileNotFoundError("adb not found"), ): success, output = locker._run_adb(["devices"]) assert success is False assert not output def test_run_adb_oserror( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: """Test ADB OSError.""" locker = create_locker(mock_tk, tmp_path) with patch( "python_pkg.screen_locker._phone_verification.subprocess.run", side_effect=OSError("permission denied"), ): success, output = locker._run_adb(["devices"]) assert success is False assert not output def test_run_adb_timeout( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: """Test ADB command timeout.""" locker = create_locker(mock_tk, tmp_path) with patch( "python_pkg.screen_locker._phone_verification.subprocess.run", side_effect=subprocess.TimeoutExpired("adb", 15), ): success, output = locker._run_adb(["devices"]) assert success is False assert not output class TestAdbShell: """Tests for _adb_shell method.""" def test_adb_shell_no_root( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: """Test ADB shell without root.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_run_adb", MagicMock( 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, tmp_path: Path, ) -> None: """Test ADB shell with root.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_run_adb", MagicMock( 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, tmp_path: Path, ) -> None: """Test phone detected as connected.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_run_adb", MagicMock( 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, tmp_path: Path, ) -> None: """Test no phone connected.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_run_adb", MagicMock( return_value=(True, "List of devices attached\n\n"), ), ) object.__setattr__( locker, "_try_wireless_reconnect", MagicMock( return_value=False, ), ) assert locker._is_phone_connected() is False def test_phone_offline( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: """Test phone connected but offline.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_run_adb", MagicMock( return_value=( True, "List of devices attached\nABC123\toffline\n\n", ), ), ) object.__setattr__( locker, "_try_wireless_reconnect", MagicMock( return_value=False, ), ) assert locker._is_phone_connected() is False def test_adb_command_fails( self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path, ) -> None: """Test ADB command failure.""" locker = create_locker(mock_tk, tmp_path) object.__setattr__( locker, "_run_adb", MagicMock( return_value=(False, ""), ), ) object.__setattr__( locker, "_try_wireless_reconnect", MagicMock( 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, 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 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 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)", ) now_ms = int(time.time() * 1000) conn.execute( "INSERT INTO workouts VALUES (?, ?, ?)", ("w1", now_ms - 3600000, now_ms), ) 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