mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 20:27:13 +01:00
Make Check Phone week-aware; add RunnerUp GPS tolerance; fix CLI weekly count
Check Phone only ever checked today's date, so a verified workout from earlier in the week (e.g. yesterday's run, found once the phone connects later) was never backfilled unless you ran the --status CLI. It now falls back to the existing RunnerUp week-scanner plus a new StrongLifts equivalent, still pulling and validating the real file via ADB before crediting anything. GPS distance tracking undershoots on tight turns/tree cover, so a run within 5% of the 5.0 km minimum (e.g. 4.9 km) is now accepted instead of silently rejected — duration is clock-based and gets no such leeway. Also fixes a pre-existing bug in the --status CLI surfaced by this change: its weekly-total summary derived counts from a per-day checkmark (max 1 per day) instead of screen_locker._weekly_check.count_weekly_workouts, undercounting any day with more than one verified workout. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01ERWuV1nExiMfiNb9Gb7AQt
This commit is contained in:
parent
8cb1ce0be8
commit
c277325a3e
@ -60,6 +60,11 @@ RUNNERUP_PACKAGES = ("org.runnerup", "org.runnerup.free")
|
||||
RUNNERUP_DB_SDCARD_TMP = "/sdcard/.runnerup_tmp_verification.db"
|
||||
MIN_RUN_DURATION_MINUTES = 30
|
||||
MIN_RUN_DISTANCE_KM = 5.0
|
||||
# GPS distance tracking undershoots on tight turns/tree cover/urban canyons;
|
||||
# a run within 5% of the minimum is accepted rather than rejected on
|
||||
# measurement noise. Duration comes from the device clock, not GPS, so it
|
||||
# gets no such leeway.
|
||||
RUNNERUP_DISTANCE_TOLERANCE = 0.05
|
||||
RUNNERUP_ACCEPTED_SPORTS: frozenset[int] = frozenset({0, 3, 5})
|
||||
# 0=RUNNING, 3=ORIENTEERING, 5=TREADMILL
|
||||
MAX_CLOCK_SKEW_SECONDS = 300 # 5 minutes max time skew from NTP
|
||||
|
||||
@ -7,6 +7,7 @@ from concurrent.futures import ( # pylint: disable=no-name-in-module
|
||||
as_completed,
|
||||
)
|
||||
import contextlib
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from http import client as _http_client
|
||||
import json
|
||||
import logging
|
||||
@ -23,6 +24,7 @@ from screen_locker._constants import (
|
||||
WORKOUT_APP_JSON_REMOTES,
|
||||
WORKOUT_HTTP_PORT,
|
||||
)
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
from screen_locker._time_check import check_clock_skew
|
||||
from screen_locker._workout_sync import pull_synced_workout
|
||||
|
||||
@ -175,13 +177,15 @@ class PhoneVerificationMixin:
|
||||
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"
|
||||
def _validate_json_content(self, data: dict) -> tuple[str, str]:
|
||||
"""Validate a workout JSON's exercises/duration, regardless of its date.
|
||||
|
||||
Split out of :meth:`_validate_json_data` so the week-scan backfill
|
||||
(``_try_fill_stronglifts_for_week``) can reuse the same content
|
||||
checks against a past-in-week date without the today-only gate.
|
||||
"""
|
||||
if not data.get("exercises"):
|
||||
return "no_exercises", "No exercises found in today's workout JSON"
|
||||
return "no_exercises", "No exercises found in the workout JSON"
|
||||
duration_min = data.get("duration_seconds", 0) / 60.0
|
||||
if duration_min < MIN_WORKOUT_DURATION_MINUTES:
|
||||
return (
|
||||
@ -192,6 +196,63 @@ class PhoneVerificationMixin:
|
||||
flag = "all succeeded" if data.get("succeeded") else "partial"
|
||||
return "verified", f"Workout verified! ({duration_min:.0f} min, {flag})"
|
||||
|
||||
def _validate_json_data(self, data: dict) -> tuple[str, str]:
|
||||
"""Validate parsed workout JSON. Returns (status, message).
|
||||
|
||||
Requires today's date — this is the live unlock-chain check, which
|
||||
must reflect a workout done *right now*. For crediting a workout
|
||||
found dated earlier in the week, see ``_try_fill_stronglifts_for_week``.
|
||||
"""
|
||||
today = time.strftime("%Y-%m-%d")
|
||||
if data.get("date") != today:
|
||||
return "stale", f"Workout JSON is from {data.get('date')}, not today"
|
||||
return self._validate_json_content(data)
|
||||
|
||||
def _try_fill_stronglifts_for_week(self, log_file: Path) -> int:
|
||||
"""Backfill a past-this-week StrongLifts workout found on the phone.
|
||||
|
||||
Unlike RunnerUp's per-activity TCX exports, the phone holds only one
|
||||
``workout_result.json`` snapshot at a time, so this fills at most one
|
||||
day per call — that mirrors :meth:`_scan_and_fill_week_runnerup`'s
|
||||
contract (0 when nothing new to fill) without pretending there could
|
||||
be more than one candidate. Still pulls and validates the real file
|
||||
via ADB before writing anything — never trusts a claim.
|
||||
|
||||
Returns 1 if a new verified entry was appended, else 0 (also 0 when
|
||||
no phone is connected, no JSON is available, or its date falls
|
||||
outside the current ISO week).
|
||||
"""
|
||||
if not self._has_adb_device():
|
||||
_logger.info(
|
||||
"Phone not connected; skipping StrongLifts week-scan backfill."
|
||||
)
|
||||
return 0
|
||||
data = self._pull_workout_app_json()
|
||||
date_str = data.get("date") if data else None
|
||||
if not date_str:
|
||||
return 0
|
||||
today = datetime.now(tz=timezone.utc).astimezone().date()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
try:
|
||||
entry_date = date.fromisoformat(date_str)
|
||||
except ValueError:
|
||||
_logger.warning("StrongLifts JSON has an unparsable date: %r", date_str)
|
||||
return 0
|
||||
if not week_start <= entry_date <= today:
|
||||
return 0
|
||||
status, msg = self._validate_json_content(data)
|
||||
if status != "verified":
|
||||
return 0
|
||||
workout_data = {
|
||||
"type": "phone_verified",
|
||||
"source": f"Auto-scanned: {msg}",
|
||||
"duration_minutes": round(data.get("duration_seconds", 0) / 60.0, 1),
|
||||
}
|
||||
if write_signed_entry(log_file, date_str, workout_data).appended:
|
||||
_logger.info("Auto-filled StrongLifts entry for %s: %s", date_str, msg)
|
||||
return 1
|
||||
return 0
|
||||
|
||||
# ── HTTP fallback (no ADB / developer options required) ───────────────────
|
||||
|
||||
def _scan_for_http_server(self) -> str | None:
|
||||
|
||||
@ -19,6 +19,7 @@ from screen_locker._constants import (
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
MIN_RUN_DURATION_MINUTES,
|
||||
RUNNERUP_ACCEPTED_SPORTS,
|
||||
RUNNERUP_DISTANCE_TOLERANCE,
|
||||
RUNNERUP_EXPORT_DIRS,
|
||||
)
|
||||
from screen_locker._log_mixin import write_signed_entry
|
||||
@ -236,9 +237,17 @@ class RunnerUpVerificationMixin(RunnerUpDbMixin):
|
||||
return "too_short", msg
|
||||
|
||||
distance_km = data["distance_m"] / 1000
|
||||
if distance_km < MIN_RUN_DISTANCE_KM:
|
||||
if distance_km * (1 + RUNNERUP_DISTANCE_TOLERANCE) < MIN_RUN_DISTANCE_KM:
|
||||
msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
|
||||
return "too_short", msg
|
||||
if distance_km < MIN_RUN_DISTANCE_KM:
|
||||
_logger.info(
|
||||
"RunnerUp distance %.2f km is under the %.1f km minimum but "
|
||||
"within %.0f%% GPS tolerance — accepted.",
|
||||
distance_km,
|
||||
MIN_RUN_DISTANCE_KM,
|
||||
RUNNERUP_DISTANCE_TOLERANCE * 100,
|
||||
)
|
||||
|
||||
sport_name = _SPORT_NAMES.get(sport, str(sport))
|
||||
return (
|
||||
|
||||
@ -81,16 +81,19 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
print("=== Weekly Workout Status ===")
|
||||
|
||||
# Per-day breakdown
|
||||
before_count = 0
|
||||
for i in range(7):
|
||||
d = monday + timedelta(days=i)
|
||||
if d > today:
|
||||
break
|
||||
if _print_day_line(d, log_data.get(d.isoformat(), []), sick_days):
|
||||
before_count += 1
|
||||
_print_day_line(d, log_data.get(d.isoformat(), []), sick_days)
|
||||
|
||||
print()
|
||||
|
||||
# The weekly total, per the anti-gaming rule (each verified workout counts
|
||||
# individually, manual counts once/day) — NOT a per-day checkmark count,
|
||||
# which would undercount a day holding two verified workouts.
|
||||
before_count = count_weekly_workouts(log_file)
|
||||
|
||||
# RunnerUp scan
|
||||
n_filled = locker._scan_and_fill_week_runnerup(log_file)
|
||||
if n_filled > 0:
|
||||
|
||||
@ -9,9 +9,11 @@ screen's unlock does.
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-module
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -22,6 +24,23 @@ if TYPE_CHECKING:
|
||||
from screen_locker.screen_lock import ScreenLocker
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PhoneCheckOutcome:
|
||||
"""Result of the "Check Phone" button's verify-then-backfill chain.
|
||||
|
||||
``credited_type`` is ``"phone_verified"`` / ``"runnerup_verified"`` when
|
||||
*today's* workout verified directly, or ``None`` otherwise (whether or
|
||||
not the week-scan backfill found something — that's reported separately
|
||||
via ``week_fill_message``, since it applies its own credit already).
|
||||
"""
|
||||
|
||||
credited_type: str | None
|
||||
status: str
|
||||
phone_message: str
|
||||
runnerup_message: str | None
|
||||
week_fill_message: str | None = None
|
||||
|
||||
|
||||
def _make_bare_verifier(log_file: Path) -> ScreenLocker:
|
||||
"""Build a minimal ``ScreenLocker`` for verification calls.
|
||||
|
||||
@ -37,29 +56,58 @@ def _make_bare_verifier(log_file: Path) -> ScreenLocker:
|
||||
return verifier
|
||||
|
||||
|
||||
def _verify_phone_then_runnerup(
|
||||
verifier: ScreenLocker,
|
||||
) -> tuple[str | None, str, str, str | None]:
|
||||
"""Check StrongLifts first, then RunnerUp as a fallback (worker-thread safe).
|
||||
def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
|
||||
"""Scan the current week for unlogged RunnerUp/StrongLifts and credit it.
|
||||
|
||||
Reuses the exact prev/new-count + ``_adjust_shutdown_time_by`` bonus
|
||||
pattern already proven by the automatic lock-check
|
||||
(``screen_lock.py:_auto_fill_week_runnerup_bonus``) and the ``--status``
|
||||
CLI (``_status.py``) — deliberately *not* routed through
|
||||
``_apply_workout_credit``, which always files under today by design and
|
||||
would mis-date a backfilled entry.
|
||||
|
||||
Returns a human-readable summary if anything was filled, else ``None``.
|
||||
"""
|
||||
prev_count = count_weekly_workouts(verifier.log_file)
|
||||
filled = verifier._scan_and_fill_week_runnerup(verifier.log_file)
|
||||
filled += verifier._try_fill_stronglifts_for_week(verifier.log_file)
|
||||
if not filled:
|
||||
return None
|
||||
new_count = count_weekly_workouts(verifier.log_file)
|
||||
bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
|
||||
if bonus > 0:
|
||||
verifier._adjust_shutdown_time_by(bonus)
|
||||
plural = "workout" if filled == 1 else "workouts"
|
||||
message = f"Auto-filled {filled} {plural} from earlier this week."
|
||||
if bonus > 0:
|
||||
message += f" +{bonus}h shutdown time."
|
||||
return message
|
||||
|
||||
|
||||
def _verify_phone_then_runnerup(verifier: ScreenLocker) -> PhoneCheckOutcome:
|
||||
"""Check StrongLifts first, then RunnerUp, then backfill the rest of the week.
|
||||
|
||||
Mirrors the locked screen's StrongLifts→RunnerUp chain (``_ui_flows.py``):
|
||||
a user may have run instead of lifted, so a failed phone check falls back
|
||||
to today's RunnerUp export. Only calls the verifier's ADB methods — never
|
||||
touches Tk — so it is safe to run inside a background thread.
|
||||
|
||||
Returns ``(credited_type, status, phone_message, runnerup_message)`` where
|
||||
``credited_type`` is ``"phone_verified"`` / ``"runnerup_verified"`` on a
|
||||
verified workout, or ``None`` when neither source verified.
|
||||
``runnerup_message`` is ``None`` when StrongLifts verified and RunnerUp was
|
||||
never consulted.
|
||||
to today's RunnerUp export. When neither source verifies *today*, this
|
||||
also scans the rest of the current ISO week for an unlogged, already-real
|
||||
(ADB-pulled, TCX/JSON-verified) workout — e.g. a run from yesterday that
|
||||
was never on-screen when the phone wasn't connected yet — and credits it
|
||||
directly. Only calls the verifier's ADB methods — never touches Tk — so
|
||||
it is safe to run inside a background thread.
|
||||
"""
|
||||
phone_status, phone_message = verifier._verify_phone_workout()
|
||||
if phone_status == "verified":
|
||||
return "phone_verified", phone_status, phone_message, None
|
||||
return PhoneCheckOutcome("phone_verified", phone_status, phone_message, None)
|
||||
runnerup_status, runnerup_message = verifier._verify_runnerup_workout()
|
||||
if runnerup_status == "verified":
|
||||
return "runnerup_verified", runnerup_status, phone_message, runnerup_message
|
||||
return None, runnerup_status, phone_message, runnerup_message
|
||||
return PhoneCheckOutcome(
|
||||
"runnerup_verified", runnerup_status, phone_message, runnerup_message
|
||||
)
|
||||
week_fill_message = _backfill_week_and_apply_bonus(verifier)
|
||||
return PhoneCheckOutcome(
|
||||
None, runnerup_status, phone_message, runnerup_message, week_fill_message
|
||||
)
|
||||
|
||||
|
||||
class PhoneCheckMixin:
|
||||
@ -72,7 +120,7 @@ class PhoneCheckMixin:
|
||||
|
||||
log_file: Path
|
||||
verifier_factory: Callable[[Path], ScreenLocker]
|
||||
_phone_future: Future[tuple[str | None, str, str, str | None]] | None
|
||||
_phone_future: Future[PhoneCheckOutcome] | None
|
||||
_phone_check_result: tuple[str, str] | None
|
||||
_credit_message: str | None
|
||||
|
||||
@ -82,7 +130,9 @@ class PhoneCheckMixin:
|
||||
Runs the two-step check in one background worker so the Tk loop stays
|
||||
responsive, then polls the future. Unlike the old display-only probe,
|
||||
a verified result here writes the entry and applies the shutdown bonus
|
||||
(see :meth:`_on_phone_check_result`).
|
||||
(see :meth:`_on_phone_check_result`). When neither source verifies
|
||||
today, the same worker also backfills any unlogged workout from
|
||||
earlier this week before giving up.
|
||||
"""
|
||||
self._phone_check_result = None
|
||||
self._credit_message = None
|
||||
@ -95,44 +145,50 @@ class PhoneCheckMixin:
|
||||
def _poll_phone_check(self) -> None:
|
||||
"""Poll the background verification future until it resolves."""
|
||||
if self._phone_future is not None and self._phone_future.done():
|
||||
self._on_phone_check_result(*self._phone_future.result())
|
||||
self._on_phone_check_result(self._phone_future.result())
|
||||
else:
|
||||
self.root.after(500, self._poll_phone_check)
|
||||
|
||||
def _on_phone_check_result(
|
||||
self,
|
||||
credited_type: str | None,
|
||||
status: str,
|
||||
phone_message: str,
|
||||
runnerup_message: str | None,
|
||||
) -> None:
|
||||
def _on_phone_check_result(self, outcome: PhoneCheckOutcome) -> None:
|
||||
"""Apply credit on a verified workout, else show a combined failure.
|
||||
|
||||
On a verified result this writes the entry and applies the reward via
|
||||
``_apply_workout_credit`` — the same path the locked screen's
|
||||
``unlock_screen`` and the "Log Manual Workout" button use, guarded
|
||||
against double-crediting on the same day. When neither source verified,
|
||||
it shows both messages so the user knows why nothing counted.
|
||||
On a verified *today* result this writes the entry and applies the
|
||||
reward via ``_apply_workout_credit`` — the same path the locked
|
||||
screen's ``unlock_screen`` and the "Log Manual Workout" button use,
|
||||
guarded against double-crediting on the same day. When the week-scan
|
||||
backfill found something instead (see ``_verify_phone_then_runnerup``),
|
||||
its message is shown as-is — that step already wrote the entry and
|
||||
applied its own bonus, applying credit again here would double it.
|
||||
When nothing verified or filled, both today-check messages are shown
|
||||
so the user knows why nothing counted.
|
||||
"""
|
||||
if credited_type is not None:
|
||||
if outcome.credited_type is not None:
|
||||
message = (
|
||||
runnerup_message
|
||||
if credited_type == "runnerup_verified"
|
||||
else phone_message
|
||||
outcome.runnerup_message
|
||||
if outcome.credited_type == "runnerup_verified"
|
||||
else outcome.phone_message
|
||||
)
|
||||
source_label = (
|
||||
"RunnerUp" if credited_type == "runnerup_verified" else "StrongLifts"
|
||||
"RunnerUp"
|
||||
if outcome.credited_type == "runnerup_verified"
|
||||
else "StrongLifts"
|
||||
)
|
||||
verifier = self.verifier_factory(self.log_file)
|
||||
verifier.workout_data = {"type": credited_type, "source": message or ""}
|
||||
verifier.workout_data = {
|
||||
"type": outcome.credited_type,
|
||||
"source": message or "",
|
||||
}
|
||||
credit = verifier._apply_workout_credit()
|
||||
self._credit_message = "\n".join(
|
||||
self._credit_result_lines(f"{source_label} verified: {message}", credit)
|
||||
)
|
||||
elif outcome.week_fill_message is not None:
|
||||
self._credit_message = outcome.week_fill_message
|
||||
else:
|
||||
self._phone_check_result = (
|
||||
status,
|
||||
f"StrongLifts: {phone_message} · RunnerUp: {runnerup_message}",
|
||||
outcome.status,
|
||||
f"StrongLifts: {outcome.phone_message} · "
|
||||
f"RunnerUp: {outcome.runnerup_message}",
|
||||
)
|
||||
self.render(gather_status())
|
||||
|
||||
|
||||
146
screen_locker/tests/test_phone_verification_part5.py
Normal file
146
screen_locker/tests/test_phone_verification_part5.py
Normal file
@ -0,0 +1,146 @@
|
||||
"""Tests for _try_fill_stronglifts_for_week (StrongLifts week-scan backfill)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import json
|
||||
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 TestTryFillStronglifitsForWeek:
|
||||
"""Tests for _try_fill_stronglifts_for_week (week-scan backfill)."""
|
||||
|
||||
def test_returns_zero_when_no_phone(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=False))
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
|
||||
def test_returns_zero_when_no_json_pulled(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
|
||||
object.__setattr__(
|
||||
locker, "_pull_workout_app_json", MagicMock(return_value=None)
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
|
||||
def test_returns_zero_when_date_unparsable(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_pull_workout_app_json",
|
||||
MagicMock(return_value={"date": "not-a-date", "exercises": ["x"]}),
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
|
||||
def test_returns_zero_when_date_outside_current_week(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A stale JSON from before this ISO week is not backfilled."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
last_week = (date.today() - timedelta(days=8)).isoformat()
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_pull_workout_app_json",
|
||||
MagicMock(
|
||||
return_value={
|
||||
"date": last_week,
|
||||
"exercises": ["x"],
|
||||
"duration_seconds": 6000,
|
||||
"succeeded": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
|
||||
def test_returns_zero_when_content_invalid(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A this-week date with no exercises still fails content validation."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_pull_workout_app_json",
|
||||
MagicMock(return_value={"date": yesterday, "exercises": []}),
|
||||
)
|
||||
assert locker._try_fill_stronglifts_for_week(log_file) == 0
|
||||
|
||||
def test_fills_gap_for_past_week_day(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
"""A valid, this-week, not-yet-logged JSON gets appended and returns 1."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_pull_workout_app_json",
|
||||
MagicMock(
|
||||
return_value={
|
||||
"date": yesterday,
|
||||
"exercises": ["x"],
|
||||
"duration_seconds": 6000,
|
||||
"succeeded": True,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"):
|
||||
result = locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
assert result == 1
|
||||
logs = json.loads(log_file.read_text())
|
||||
entry = logs[yesterday][0]
|
||||
assert entry["workout_data"]["type"] == "phone_verified"
|
||||
assert entry["workout_id"] == f"phone_verified:{yesterday}"
|
||||
|
||||
def test_rescanning_an_already_filled_day_is_a_no_op(
|
||||
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
|
||||
) -> None:
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text("{}")
|
||||
yesterday = (date.today() - timedelta(days=1)).isoformat()
|
||||
data = {
|
||||
"date": yesterday,
|
||||
"exercises": ["x"],
|
||||
"duration_seconds": 6000,
|
||||
"succeeded": True,
|
||||
}
|
||||
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
|
||||
object.__setattr__(
|
||||
locker, "_pull_workout_app_json", MagicMock(return_value=data)
|
||||
)
|
||||
|
||||
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value="sig"):
|
||||
first = locker._try_fill_stronglifts_for_week(log_file)
|
||||
second = locker._try_fill_stronglifts_for_week(log_file)
|
||||
|
||||
assert (first, second) == (1, 0)
|
||||
@ -75,6 +75,57 @@ def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
|
||||
return str(p)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_runnerup_data — GPS distance tolerance
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestValidateRunnerupDataDistanceTolerance:
|
||||
"""A run just under MIN_RUN_DISTANCE_KM is accepted within 5% GPS tolerance."""
|
||||
|
||||
def test_distance_within_tolerance_is_verified(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""4.9 km * 1.05 = 5.145 >= 5.0 km minimum → verified."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
status, msg = locker._validate_runnerup_data(
|
||||
{"sport": 0, "duration_seconds": 1947, "distance_m": 4929.05}
|
||||
)
|
||||
assert status == "verified"
|
||||
assert "4.9 km" in msg
|
||||
|
||||
def test_distance_below_tolerance_is_too_short(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""4.0 km * 1.05 = 4.2 km, still short of the 5.0 km minimum → too_short."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
status, msg = locker._validate_runnerup_data(
|
||||
{"sport": 0, "duration_seconds": 1947, "distance_m": 4000}
|
||||
)
|
||||
assert status == "too_short"
|
||||
assert "km" in msg
|
||||
|
||||
def test_distance_at_full_minimum_is_verified(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A run already meeting the minimum outright still passes (tolerance is
|
||||
additive leeway, not a stricter requirement)."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
status, _ = locker._validate_runnerup_data(
|
||||
{"sport": 0, "duration_seconds": 1947, "distance_m": 5000}
|
||||
)
|
||||
assert status == "verified"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# _validate_runnerup_data
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@ -242,3 +242,46 @@ class TestRunStatusWorkoutLog:
|
||||
run_status(locker)
|
||||
out = capsys.readouterr().out
|
||||
assert "heat_skip" in out
|
||||
|
||||
|
||||
class TestWeeklyTotalUsesPerWorkoutRule:
|
||||
"""The final summary must count each verified workout, not days-with-any."""
|
||||
|
||||
def test_multiple_same_day_workouts_all_count(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""One day with 1 manual + 3 verified entries = 4, not capped at 1.
|
||||
|
||||
Regression test: the summary used to derive its total from a
|
||||
per-day checkmark count (max 1 per day), undercounting any day
|
||||
holding more than one counted workout — exactly what multi-workout
|
||||
days are supposed to support.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
||||
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
|
||||
log_file = tmp_path / "log.json"
|
||||
log_file.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
today: [
|
||||
{"workout_data": {"type": "manual_workout", "source": "gym"}},
|
||||
{"workout_data": {"type": "runnerup_verified", "source": "a"}},
|
||||
{"workout_data": {"type": "phone_verified", "source": "b"}},
|
||||
{"workout_data": {"type": "runnerup_verified", "source": "c"}},
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
eb_file = tmp_path / "eb.json"
|
||||
# n_filled=0 so the summary is derived purely from before_count.
|
||||
locker = _make_locker(log_file, n_filled=0)
|
||||
with (
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
out = capsys.readouterr().out
|
||||
assert "Weekly minimum met exactly (4/4)." in out
|
||||
|
||||
@ -23,20 +23,25 @@ class TestRunStatusFill:
|
||||
def test_fill_with_bonus_applied(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown."""
|
||||
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown.
|
||||
|
||||
count_weekly_workouts is called twice: before the fill (3) and after
|
||||
(5) — a flat return_value would collapse both calls to the same
|
||||
number and mask the bonus math, so this uses side_effect instead.
|
||||
"""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=2, bonus_applied=True)
|
||||
# after_count=5 (> WEEKLY_WORKOUT_MINIMUM=4), before_count=3
|
||||
with (
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", return_value=5),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[3, 5]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
out = capsys.readouterr().out
|
||||
assert "Auto-filled 2 workout(s)" in out
|
||||
assert "+1h shutdown bonus applied." in out
|
||||
|
||||
def test_fill_bonus_pending_when_adjust_fails(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
@ -48,7 +53,7 @@ class TestRunStatusFill:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", return_value=5),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
@ -58,14 +63,14 @@ class TestRunStatusFill:
|
||||
def test_fill_no_bonus_when_still_below_min(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""n_filled=1 but count still < 4 → no bonus line."""
|
||||
"""n_filled=1 but count still < 4, before and after the fill → no bonus."""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
|
||||
with (
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", return_value=3),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[2, 3]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
@ -82,7 +87,7 @@ class TestRunStatusMinimumStatus:
|
||||
"""after_count > WEEKLY_WORKOUT_MINIMUM → 'above minimum' line.
|
||||
|
||||
n_filled=1 triggers the count_weekly_workouts() branch so after_count
|
||||
is taken from that mock (5), not from the per-day log loop (0).
|
||||
is taken from the second mocked call (5), not the first (before_count).
|
||||
"""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
|
||||
@ -90,7 +95,7 @@ class TestRunStatusMinimumStatus:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", return_value=5),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 5]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
@ -111,7 +116,7 @@ class TestRunStatusMinimumStatus:
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.current_streak", return_value=0),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=False),
|
||||
patch("screen_locker._status.count_weekly_workouts", return_value=4),
|
||||
patch("screen_locker._status.count_weekly_workouts", side_effect=[0, 4]),
|
||||
patch("sys.exit"),
|
||||
):
|
||||
run_status(locker)
|
||||
|
||||
@ -2,10 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._compliance_state import AutoUpgradeOpportunity
|
||||
from screen_locker._status_view_verify import _verify_phone_then_runnerup
|
||||
from screen_locker._status_view_verify import (
|
||||
PhoneCheckOutcome,
|
||||
_backfill_week_and_apply_bonus,
|
||||
_verify_phone_then_runnerup,
|
||||
)
|
||||
from screen_locker.tests._status_view_helpers import (
|
||||
_day,
|
||||
_lock_explanation,
|
||||
@ -189,7 +193,9 @@ class TestVerifyPhoneThenRunnerup:
|
||||
|
||||
result = _verify_phone_then_runnerup(verifier)
|
||||
|
||||
assert result == ("phone_verified", "verified", "5x5 done", None)
|
||||
assert result == PhoneCheckOutcome(
|
||||
"phone_verified", "verified", "5x5 done", None
|
||||
)
|
||||
verifier._verify_runnerup_workout.assert_not_called()
|
||||
|
||||
def test_falls_back_to_runnerup_when_phone_not_verified(self) -> None:
|
||||
@ -200,14 +206,89 @@ class TestVerifyPhoneThenRunnerup:
|
||||
|
||||
result = _verify_phone_then_runnerup(verifier)
|
||||
|
||||
assert result == ("runnerup_verified", "verified", "stale", "9.8 km")
|
||||
assert result == PhoneCheckOutcome(
|
||||
"runnerup_verified", "verified", "stale", "9.8 km"
|
||||
)
|
||||
|
||||
def test_neither_source_verified(self) -> None:
|
||||
"""Neither verified → no credited type, both messages carried back."""
|
||||
def test_neither_source_verified_nor_backfilled(self) -> None:
|
||||
"""Neither verified today, nothing to backfill → no credit, no fill message."""
|
||||
verifier = MagicMock()
|
||||
verifier._verify_phone_workout.return_value = ("not_verified", "stale")
|
||||
verifier._verify_runnerup_workout.return_value = ("too_short", "3 km")
|
||||
verifier._scan_and_fill_week_runnerup.return_value = 0
|
||||
verifier._try_fill_stronglifts_for_week.return_value = 0
|
||||
|
||||
result = _verify_phone_then_runnerup(verifier)
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.count_weekly_workouts",
|
||||
return_value=2,
|
||||
):
|
||||
result = _verify_phone_then_runnerup(verifier)
|
||||
|
||||
assert result == (None, "too_short", "stale", "3 km")
|
||||
assert result == PhoneCheckOutcome(None, "too_short", "stale", "3 km", None)
|
||||
|
||||
def test_falls_back_to_week_scan_backfill(self) -> None:
|
||||
"""Neither verified today, but the week-scan finds something → fill message."""
|
||||
verifier = MagicMock()
|
||||
verifier._verify_phone_workout.return_value = ("not_verified", "stale")
|
||||
verifier._verify_runnerup_workout.return_value = ("not_verified", "no run")
|
||||
verifier._scan_and_fill_week_runnerup.return_value = 1
|
||||
verifier._try_fill_stronglifts_for_week.return_value = 0
|
||||
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.count_weekly_workouts",
|
||||
side_effect=[2, 3],
|
||||
):
|
||||
result = _verify_phone_then_runnerup(verifier)
|
||||
|
||||
assert result.credited_type is None
|
||||
assert (
|
||||
result.week_fill_message == "Auto-filled 1 workout from earlier this week."
|
||||
)
|
||||
verifier._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
|
||||
class TestBackfillWeekAndApplyBonus:
|
||||
"""The week-scan fallback: backfill unlogged days, apply the earned bonus."""
|
||||
|
||||
def test_nothing_filled_returns_none(self) -> None:
|
||||
verifier = MagicMock()
|
||||
verifier._scan_and_fill_week_runnerup.return_value = 0
|
||||
verifier._try_fill_stronglifts_for_week.return_value = 0
|
||||
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.count_weekly_workouts",
|
||||
return_value=2,
|
||||
):
|
||||
assert _backfill_week_and_apply_bonus(verifier) is None
|
||||
verifier._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
def test_fill_at_or_below_minimum_earns_no_bonus(self) -> None:
|
||||
"""Filling up to (not past) the weekly minimum earns no shutdown push."""
|
||||
verifier = MagicMock()
|
||||
verifier._scan_and_fill_week_runnerup.return_value = 1
|
||||
verifier._try_fill_stronglifts_for_week.return_value = 0
|
||||
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.count_weekly_workouts",
|
||||
side_effect=[3, 4],
|
||||
):
|
||||
message = _backfill_week_and_apply_bonus(verifier)
|
||||
|
||||
assert message == "Auto-filled 1 workout from earlier this week."
|
||||
verifier._adjust_shutdown_time_by.assert_not_called()
|
||||
|
||||
def test_fill_past_minimum_applies_bonus(self) -> None:
|
||||
verifier = MagicMock()
|
||||
verifier._scan_and_fill_week_runnerup.return_value = 1
|
||||
verifier._try_fill_stronglifts_for_week.return_value = 1
|
||||
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.count_weekly_workouts",
|
||||
side_effect=[4, 6],
|
||||
):
|
||||
message = _backfill_week_and_apply_bonus(verifier)
|
||||
|
||||
assert message == (
|
||||
"Auto-filled 2 workouts from earlier this week. +2h shutdown time."
|
||||
)
|
||||
verifier._adjust_shutdown_time_by.assert_called_once_with(2)
|
||||
|
||||
@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._status_view_verify import PhoneCheckOutcome
|
||||
from screen_locker._workout_credit import WorkoutCreditResult
|
||||
from screen_locker.status_view import (
|
||||
StatusWindow,
|
||||
@ -101,16 +102,6 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
window._on_check_phone_clicked()
|
||||
assert window._phone_future is not None
|
||||
|
||||
def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None:
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
mock_future = MagicMock()
|
||||
mock_future.done.return_value = True
|
||||
mock_future.result.return_value = ("verified", "ok")
|
||||
window._phone_future = mock_future
|
||||
with patch.object(window, "_on_phone_check_result") as mock_handle:
|
||||
window._poll_phone_check()
|
||||
mock_handle.assert_called_once_with("verified", "ok")
|
||||
|
||||
def test_poll_waits_when_future_not_done(self, mock_tk: MagicMock) -> None:
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
mock_future = MagicMock()
|
||||
@ -136,7 +127,9 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.gather_status", return_value=_snapshot()
|
||||
) as mock_gather:
|
||||
window._on_phone_check_result(None, "no_phone", "no lifts", "no run")
|
||||
window._on_phone_check_result(
|
||||
PhoneCheckOutcome(None, "no_phone", "no lifts", "no run")
|
||||
)
|
||||
assert window._phone_check_result == (
|
||||
"no_phone",
|
||||
"StrongLifts: no lifts · RunnerUp: no run",
|
||||
@ -168,7 +161,7 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
"screen_locker._status_view_verify.gather_status", return_value=_snapshot()
|
||||
):
|
||||
window._on_phone_check_result(
|
||||
"phone_verified", "verified", "StrongLifts ok", None
|
||||
PhoneCheckOutcome("phone_verified", "verified", "StrongLifts ok", None)
|
||||
)
|
||||
assert fake_verifier.workout_data == {
|
||||
"type": "phone_verified",
|
||||
@ -205,7 +198,9 @@ class TestRefreshAndCheckPhoneFlow:
|
||||
"screen_locker._status_view_verify.gather_status", return_value=_snapshot()
|
||||
):
|
||||
window._on_phone_check_result(
|
||||
"runnerup_verified", "verified", "no lifts", "RunnerUp 5.0 km"
|
||||
PhoneCheckOutcome(
|
||||
"runnerup_verified", "verified", "no lifts", "RunnerUp 5.0 km"
|
||||
)
|
||||
)
|
||||
assert fake_verifier.workout_data == {
|
||||
"type": "runnerup_verified",
|
||||
|
||||
50
screen_locker/tests/test_status_view_part3.py
Normal file
50
screen_locker/tests/test_status_view_part3.py
Normal file
@ -0,0 +1,50 @@
|
||||
"""Tests for status_view.py: Check Phone's week-scan backfill reporting."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from screen_locker._status_view_verify import PhoneCheckOutcome
|
||||
from screen_locker.tests._status_view_helpers import _make_window, _snapshot
|
||||
|
||||
|
||||
class TestPollRoutesPhoneCheckOutcome:
|
||||
def test_poll_routes_to_result_when_future_done(self, mock_tk: MagicMock) -> None:
|
||||
window = _make_window(mock_tk, _snapshot())
|
||||
mock_future = MagicMock()
|
||||
mock_future.done.return_value = True
|
||||
mock_future.result.return_value = PhoneCheckOutcome(
|
||||
"phone_verified", "verified", "ok", None
|
||||
)
|
||||
window._phone_future = mock_future
|
||||
with patch.object(window, "_on_phone_check_result") as mock_handle:
|
||||
window._poll_phone_check()
|
||||
mock_handle.assert_called_once_with(mock_future.result.return_value)
|
||||
|
||||
|
||||
class TestWeekScanBackfillResult:
|
||||
def test_week_scan_backfill_shows_fill_message_and_applies_no_credit(
|
||||
self, mock_tk: MagicMock
|
||||
) -> None:
|
||||
"""A week-scan backfill message is shown as-is, no _apply_workout_credit call."""
|
||||
fake_verifier = MagicMock()
|
||||
window = _make_window(
|
||||
mock_tk,
|
||||
_snapshot(),
|
||||
verifier_factory=lambda _log_file: fake_verifier,
|
||||
)
|
||||
with patch(
|
||||
"screen_locker._status_view_verify.gather_status", return_value=_snapshot()
|
||||
):
|
||||
window._on_phone_check_result(
|
||||
PhoneCheckOutcome(
|
||||
None,
|
||||
"not_verified",
|
||||
"no lifts",
|
||||
"no run",
|
||||
"Auto-filled 1 workout from earlier this week.",
|
||||
)
|
||||
)
|
||||
assert window._credit_message == "Auto-filled 1 workout from earlier this week."
|
||||
assert window._phone_check_result is None
|
||||
fake_verifier._apply_workout_credit.assert_not_called()
|
||||
Loading…
Reference in New Issue
Block a user