mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 12:43:12 +02:00
fix(screen_locker): parse exercises from JSON column, show reason in suspicious message
- Rewrite _get_today_exercise_count() to parse JSON from workouts.exercises column instead of broken JOIN on exercises definition table - Show actual reason (stale/no_exercises) instead of generic 'suspicious' - Fix pylint issues: generated-members regex for mock assertions, design limits for mixins/tests, concurrent.futures no-name-in-module disable, implicit booleanness in assertions, module-level pylint disables in tests - Add pytest to pre-commit pylint additional_dependencies - Add tests for missing exercises column, null/malformed JSON, nameless exercise entries
This commit is contained in:
parent
1322700cc8
commit
7f2d2c4c39
@ -155,6 +155,7 @@ repos:
|
||||
- --fail-under=8.0
|
||||
- --jobs=0
|
||||
additional_dependencies:
|
||||
- pytest
|
||||
- python-chess
|
||||
- requests
|
||||
- pygame
|
||||
|
||||
@ -180,18 +180,31 @@ enable = "all"
|
||||
disable = []
|
||||
|
||||
[tool.pylint.design]
|
||||
# A class with just run() as public API is valid for games/apps
|
||||
min-public-methods = 1
|
||||
# Enforce maximum file length of 500 lines
|
||||
max-module-lines = 500
|
||||
# Mixins and single-entry-point classes may have zero public methods
|
||||
min-public-methods = 0
|
||||
# Test modules can be large
|
||||
max-module-lines = 1000
|
||||
# UI/mixin classes accumulate attributes across multiple mixins
|
||||
max-attributes = 10
|
||||
|
||||
[tool.pylint.spelling]
|
||||
# No spelling dictionary to avoid false positives
|
||||
spelling-dict = ""
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
# cv2 (OpenCV) dynamically loads members from C extension at runtime
|
||||
generated-members = ["cv2.*"]
|
||||
# cv2 (OpenCV) dynamically loads members from C extension at runtime.
|
||||
# unittest.mock.MagicMock generates assertion/introspection methods at runtime.
|
||||
generated-members = [
|
||||
"cv2.*",
|
||||
".*\\.assert_called_once_with",
|
||||
".*\\.assert_called_once",
|
||||
".*\\.assert_called",
|
||||
".*\\.assert_not_called",
|
||||
".*\\.assert_any_call",
|
||||
".*\\.call_args",
|
||||
".*\\.call_args_list",
|
||||
".*\\.call_count",
|
||||
]
|
||||
|
||||
# ============================================================================
|
||||
# BANDIT - Security linter
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user