diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 2676443..514b95f 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -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 diff --git a/screen_locker/_phone_verification.py b/screen_locker/_phone_verification.py index bfb890a..0558d8b 100644 --- a/screen_locker/_phone_verification.py +++ b/screen_locker/_phone_verification.py @@ -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: diff --git a/screen_locker/_runnerup_verification.py b/screen_locker/_runnerup_verification.py index 52e0a7e..b3e7f1b 100644 --- a/screen_locker/_runnerup_verification.py +++ b/screen_locker/_runnerup_verification.py @@ -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 ( diff --git a/screen_locker/_status.py b/screen_locker/_status.py index 771230d..6b99317 100644 --- a/screen_locker/_status.py +++ b/screen_locker/_status.py @@ -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: diff --git a/screen_locker/_status_view_verify.py b/screen_locker/_status_view_verify.py index a318888..b546585 100644 --- a/screen_locker/_status_view_verify.py +++ b/screen_locker/_status_view_verify.py @@ -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()) diff --git a/screen_locker/tests/test_phone_verification_part5.py b/screen_locker/tests/test_phone_verification_part5.py new file mode 100644 index 0000000..8a4f910 --- /dev/null +++ b/screen_locker/tests/test_phone_verification_part5.py @@ -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) diff --git a/screen_locker/tests/test_runnerup_verification_part2.py b/screen_locker/tests/test_runnerup_verification_part2.py index ce05e29..5c7939d 100644 --- a/screen_locker/tests/test_runnerup_verification_part2.py +++ b/screen_locker/tests/test_runnerup_verification_part2.py @@ -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 # --------------------------------------------------------------------------- diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index 12f3937..2a0a61f 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -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 diff --git a/screen_locker/tests/test_status_part2.py b/screen_locker/tests/test_status_part2.py index 3055a5f..bd14829 100644 --- a/screen_locker/tests/test_status_part2.py +++ b/screen_locker/tests/test_status_part2.py @@ -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) diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py index 20978ec..5f6902a 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -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) diff --git a/screen_locker/tests/test_status_view_part2.py b/screen_locker/tests/test_status_view_part2.py index 6146656..0e6f64d 100644 --- a/screen_locker/tests/test_status_view_part2.py +++ b/screen_locker/tests/test_status_view_part2.py @@ -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", diff --git a/screen_locker/tests/test_status_view_part3.py b/screen_locker/tests/test_status_view_part3.py new file mode 100644 index 0000000..e4291c1 --- /dev/null +++ b/screen_locker/tests/test_status_view_part3.py @@ -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()