Make Check Phone week-aware; add RunnerUp GPS tolerance; fix CLI weekly count
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run

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:
Krzysztof Rudnicki 2026-07-18 06:55:55 +02:00
parent 8cb1ce0be8
commit c277325a3e
12 changed files with 583 additions and 78 deletions

View File

@ -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

View File

@ -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:

View File

@ -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 (

View File

@ -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:

View File

@ -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())

View 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)

View File

@ -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
# ---------------------------------------------------------------------------

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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",

View 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()