Add auto-fill RunnerUp scan, carrot bonuses, and --status interface

- Refactor RunnerUp verification: extract RunnerUpDbMixin (_runnerup_db.py),
  split _scan_and_fill_week_runnerup into a helper _try_fill_runnerup_for_date
  to keep cyclomatic complexity ≤10
- Generalise TCX lookup to any date in the ISO week (was today-only); all gap
  days Mon→today auto-filled on every startup and 08:30 timer firing
- Add _adjust_shutdown_time_by(): +1h per extra workout beyond the 4-workout
  minimum, capped at midnight (hour=24)
- Add _shutdown_base.py: daily reset of shutdown config to a stored base so
  the bonus doesn't silently accumulate across days
- Add _extra_benefits.py: streak tracking, skip credits (earn (n-4) credits
  for 5+ workout weeks), early-bird extension to 09:00 for eligible weeks
- Add --status mode (_status.py): non-locking CLI view showing per-day
  breakdown (✓/✗), RunnerUp auto-scan, bonus status, shutdown time, streak,
  skip credits, and early-bird status
- Hook carrot into _check_non_verify_exits: bonus applied whenever auto-fill
  pushes weekly count above the minimum
- Pass all pre-commit hooks (ruff, mypy, pylint, bandit, shellcheck,
  codespell, max-file-length); 508 tests at 100% branch coverage

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017auyHmf2ZwQcDAwXaSo7KX
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-06-28 08:08:35 +02:00
parent 909f035b49
commit 74a8bd7529
40 changed files with 4212 additions and 438 deletions

View File

@ -2,3 +2,4 @@ do NOT run tests unless specifically instructed to do so or before committing
If tests fail on the same issue twice in a row, STOP and ask the user how to proceed instead of continuing to fix and retry.
ALWAYS confirm that the feature you add / bug you fixed behaves as it should by running the program after your changes (not tests!) and inspecting output comparing it with what user wanted, after confirming by yourself ask user if the program behaves as they intended
After running tests fix all coverage gaps and issues, do not ignore unless specifically instructed to do so
You are NOT done until you install the new version on the phone itself (flutter install --debug from the workout_app directory, then adb shell monkey -p com.kuhy.workout_app to launch).

View File

@ -2,7 +2,6 @@
# Install workout locker as a systemd user service
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SCREEN_LOCK_PATH="$SCRIPT_DIR/screen_lock.py"
SERVICE_FILE="$SCRIPT_DIR/workout-locker.service"
EARLY_BIRD_TIMER_FILE="$SCRIPT_DIR/early-bird-workout-check.timer"
USER_SERVICE_DIR="$HOME/.config/systemd/user"
@ -32,6 +31,13 @@ if systemctl --user is-active "workout-locker.timer" &>/dev/null; then
fi
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
# Seed shutdown_base.json with base=21 if not already present
SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json"
if [[ ! -f "$SHUTDOWN_BASE" ]]; then
printf '{\n "base_mon_wed_hour": 21,\n "base_thu_sun_hour": 21,\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
echo "✓ Created shutdown_base.json with base=21:00"
fi
# Copy service file to user systemd directory
cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
@ -39,10 +45,10 @@ cp "$SERVICE_FILE" "$USER_SERVICE_DIR/$SERVICE_NAME"
cp "$EARLY_BIRD_TIMER_FILE" "$USER_SERVICE_DIR/$EARLY_BIRD_TIMER_NAME"
# Update paths in the service file to use absolute paths
REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)"
REPO_ROOT="$SCRIPT_DIR"
sed -i "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
sed -i "s|Environment=PYTHONPATH=.*|Environment=PYTHONPATH=$REPO_ROOT|" "$USER_SERVICE_DIR/$SERVICE_NAME"
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
sed -i "s|ExecStart=/usr/bin/python3.*|ExecStart=/usr/bin/python3 -m screen_locker.screen_lock --production|" "$USER_SERVICE_DIR/$SERVICE_NAME"
# Reload systemd daemon
systemctl --user daemon-reload
@ -83,10 +89,10 @@ else
echo " i3 autostart: not installed"
echo ""
echo "To add i3 startup hook (recommended), add this line to $I3_CONFIG:"
echo " exec --no-startup-id /usr/bin/python3 -m python_pkg.screen_locker.screen_lock --production"
echo " exec --no-startup-id /usr/bin/python3 -m screen_locker.screen_lock --production"
fi
# Immediately check if today's workout is done; block if not
echo ""
echo "=== Checking today's workout status ==="
PYTHONPATH="$(cd "$SCRIPT_DIR/../.." && pwd)" python3 "$SCREEN_LOCK_PATH" --production
PYTHONPATH="$SCRIPT_DIR" python3 -m screen_locker.screen_lock --production

View File

@ -33,8 +33,18 @@ fixable = ["ALL"]
unfixable = []
[tool.ruff.lint.per-file-ignores]
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
"**/tests/**/*.py" = [
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
"PLC0415", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
]
"**/test_*.py" = [
"ANN", "ARG", "D", "DTZ011", "E501", "FBT",
"PLC0415", "PLR2004", "PTH", "RUF003", "S101", "SLF001",
]
"screen_locker/_runnerup_verification.py" = ["S314"]
"screen_locker/_shutdown_base.py" = ["SLF001"]
"screen_locker/_status.py" = ["PLR0915", "SLF001", "T201"]
"scripts/check_file_length.py" = ["PTH123"]
[tool.ruff.lint.pydocstyle]
convention = "google"

View File

@ -1,9 +1,9 @@
# Screen Locker — runtime + development dependencies
# Runtime: tkinter/subprocess/socket/sqlite3 (stdlib) plus gatelock (below).
bandit>=1.7.0
gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
codespell>=2.2.0
coverage>=7.4.0
gatelock @ git+https://github.com/kuhyx/gatelock@v0.1.0
mypy>=1.8.0
pre-commit>=3.6.0
pylint>=3.0.0

View File

@ -0,0 +1,135 @@
"""Mixin: auto-upgrade early_bird/sick_day log entries via phone or RunnerUp."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
import sys
from screen_locker._wake_state import has_workout_skip_today
_logger = logging.getLogger(__name__)
class AutoUpgradeMixin:
"""Handles today-state detection and silent log-entry upgrading.
Relies on methods from EarlyBirdMixin, PhoneVerificationMixin,
RunnerUpVerificationMixin, LogMixin, and ShutdownMixin via MRO.
"""
def _is_sick_day_log(self) -> bool:
"""Check if today's workout log is a sick day (not yet verified)."""
if not self.log_file.exists(): # type: ignore[attr-defined]
return False
try:
with self.log_file.open() as f: # type: ignore[attr-defined]
logs = json.load(f)
except (OSError, json.JSONDecodeError):
return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry = logs.get(today)
if entry is None:
return False
return entry.get("workout_data", {}).get("type") == "sick_day"
def _check_early_exits(self, *, verify_only: bool) -> None:
"""Check startup conditions and exit early when appropriate."""
if verify_only:
if not self._is_sick_day_log():
_logger.info("No sick day logged today. Nothing to verify.")
sys.exit(0)
return
self._check_non_verify_exits() # type: ignore[attr-defined]
def _check_today_state_exits(self) -> bool:
"""Handle early-bird and today's log states. Return True to stop startup."""
if (
self._is_early_bird_log() # type: ignore[attr-defined]
and not self._is_early_bird_time() # type: ignore[attr-defined]
):
if self._try_auto_upgrade_early_bird():
_logger.info("Auto-upgraded early_bird entry to phone_verified.")
sys.exit(0)
return True
return False # Expired early bird, upgrade unavailable — full lock.
if self._is_early_bird_log(): # type: ignore[attr-defined]
_logger.info("Early bird window still active — skipping lock.")
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day():
_logger.info("Auto-upgraded today's sick_day entry to phone_verified.")
elif self.has_logged_today(): # type: ignore[attr-defined]
_logger.info("Workout already logged today. Skipping screen lock.")
elif has_workout_skip_today():
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
elif self._is_early_bird_time(): # type: ignore[attr-defined]
self._save_early_bird_log() # type: ignore[attr-defined]
_logger.info("Early bird time — skipping lock, will re-check at 08:30.")
else:
return False
sys.exit(0)
return True
def _try_auto_upgrade_sick_day(self) -> bool:
"""Upgrade sick_day entry when phone or RunnerUp detects a valid workout."""
try:
status, message = self._verify_phone_workout() # type: ignore[attr-defined]
except (OSError, RuntimeError) as exc:
_logger.info("Auto-upgrade phone check failed: %s", exc)
status, message = "error", str(exc)
if status == "verified":
self.workout_data["type"] = "phone_verified" # type: ignore[attr-defined]
self.workout_data["source"] = message # type: ignore[attr-defined]
self.workout_data["after_sick_day"] = "true" # type: ignore[attr-defined]
self._adjust_shutdown_time_later() # type: ignore[attr-defined]
self.save_workout_log() # type: ignore[attr-defined]
return True
_logger.info("Auto-upgrade phone skipped (%s), trying RunnerUp...", status)
try:
runnerup_status, runnerup_msg = self._verify_runnerup_workout() # type: ignore[attr-defined]
except (OSError, RuntimeError) as exc:
_logger.info("Auto-upgrade RunnerUp check failed: %s", exc)
return False
if runnerup_status != "verified":
_logger.info(
"Auto-upgrade RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
)
return False
self.workout_data["type"] = "runnerup_verified" # type: ignore[attr-defined]
self.workout_data["source"] = runnerup_msg # type: ignore[attr-defined]
self.workout_data["after_sick_day"] = "true" # type: ignore[attr-defined]
self._adjust_shutdown_time_later() # type: ignore[attr-defined]
self.save_workout_log() # type: ignore[attr-defined]
return True
def _try_auto_upgrade_early_bird(self) -> bool:
"""Try phone then RunnerUp to upgrade an early_bird log entry."""
try:
status, message = self._verify_phone_workout() # type: ignore[attr-defined]
except (OSError, RuntimeError) as exc:
_logger.info("Early bird upgrade phone check failed: %s", exc)
status, message = "error", str(exc)
if status == "verified":
self.workout_data["type"] = "phone_verified" # type: ignore[attr-defined]
self.workout_data["source"] = message # type: ignore[attr-defined]
self.workout_data["after_early_bird"] = "true" # type: ignore[attr-defined]
self._adjust_shutdown_time_later() # type: ignore[attr-defined]
self.save_workout_log() # type: ignore[attr-defined]
return True
_logger.info("Early bird phone skipped (%s), trying RunnerUp...", status)
try:
runnerup_status, runnerup_msg = self._verify_runnerup_workout() # type: ignore[attr-defined]
except (OSError, RuntimeError) as exc:
_logger.info("Early bird RunnerUp check failed: %s", exc)
return False
if runnerup_status != "verified":
_logger.info(
"Early bird RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
)
return False
self.workout_data["type"] = "runnerup_verified" # type: ignore[attr-defined]
self.workout_data["source"] = runnerup_msg # type: ignore[attr-defined]
self.workout_data["after_early_bird"] = "true" # type: ignore[attr-defined]
self._adjust_shutdown_time_later() # type: ignore[attr-defined]
self.save_workout_log() # type: ignore[attr-defined]
return True

View File

@ -63,6 +63,10 @@ SICK_DAY_STATE_FILE = Path(__file__).resolve().parent / "sick_day_state.json"
SICK_HISTORY_FILE = Path(__file__).resolve().parent / "sick_history.json"
# JSON list of ISO date strings ("YYYY-MM-DD") for which the screen lock is skipped.
SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
# State file tracking streak, skip credits, and early-bird extension weeks.
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
# State file storing the base (pre-bonus) shutdown hours and last reset date.
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
# ---------------------------------------------------------------------------
# Wake-alarm integration (originally from wake_alarm._constants / _state).

View File

@ -10,7 +10,9 @@ from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE,
)
from screen_locker._extra_benefits import has_extended_early_bird
_logger = logging.getLogger(__name__)
@ -24,9 +26,17 @@ class EarlyBirdMixin:
return now.hour * 60 + now.minute
def _is_early_bird_time(self) -> bool:
"""Return True if current local time is in the early bird window."""
"""Return True if current local time is in the early bird window.
Normally the window closes at 08:30. When the current ISO week has an
extended early-bird reward (earned by 5+ workouts the prior week) the
window extends to 09:00.
"""
minutes = self._get_local_time_minutes()
start = EARLY_BIRD_START_HOUR * 60
if has_extended_early_bird(EXTRA_BENEFITS_FILE):
end = 9 * 60 # 09:00
else:
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= minutes < end
@ -49,24 +59,3 @@ class EarlyBirdMixin:
"""Save an early_bird provisional entry to the workout log."""
self.workout_data = {"type": "early_bird"}
self.save_workout_log()
def _try_auto_upgrade_early_bird(self) -> bool:
"""Silently upgrade today's early_bird entry if phone shows a workout."""
try:
status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc:
_logger.info("Early bird upgrade phone check failed: %s", exc)
return False
if status != "verified":
_logger.info(
"Early bird upgrade skipped (phone status=%s): %s",
status,
message,
)
return False
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.workout_data["after_early_bird"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True

View File

@ -0,0 +1,147 @@
"""Extra benefits for exceeding the weekly workout minimum.
Tracks:
- Consecutive weeks with 5+ workouts (streak counter).
- Banked skip credits earned from extra workouts.
- ISO weeks in which the early-bird window is extended to 09:00.
State is persisted in ``extra_benefits_state.json`` next to this file.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._weekly_check import count_weekly_workouts
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_MILESTONE_INTERVAL = 4 # every 4-week streak → +1 bonus skip credit
_BONUS_THRESHOLD = 5 # workouts/week required to earn extra rewards
def _load_state(state_file: Path) -> dict[str, Any]:
"""Load benefits state, returning defaults if missing or corrupt."""
if not state_file.exists():
return {}
try:
with state_file.open() as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _save_state(state_file: Path, state: dict[str, Any]) -> None:
"""Persist benefits state to disk."""
try:
with state_file.open("w") as f:
json.dump(state, f, indent=2)
except OSError as exc:
_logger.warning("Failed to save extra benefits state: %s", exc)
def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
"""Process last week's results if we've entered a new ISO week.
Counts workouts from the previous ISO week. If count >= 5:
- Increments the consecutive-streak counter.
- Awards (count - 4) skip credits.
- Marks the *current* ISO week as having extended early-bird (09:00).
- Awards a bonus skip credit every ``_MILESTONE_INTERVAL`` streak weeks.
Returns a list of human-readable reward strings (empty if no transition).
"""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week_str = f"{year}-W{week:02d}"
state = _load_state(state_file)
if state.get("last_processed_iso_week") == current_week_str:
return []
# Count workouts in the previous ISO week (Mon through Sun).
monday_this_week = now.date() - timedelta(days=now.weekday())
sunday_prev_week = monday_this_week - timedelta(days=1)
prev_week_dt = datetime(
sunday_prev_week.year,
sunday_prev_week.month,
sunday_prev_week.day,
23,
59,
59,
tzinfo=timezone.utc,
)
prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt)
streak = int(state.get("consecutive_5plus_weeks", 0))
skip_credits = int(state.get("skip_credits", 0))
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
rewards: list[str] = []
prev_year, prev_week, _ = sunday_prev_week.isocalendar()
prev_week_str = f"{prev_year}-W{prev_week:02d}"
if prev_week_count >= _BONUS_THRESHOLD:
extra = prev_week_count - 4
streak += 1
skip_credits += extra
if current_week_str not in eb_weeks:
eb_weeks.append(current_week_str)
rewards.append(
f"{prev_week_count} workouts in {prev_week_str}! "
f"+{extra} skip credit(s), early-bird extended to 09:00 this week"
)
if streak % _MILESTONE_INTERVAL == 0:
skip_credits += 1
rewards.append(f"{streak}-week streak milestone! +1 bonus skip credit")
else:
if streak > 0:
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
streak = 0
_save_state(
state_file,
{
"consecutive_5plus_weeks": streak,
"last_processed_iso_week": current_week_str,
"skip_credits": skip_credits,
"extended_early_bird_iso_weeks": eb_weeks,
},
)
return rewards
def current_streak(state_file: Path) -> int:
"""Return the current consecutive-5plus-weeks streak count."""
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
def has_skip_credit(state_file: Path) -> bool:
"""Return True if at least one banked skip credit is available."""
return int(_load_state(state_file).get("skip_credits", 0)) > 0
def consume_skip_credit(state_file: Path) -> None:
"""Deduct one skip credit from the bank."""
state = _load_state(state_file)
credit_count = int(state.get("skip_credits", 0))
if credit_count > 0:
state["skip_credits"] = credit_count - 1
_save_state(state_file, state)
def has_extended_early_bird(state_file: Path) -> bool:
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week_str = f"{year}-W{week:02d}"
eb_weeks: list[str] = _load_state(state_file).get(
"extended_early_bird_iso_weeks", []
)
return current_week_str in eb_weeks

View File

@ -0,0 +1,81 @@
"""Mixin: workout log persistence (read/write workout_log.json)."""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._constants import SCHEDULED_SKIPS_FILE
_logger = logging.getLogger(__name__)
class LogMixin:
"""Handles reading and writing workout_log.json for the ScreenLocker."""
def has_logged_today(self) -> bool:
"""Check if workout has been logged today with valid HMAC."""
if not self.log_file.exists(): # type: ignore[attr-defined]
return False
try:
with self.log_file.open() as f: # type: ignore[attr-defined]
logs = json.load(f)
except (OSError, json.JSONDecodeError):
return False
else:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry = logs.get(today)
if entry is None:
return False
if verify_entry_hmac(entry):
return entry.get("workout_data", {}).get("type") != "early_bird"
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry:
_logger.info("HMAC key unavailable — accepting unsigned entry")
return entry.get("workout_data", {}).get("type") != "early_bird"
_logger.warning("HMAC verification failed for today's log entry")
return False
def _load_existing_logs(self) -> dict:
"""Load existing workout logs from file."""
if not self.log_file.exists(): # type: ignore[attr-defined]
return {}
try:
with self.log_file.open() as f: # type: ignore[attr-defined]
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _is_scheduled_skip_today(self) -> bool:
"""Return True if today's date is listed in the scheduled skips file."""
if not SCHEDULED_SKIPS_FILE.exists():
return False
try:
with SCHEDULED_SKIPS_FILE.open() as f:
skips = json.load(f)
except (OSError, json.JSONDecodeError):
return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
return today in skips
def save_workout_log(self) -> None:
"""Save workout data to log file with HMAC signature."""
logs = self._load_existing_logs()
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry: dict[str, object] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"workout_data": self.workout_data, # type: ignore[attr-defined]
}
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned entry")
logs[today] = entry
try:
with self.log_file.open("w") as f: # type: ignore[attr-defined]
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)

View File

@ -0,0 +1,150 @@
"""Mixin: RunnerUp root-DB pull path (fallback when no TCX exports found)."""
from __future__ import annotations
import logging
from pathlib import Path
import shutil
import sqlite3
import tempfile
import time
from typing import Any
from screen_locker._constants import (
RUNNERUP_DB_SDCARD_TMP,
RUNNERUP_PACKAGES,
)
_logger = logging.getLogger(__name__)
class RunnerUpDbMixin:
"""Mixin: root DB pull for RunnerUp workout verification.
Called as fallback when no TCX export files are found for today.
Relies on _adb_shell, _run_adb, and _validate_runnerup_data from MRO.
"""
def _find_runnerup_package(self) -> str | None:
"""Return the first installed RunnerUp package name, or None."""
for pkg in RUNNERUP_PACKAGES:
ok, out = self._adb_shell(f"pm list packages {pkg}") # type: ignore[attr-defined]
if ok and pkg in out:
return pkg
return None
def _pull_runnerup_db(self) -> str | None:
"""Pull RunnerUp's SQLite DB from the device to a local temp file.
Copies the DB and WAL/SHM sidecar files via root shell to ``/sdcard``
(accessible without root by adb pull), then pulls them locally.
WAL files must travel with the main DB so that ``PRAGMA wal_checkpoint``
can merge in-flight writes.
Returns the local DB path on success, or ``None`` on any failure.
"""
pkg = self._find_runnerup_package()
if pkg is None:
_logger.info("RunnerUp not installed (tried %s)", RUNNERUP_PACKAGES)
return None
db_device = f"/data/data/{pkg}/databases/runnerup.db"
tmp_dir = tempfile.mkdtemp(prefix="runnerup_verify_")
local_db = str(Path(tmp_dir) / "runnerup.db")
ok, err = self._adb_shell( # type: ignore[attr-defined]
f"cp {db_device} {RUNNERUP_DB_SDCARD_TMP}",
root=True,
)
if not ok:
_logger.info("Failed to copy RunnerUp DB to sdcard: %s", err)
shutil.rmtree(tmp_dir, ignore_errors=True)
return None
for suffix in ("-wal", "-shm"):
self._adb_shell( # type: ignore[attr-defined]
f"test -f {db_device}{suffix} "
f"&& cp {db_device}{suffix} {RUNNERUP_DB_SDCARD_TMP}{suffix} "
f"|| true",
root=True,
)
ok, _ = self._run_adb(["pull", RUNNERUP_DB_SDCARD_TMP, local_db]) # type: ignore[attr-defined]
if not ok:
_logger.info("adb pull of RunnerUp DB failed")
self._cleanup_runnerup_sdcard()
shutil.rmtree(tmp_dir, ignore_errors=True)
return None
for suffix in ("-wal", "-shm"):
self._run_adb( # type: ignore[attr-defined]
["pull", f"{RUNNERUP_DB_SDCARD_TMP}{suffix}", f"{local_db}{suffix}"]
)
self._cleanup_runnerup_sdcard()
return local_db
def _cleanup_runnerup_sdcard(self) -> None:
"""Remove temporary RunnerUp DB files from the sdcard."""
for suffix in ("", "-wal", "-shm"):
self._adb_shell( # type: ignore[attr-defined]
f"test -f {RUNNERUP_DB_SDCARD_TMP}{suffix} "
f"&& rm {RUNNERUP_DB_SDCARD_TMP}{suffix} || true",
root=True,
)
def _query_todays_run(self, db_path: str) -> dict[str, Any] | None:
"""Query the pulled RunnerUp DB for today's most recent activity.
Runs ``PRAGMA wal_checkpoint`` first so that uncommitted WAL entries
are visible (important for runs just finished before connecting).
"""
local_midnight = time.mktime((*time.localtime()[:3], 0, 0, 0, 0, 0, -1))
local_end = local_midnight + 86400
try:
with sqlite3.connect(db_path, timeout=5) as conn:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
cursor = conn.execute(
"""
SELECT start_time, distance, time, type
FROM activity
WHERE deleted = 0
AND start_time >= ?
AND start_time < ?
ORDER BY start_time DESC
LIMIT 1
""",
(local_midnight, local_end),
)
row = cursor.fetchone()
except sqlite3.Error as exc:
_logger.info("RunnerUp DB query failed: %s", exc)
return None
if row is None:
return None
start_time, distance_m, duration_seconds, sport = row
return {
"start_time": int(start_time or 0),
"distance_m": float(distance_m or 0),
"duration_seconds": int(duration_seconds or 0),
"sport": int(sport or 0),
}
def _verify_runnerup_via_db(self) -> tuple[str, str]:
"""Verify today's run via root DB pull (fallback path)."""
db_path = self._pull_runnerup_db()
if db_path is None:
return "not_verified", "Could not retrieve RunnerUp database from phone"
try:
run_data = self._query_todays_run(db_path)
finally:
shutil.rmtree(Path(db_path).parent, ignore_errors=True)
if run_data is None:
return "not_verified", "No RunnerUp activity found for today"
return self._validate_runnerup_data(run_data) # type: ignore[attr-defined]

View File

@ -1,39 +1,32 @@
"""RunnerUp run auto-verification via ADB.
"""RunnerUp run auto-verification via ADB (file-based path + shared validation).
Two verification paths, tried in order:
1. **File-based** (no root, works over WiFi): reads per-activity TCX files
that RunnerUp's File Synchronizer writes to ``/sdcard/Documents/RunnerUp/``
after each run. Requires one-time setup in RunnerUp:
Settings Accounts Add File format=TCX, dir=Documents/RunnerUp.
2. **Root DB pull** (fallback): copies RunnerUp's private SQLite database to
sdcard via ``su``, pulls it locally, and queries it directly. Used when
no today's TCX export is found (e.g. sync hasn't fired yet, or File
Synchronizer isn't configured).
File-based (no root, works over WiFi): reads per-activity TCX files that
RunnerUp's File Synchronizer writes to ``/sdcard/Documents/RunnerUp/``.
Root DB fallback lives in ``_runnerup_db.py``.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
import logging
import os
from pathlib import Path
import shutil
import sqlite3
import tempfile
import time
import xml.etree.ElementTree as ET
from datetime import datetime
from typing import Any
import xml.etree.ElementTree as ET
from gatelock.log_integrity import compute_entry_hmac
from screen_locker._constants import (
MIN_RUN_DISTANCE_KM,
MIN_RUN_DURATION_MINUTES,
RUNNERUP_ACCEPTED_SPORTS,
RUNNERUP_DB_SDCARD_TMP,
RUNNERUP_EXPORT_DIRS,
RUNNERUP_PACKAGES,
)
from screen_locker._runnerup_db import RunnerUpDbMixin
from screen_locker._time_check import check_clock_skew
from screen_locker._weekly_check import COUNTED_WORKOUT_TYPES
_logger = logging.getLogger(__name__)
@ -55,24 +48,28 @@ _TCX_SPORT_TO_INT: dict[str, int] = {v: k for k, v in _SPORT_NAMES.items()}
_TCX_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
class RunnerUpVerificationMixin:
class RunnerUpVerificationMixin(RunnerUpDbMixin):
"""Mixin providing RunnerUp-based workout verification via ADB."""
# ------------------------------------------------------------------
# File-based path (no root required)
# ------------------------------------------------------------------
def _find_todays_runnerup_exports(self) -> list[str]:
"""Return adb paths of today's RunnerUp TCX exports, or empty list."""
today = datetime.now().strftime("%Y-%m-%d")
def _find_runnerup_exports_for_date(self, date_str: str) -> list[str]:
"""Return adb paths of RunnerUp TCX exports for the given date, or empty list.
Args:
date_str: ISO date string in ``YYYY-MM-DD`` format matched against
TCX filenames (``RunnerUp_YYYY-MM-DD-HH-MM-SS_xxx.tcx``).
"""
found: list[str] = []
for dirpath in RUNNERUP_EXPORT_DIRS:
ok, out = self._run_adb(["shell", "ls", dirpath])
if not ok or not out.strip():
continue
for name in out.strip().splitlines():
name = name.strip()
if today in name and name.endswith(".tcx"):
for raw in out.strip().splitlines():
name = raw.strip()
if date_str in name and name.endswith(".tcx"):
remote = f"{dirpath}/{name}"
if remote not in found:
found.append(remote)
@ -81,10 +78,10 @@ class RunnerUpVerificationMixin:
def _pull_and_parse_tcx(self, remote_path: str) -> dict[str, Any] | None:
"""Pull a remote TCX file and parse it. Returns activity dict or None."""
tmp_dir = tempfile.mkdtemp(prefix="runnerup_tcx_")
local_path = os.path.join(tmp_dir, "activity.tcx")
local_path = str(Path(tmp_dir) / "activity.tcx")
try:
ok, _ = self._run_adb(["pull", remote_path, local_path])
if not ok or not os.path.exists(local_path):
if not ok or not Path(local_path).exists():
_logger.info("Failed to pull TCX file: %s", remote_path)
return None
return self._parse_tcx(local_path)
@ -98,7 +95,7 @@ class RunnerUpVerificationMixin:
multi-segment runs (pause/resume) are counted in full.
"""
try:
tree = ET.parse(tcx_path) # noqa: S314 — local file we pulled
tree = ET.parse(tcx_path)
except ET.ParseError as exc:
_logger.info("TCX parse error in %s: %s", tcx_path, exc)
return None
@ -135,7 +132,8 @@ class RunnerUpVerificationMixin:
fails validation), or ``None`` if no today's file exists at all
(caller should try the root DB path instead).
"""
exports = self._find_todays_runnerup_exports()
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
exports = self._find_runnerup_exports_for_date(today)
if not exports:
return None
@ -152,143 +150,88 @@ class RunnerUpVerificationMixin:
best = (status, msg)
# All files found but none passed validation.
return best or ("not_verified", "RunnerUp TCX export found but could not be read")
return best or (
"not_verified",
"RunnerUp TCX export found but could not be read",
)
# ------------------------------------------------------------------
# Root DB pull path (fallback)
# ------------------------------------------------------------------
def _try_fill_runnerup_for_date(self, date_str: str, logs: dict[str, Any]) -> bool:
"""Try to fill one date gap from RunnerUp TCX exports, mutating logs in-place.
def _find_runnerup_package(self) -> str | None:
"""Return the first installed RunnerUp package name, or None."""
for pkg in RUNNERUP_PACKAGES:
ok, out = self._adb_shell(f"pm list packages {pkg}")
if ok and pkg in out:
return pkg
return None
def _pull_runnerup_db(self) -> str | None:
"""Pull RunnerUp's SQLite DB from the device to a local temp file.
Copies the DB and WAL/SHM sidecar files via root shell to ``/sdcard``
(accessible without root by adb pull), then pulls them locally.
WAL files must travel with the main DB so that ``PRAGMA wal_checkpoint``
can merge in-flight writes.
Returns the local DB path on success, or ``None`` on any failure.
Returns True if a verified entry was written for ``date_str``.
"""
pkg = self._find_runnerup_package()
if pkg is None:
_logger.info("RunnerUp not installed (tried %s)", RUNNERUP_PACKAGES)
return None
db_device = f"/data/data/{pkg}/databases/runnerup.db"
tmp_dir = tempfile.mkdtemp(prefix="runnerup_verify_")
local_db = os.path.join(tmp_dir, "runnerup.db")
ok, err = self._adb_shell(
f"cp {db_device} {RUNNERUP_DB_SDCARD_TMP}",
root=True,
)
if not ok:
_logger.info("Failed to copy RunnerUp DB to sdcard: %s", err)
shutil.rmtree(tmp_dir, ignore_errors=True)
return None
for suffix in ("-wal", "-shm"):
self._adb_shell(
f"test -f {db_device}{suffix} "
f"&& cp {db_device}{suffix} {RUNNERUP_DB_SDCARD_TMP}{suffix} "
f"|| true",
root=True,
)
ok, _ = self._run_adb(["pull", RUNNERUP_DB_SDCARD_TMP, local_db])
if not ok:
_logger.info("adb pull of RunnerUp DB failed")
self._cleanup_runnerup_sdcard()
shutil.rmtree(tmp_dir, ignore_errors=True)
return None
for suffix in ("-wal", "-shm"):
self._run_adb(
["pull", f"{RUNNERUP_DB_SDCARD_TMP}{suffix}", f"{local_db}{suffix}"]
)
self._cleanup_runnerup_sdcard()
return local_db
def _cleanup_runnerup_sdcard(self) -> None:
"""Remove temporary RunnerUp DB files from the sdcard."""
for suffix in ("", "-wal", "-shm"):
self._adb_shell(
f"test -f {RUNNERUP_DB_SDCARD_TMP}{suffix} "
f"&& rm {RUNNERUP_DB_SDCARD_TMP}{suffix} || true",
root=True,
)
def _query_todays_run(self, db_path: str) -> dict[str, Any] | None:
"""Query the pulled RunnerUp DB for today's most recent activity.
Runs ``PRAGMA wal_checkpoint`` first so that uncommitted WAL entries
are visible (important for runs just finished before connecting).
"""
local_midnight = time.mktime(time.localtime()[:3] + (0, 0, 0, 0, 0, -1))
local_end = local_midnight + 86400
try:
with sqlite3.connect(db_path, timeout=5) as conn:
conn.execute("PRAGMA wal_checkpoint(PASSIVE)")
cursor = conn.execute(
"""
SELECT start_time, distance, time, type
FROM activity
WHERE deleted = 0
AND start_time >= ?
AND start_time < ?
ORDER BY start_time DESC
LIMIT 1
""",
(local_midnight, local_end),
)
row = cursor.fetchone()
except sqlite3.Error as exc:
_logger.info("RunnerUp DB query failed: %s", exc)
return None
if row is None:
return None
start_time, distance_m, duration_seconds, sport = row
return {
"start_time": int(start_time or 0),
"distance_m": float(distance_m or 0),
"duration_seconds": int(duration_seconds or 0),
"sport": int(sport or 0),
existing = logs.get(date_str, {})
if isinstance(existing, dict):
wtype = existing.get("workout_data", {}).get("type", "")
if wtype in COUNTED_WORKOUT_TYPES:
return False
for remote in self._find_runnerup_exports_for_date(date_str):
data = self._pull_and_parse_tcx(remote)
if data is None:
continue
status, msg = self._validate_runnerup_data(data)
if status != "verified":
continue
entry: dict[str, Any] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"workout_data": {
"type": "runnerup_verified",
"source": f"Auto-scanned: {msg}",
"distance_km": round(data["distance_m"] / 1000, 2),
"duration_minutes": round(data["duration_seconds"] / 60, 1),
},
}
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
logs[date_str] = entry
_logger.info("Auto-filled RunnerUp entry for %s: %s", date_str, msg)
return True
return False
def _verify_runnerup_via_db(self) -> tuple[str, str]:
"""Verify today's run via root DB pull (fallback path)."""
db_path = self._pull_runnerup_db()
if db_path is None:
return "not_verified", "Could not retrieve RunnerUp database from phone"
def _scan_and_fill_week_runnerup(self, log_file: Path) -> int:
"""Scan the current ISO week for RunnerUp TCX gaps and fill them.
Returns the count of newly filled entries (0 if phone not connected).
"""
if not self._has_adb_device():
_logger.info(
"Phone not connected; skipping auto-scan for past RunnerUp exports."
)
return 0
now = datetime.now(tz=timezone.utc).astimezone()
today = now.date()
week_start = today - timedelta(days=today.weekday())
try:
run_data = self._query_todays_run(db_path)
finally:
shutil.rmtree(os.path.dirname(db_path), ignore_errors=True)
with log_file.open() as f:
logs: dict[str, Any] = json.load(f)
except (OSError, json.JSONDecodeError):
logs = {}
if run_data is None:
return "not_verified", "No RunnerUp activity found for today"
filled = 0
current = week_start
while current <= today:
if self._try_fill_runnerup_for_date(current.strftime("%Y-%m-%d"), logs):
filled += 1
current += timedelta(days=1)
return self._validate_runnerup_data(run_data)
if filled > 0:
try:
with log_file.open("w") as f:
json.dump(logs, f, indent=2)
except OSError as exc:
_logger.warning("Failed to write workout log after scan: %s", exc)
return 0
return filled
# ------------------------------------------------------------------
# Shared validation
# ------------------------------------------------------------------
def _validate_runnerup_data(
self, data: dict[str, Any]
) -> tuple[str, str]:
def _validate_runnerup_data(self, data: dict[str, Any]) -> tuple[str, str]:
"""Validate a RunnerUp activity against configured thresholds.
Returns ``(status, message)`` following the same contract as
@ -304,17 +247,15 @@ class RunnerUpVerificationMixin:
duration_min = data["duration_seconds"] / 60
if duration_min < MIN_RUN_DURATION_MINUTES:
return (
"too_short",
f"Run was {duration_min:.0f} min — need at least {MIN_RUN_DURATION_MINUTES} min",
msg = (
f"Run was {duration_min:.0f} min — need {MIN_RUN_DURATION_MINUTES}+ min"
)
return "too_short", msg
distance_km = data["distance_m"] / 1000
if distance_km < MIN_RUN_DISTANCE_KM:
return (
"too_short",
f"Run was {distance_km:.1f} km — need at least {MIN_RUN_DISTANCE_KM:.0f} km",
)
msg = f"Run was {distance_km:.1f} km — need {MIN_RUN_DISTANCE_KM:.0f}+ km"
return "too_short", msg
sport_name = _SPORT_NAMES.get(sport, str(sport))
return (

View File

@ -81,6 +81,32 @@ class ShutdownMixin:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
def _adjust_shutdown_time_by(self, extra_hours: int) -> bool:
"""Adjust shutdown hours by *extra_hours*, capped at 24 (midnight).
Used for extra-workout bonuses beyond the weekly minimum. A cap of 24
works because ``day-specific-shutdown-check.sh`` fires at 00:00 and
catches it via the morning-window condition (0 <= 300 minutes).
Returns True if successful, False otherwise.
"""
try:
config_values = self._read_shutdown_config()
if config_values is None:
return False
mw, ts, morning = config_values
return self._write_shutdown_config(
min(24, mw + extra_hours),
min(24, ts + extra_hours),
morning,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning(
"Failed to adjust shutdown time by %d h: %s", extra_hours, e
)
return False
def _sick_mode_used_today(self) -> bool:
"""Check if sick mode was already used today."""
if not SICK_DAY_STATE_FILE.exists():

View File

@ -0,0 +1,102 @@
"""Daily shutdown-time base reset for the screen locker.
On each new calendar day the shutdown config is reset to base hours (21:00
by default) so that the day's workout bonuses always layer on top of a known
floor rather than accumulating indefinitely across days.
The sick-day state file is cleared on reset so the sick-restore path cannot
overwrite the fresh base when it runs later in the same startup.
"""
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_DEFAULT_BASE_HOUR = 21
def get_base_hours(state_file: Path) -> tuple[int, int]:
"""Return ``(base_mon_wed_hour, base_thu_sun_hour)`` from *state_file*.
Falls back to ``(21, 21)`` if the file is missing or corrupt.
"""
if not state_file.exists():
return (_DEFAULT_BASE_HOUR, _DEFAULT_BASE_HOUR)
try:
with state_file.open() as f:
state: dict[str, Any] = json.load(f)
return (
int(state.get("base_mon_wed_hour", _DEFAULT_BASE_HOUR)),
int(state.get("base_thu_sun_hour", _DEFAULT_BASE_HOUR)),
)
except (OSError, json.JSONDecodeError, ValueError):
return (_DEFAULT_BASE_HOUR, _DEFAULT_BASE_HOUR)
def reset_to_base_if_new_day(
state_file: Path,
mixin: object,
sick_day_state_file: Path | None = None,
) -> bool:
"""Reset the shutdown config to base hours if a new calendar day has begun.
Writes base hours via *mixin*._write_shutdown_config (with restore=True so
the script allows moving the time earlier), updates ``last_reset_date`` in
*state_file*, and removes *sick_day_state_file* if it exists so the
sick-restore path does not fight with the fresh base on the same startup.
Returns True if a reset was performed, False if today was already reset.
"""
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
if state_file.exists():
try:
with state_file.open() as f:
state: dict[str, Any] = json.load(f)
if state.get("last_reset_date") == today:
return False
except (OSError, json.JSONDecodeError):
pass
base_mw, base_ts = get_base_hours(state_file)
# Preserve the morning-end hour from the live config.
config = mixin._read_shutdown_config()
morning_end = config[2] if config else 5
ok: bool = mixin._write_shutdown_config(base_mw, base_ts, morning_end, restore=True)
if not ok:
_logger.warning("Daily base reset: failed to write shutdown config.")
return False
# Clear stale sick-day state so it does not override the base reset.
if sick_day_state_file is not None and sick_day_state_file.exists():
try:
sick_day_state_file.unlink()
_logger.info("Daily base reset: cleared stale sick-day state.")
except OSError as exc:
_logger.warning(
"Daily base reset: could not remove sick-day state: %s", exc
)
new_state: dict[str, Any] = {
"base_mon_wed_hour": base_mw,
"base_thu_sun_hour": base_ts,
"last_reset_date": today,
}
try:
with state_file.open("w") as f:
json.dump(new_state, f, indent=2)
except OSError as exc:
_logger.warning("Daily base reset: failed to write state file: %s", exc)
_logger.info("Daily base reset: Mon-Wed=%d, Thu-Sun=%d.", base_mw, base_ts)
return True

130
screen_locker/_status.py Normal file
View File

@ -0,0 +1,130 @@
"""Non-locking status view: workout count, bonuses, RunnerUp scan trigger."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
import sys
from typing import TYPE_CHECKING
from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
)
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
)
if TYPE_CHECKING:
from pathlib import Path
from screen_locker.screen_lock import ScreenLocker
def _load_log(log_file: Path) -> dict:
"""Load the workout log dict, returning {} on any error."""
if not log_file.exists():
return {}
try:
with log_file.open() as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _load_extra_benefits() -> dict:
"""Load extra_benefits_state.json, returning {} on any error."""
if not EXTRA_BENEFITS_FILE.exists():
return {}
try:
return json.loads(EXTRA_BENEFITS_FILE.read_text())
except (OSError, ValueError):
return {}
def run_status(locker: ScreenLocker) -> None:
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
today = datetime.now(tz=timezone.utc).astimezone().date()
monday = today - timedelta(days=today.weekday())
log_file: Path = locker.log_file # type: ignore[attr-defined]
log_data = _load_log(log_file)
print("=== Weekly Workout Status ===")
# Per-day breakdown
before_count = 0
for i in range(7):
d = monday + timedelta(days=i)
if d > today:
break
dstr = d.isoformat()
entry = log_data.get(dstr)
if entry is None:
print(f" {d.strftime('%a %b %d')} — no entry")
else:
wtype = entry.get("workout_data", {}).get("type", "?")
src = entry.get("workout_data", {}).get("source", "")
counted = wtype in COUNTED_WORKOUT_TYPES
src_str = f" ({src[:45]})" if src else ""
mark = "" if counted else ""
print(f" {d.strftime('%a %b %d')} {mark} {wtype}{src_str}")
if counted:
before_count += 1
print()
# RunnerUp scan
n_filled = locker._scan_and_fill_week_runnerup(log_file) # type: ignore[attr-defined]
if n_filled > 0:
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
after_count = count_weekly_workouts(log_file)
bonus = max(0, after_count - max(WEEKLY_WORKOUT_MINIMUM, before_count))
if bonus > 0:
ok = locker._adjust_shutdown_time_by(bonus) # type: ignore[attr-defined]
if ok:
print(f" +{bonus}h shutdown bonus applied.")
else:
print(f" +{bonus}h shutdown bonus pending (config write failed).")
else:
print(" No new workouts found via RunnerUp scan.")
after_count = before_count
print()
# Extra benefits summary
state = _load_extra_benefits()
credits = state.get("skip_credits", 0)
streak = current_streak(EXTRA_BENEFITS_FILE)
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No"
print(f" Skip credits banked : {credits}")
print(f" Streak (5+ wks) : {streak}")
print(f" Early-bird extended : {eb_str}")
print()
remaining = max(0, WEEKLY_WORKOUT_MINIMUM - after_count)
extra = max(0, after_count - WEEKLY_WORKOUT_MINIMUM)
if remaining > 0:
print(
f" Need {remaining} more to reach the minimum ({WEEKLY_WORKOUT_MINIMUM})."
)
elif extra > 0:
print(f" {after_count}/{WEEKLY_WORKOUT_MINIMUM}{extra} above minimum!")
else:
print(
f" Weekly minimum met exactly"
f" ({WEEKLY_WORKOUT_MINIMUM}/{WEEKLY_WORKOUT_MINIMUM})."
)
# Shutdown config
cfg = locker._read_shutdown_config() # type: ignore[attr-defined]
if cfg:
_mw, _ts, _morning = cfg
print(f" Shutdown tonight : {_mw:02d}:00")
sys.exit(0)

View File

@ -103,9 +103,7 @@ class UIFlowsMixin:
# no_phone or error — try RunnerUp first, then penalty timer.
self._start_runnerup_fallback(lambda: self._show_phone_penalty(message))
def _start_runnerup_fallback(
self, on_failure: "Callable[[], None]"
) -> None:
def _start_runnerup_fallback(self, on_failure: Callable[[], None]) -> None:
"""Check RunnerUp as fallback after phone check fails.
Shows a waiting screen, runs the check in a background thread, then

View File

@ -6,8 +6,6 @@ Requires user to log their workout to unlock the screen.
from __future__ import annotations
from datetime import datetime, timezone
import json
import logging
from pathlib import Path
import sys
@ -15,39 +13,51 @@ import tkinter as tk
from typing import TYPE_CHECKING
from gatelock import GateRoot, LockConfig, LockWindow
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker import _sick_tracker
from screen_locker._auto_upgrade import AutoUpgradeMixin
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE,
HMAC_KEY_FILE,
MAX_CLOCK_SKEW_SECONDS,
MIN_WORKOUT_DURATION_MINUTES,
PHONE_PENALTY_DELAY_DEMO,
PHONE_PENALTY_DELAY_PRODUCTION,
SCHEDULED_SKIPS_FILE,
SHUTDOWN_BASE_FILE,
SICK_DAY_STATE_FILE,
SICK_LOCKOUT_SECONDS,
)
from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._extra_benefits import (
consume_skip_credit,
current_streak,
has_skip_credit,
process_week_transition,
)
from screen_locker._log_mixin import LogMixin
from screen_locker._phone_verification import PhoneVerificationMixin
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
from screen_locker._shutdown import ShutdownMixin
from screen_locker._shutdown_base import reset_to_base_if_new_day
from screen_locker._sick_dialog import SickDialogMixin
from screen_locker._ui_flows import UIFlowsMixin
from screen_locker._ui_flows_relaxed import UIFlowsRelaxedMixin
from screen_locker._ui_widgets import UIWidgetsMixin
from screen_locker._wake_state import has_workout_skip_today
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
has_weekly_minimum,
is_relaxed_day,
)
from screen_locker._window_setup import WindowSetupMixin
if TYPE_CHECKING:
from collections.abc import Callable
from concurrent.futures import Future
__all__ = [
@ -84,7 +94,9 @@ def _assert_not_under_pytest() -> None:
class ScreenLocker(
AutoUpgradeMixin,
EarlyBirdMixin,
LogMixin,
WindowSetupMixin,
ShutdownMixin,
PhoneVerificationMixin,
@ -137,7 +149,7 @@ class ScreenLocker(
self.container.place(relx=0.5, rely=0.5, anchor="center")
self._phone_future: Future[tuple[str, str]] | None = None
self._runnerup_future: Future[tuple[str, str]] | None = None
self._runnerup_on_failure: "Callable[[], None] | None" = None
self._runnerup_on_failure: Callable[[], None] | None = None
if verify_only:
self._start_verify_workout_check()
elif self._relaxed_day_mode:
@ -149,62 +161,34 @@ class ScreenLocker(
if self._lock is not None: # pragma: no branch
self._lock.grab_input()
def _is_sick_day_log(self) -> bool:
"""Check if today's workout log is a sick day (not yet verified)."""
if not self.log_file.exists():
return False
try:
with self.log_file.open() as f:
logs = json.load(f)
except (OSError, json.JSONDecodeError):
return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry = logs.get(today)
if entry is None:
return False
return entry.get("workout_data", {}).get("type") == "sick_day"
def _check_early_exits(self, *, verify_only: bool) -> None:
"""Check startup conditions and exit early when appropriate."""
if verify_only:
if not self._is_sick_day_log():
_logger.info(
"No sick day logged today. Nothing to verify.",
)
sys.exit(0)
return
self._check_non_verify_exits()
def _check_today_state_exits(self) -> bool:
"""Handle early-bird and today's log states. Return True to stop startup."""
if self._is_early_bird_log() and not self._is_early_bird_time():
if self._try_auto_upgrade_early_bird():
_logger.info("Auto-upgraded early_bird entry to phone_verified.")
sys.exit(0)
return True
return False # Expired early bird, upgrade unavailable — full lock.
if self._is_early_bird_log():
_logger.info("Early bird window still active — skipping lock.")
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day():
_logger.info("Auto-upgraded today's sick_day entry to phone_verified.")
elif self.has_logged_today():
_logger.info("Workout already logged today. Skipping screen lock.")
elif has_workout_skip_today():
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
elif self._is_early_bird_time():
self._save_early_bird_log()
_logger.info("Early bird time — skipping lock, will re-check at 08:30.")
else:
return False
sys.exit(0)
return True
def _check_non_verify_exits(self) -> None:
"""Check all normal (non-verify) startup early-exit conditions."""
if self._is_scheduled_skip_today():
_logger.info("Today is a scheduled skip day. Skipping screen lock.")
sys.exit(0)
return
# Reset shutdown config to base (21:00) at the start of each new day
# so workout bonuses always layer on top of a known floor.
reset_to_base_if_new_day(
SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE
)
# Auto-fill any RunnerUp workouts from earlier in the current ISO week
# before any early-exit check, so gaps are closed regardless of today's
# logged state (early_bird, sick_day, etc.).
prev_count = count_weekly_workouts(self.log_file)
n_filled = self._scan_and_fill_week_runnerup(self.log_file)
if n_filled:
new_count = count_weekly_workouts(self.log_file)
_logger.info(
"Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled
)
# Award +1h for each newly auto-filled workout above the minimum.
bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
if bonus > 0 and self._adjust_shutdown_time_by(bonus):
_logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus)
# Award streak / skip-credit / EB-extension rewards from last week.
for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward_msg)
if self._check_today_state_exits():
return
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
@ -220,74 +204,12 @@ class ScreenLocker(
)
sys.exit(0)
return
def _try_auto_upgrade_sick_day(self) -> bool:
"""Silently upgrade today's sick_day entry if phone or RunnerUp shows a workout."""
try:
status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc:
_logger.info("Auto-upgrade phone check failed: %s", exc)
status, message = "error", str(exc)
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.workout_data["after_sick_day"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True
_logger.info("Auto-upgrade phone skipped (%s), trying RunnerUp...", status)
try:
runnerup_status, runnerup_msg = self._verify_runnerup_workout()
except (OSError, RuntimeError) as exc:
_logger.info("Auto-upgrade RunnerUp check failed: %s", exc)
return False
if runnerup_status != "verified":
_logger.info(
"Auto-upgrade RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
)
return False
self.workout_data["type"] = "runnerup_verified"
self.workout_data["source"] = runnerup_msg
self.workout_data["after_sick_day"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True
def _try_auto_upgrade_early_bird(self) -> bool:
"""Override: try phone then RunnerUp to upgrade an early_bird entry."""
try:
status, message = self._verify_phone_workout()
except (OSError, RuntimeError) as exc:
_logger.info("Early bird upgrade phone check failed: %s", exc)
status, message = "error", str(exc)
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
self.workout_data["after_early_bird"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True
_logger.info("Early bird phone skipped (%s), trying RunnerUp...", status)
try:
runnerup_status, runnerup_msg = self._verify_runnerup_workout()
except (OSError, RuntimeError) as exc:
_logger.info("Early bird RunnerUp check failed: %s", exc)
return False
if runnerup_status != "verified":
_logger.info(
"Early bird RunnerUp skipped (%s): %s", runnerup_status, runnerup_msg
)
return False
self.workout_data["type"] = "runnerup_verified"
self.workout_data["source"] = runnerup_msg
self.workout_data["after_early_bird"] = "true"
self._adjust_shutdown_time_later()
self.save_workout_log()
return True
# ------------------------------------------------------------------
# Unlock, logging
# ------------------------------------------------------------------
# Spend a banked skip credit if the minimum hasn't been reached yet.
if has_skip_credit(EXTRA_BENEFITS_FILE):
consume_skip_credit(EXTRA_BENEFITS_FILE)
_logger.info("Used a banked skip credit — no lock today.")
sys.exit(0)
return
def _try_adjust_shutdown_for_workout(self) -> bool:
"""Try to adjust shutdown time later for actual workouts."""
@ -319,6 +241,17 @@ class ScreenLocker(
self.save_workout_log()
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
new_debt = self._clear_debt_on_verified_workout()
# Extra-workout bonus: +1h per workout above the weekly minimum.
extra_bonus_delta = 0
weekly_count = count_weekly_workouts(self.log_file)
if weekly_count > WEEKLY_WORKOUT_MINIMUM:
old_cfg = self._read_shutdown_config()
if old_cfg and self._adjust_shutdown_time_by(1):
new_cfg = self._read_shutdown_config()
if new_cfg:
extra_bonus_delta = new_cfg[1] - old_cfg[1]
self.clear_container()
self._label("Great job! 💪", font_size=48, color="#00ff00", pady=30)
if shutdown_adjusted:
@ -327,12 +260,26 @@ class ScreenLocker(
font_size=24,
color="#ffaa00",
)
if extra_bonus_delta > 0:
extra_n = weekly_count - WEEKLY_WORKOUT_MINIMUM
self._text(
f"Extra workout #{extra_n}! +{extra_bonus_delta}h tonight",
font_size=20,
color="#ffaa00",
)
if new_debt is not None:
self._text(
f"Workout debt: {new_debt}",
font_size=20,
color="#ffaa00" if new_debt > 0 else "#888888",
)
streak = current_streak(EXTRA_BENEFITS_FILE)
if streak >= 1:
self._text(
f"🔥 {streak}-week streak (5+ workouts each)",
font_size=14,
color="#888888",
)
self._text("Screen Unlocked!", font_size=36, pady=20)
if self.workout_data.get("type") in ("phone_verified", "runnerup_verified"):
self.root.after(
@ -342,75 +289,6 @@ class ScreenLocker(
else:
self.root.after(1500, self.close)
def has_logged_today(self) -> bool:
"""Check if workout has been logged today with valid HMAC."""
if not self.log_file.exists():
return False
try:
with self.log_file.open() as f:
logs = json.load(f)
except (OSError, json.JSONDecodeError):
return False
else:
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry = logs.get(today)
if entry is None:
return False
if verify_entry_hmac(entry):
return entry.get("workout_data", {}).get("type") != "early_bird"
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry:
_logger.info(
"HMAC key unavailable — accepting unsigned entry",
)
return entry.get("workout_data", {}).get("type") != "early_bird"
_logger.warning(
"HMAC verification failed for today's log entry",
)
return False
def _load_existing_logs(self) -> dict:
"""Load existing workout logs from file."""
if not self.log_file.exists():
return {}
try:
with self.log_file.open() as f:
return json.load(f)
except (OSError, json.JSONDecodeError):
return {}
def _is_scheduled_skip_today(self) -> bool:
"""Return True if today's date is listed in the scheduled skips file."""
if not SCHEDULED_SKIPS_FILE.exists():
return False
try:
with SCHEDULED_SKIPS_FILE.open() as f:
skips = json.load(f)
except (OSError, json.JSONDecodeError):
return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
return today in skips
def save_workout_log(self) -> None:
"""Save workout data to log file with HMAC signature."""
logs = self._load_existing_logs()
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry: dict[str, object] = {
"timestamp": datetime.now(tz=timezone.utc).isoformat(),
"workout_data": self.workout_data,
}
signature = compute_entry_hmac(entry)
if signature is not None:
entry["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned entry")
logs[today] = entry
try:
with self.log_file.open("w") as f:
json.dump(logs, f, indent=2)
except OSError as e:
_logger.warning("Could not save workout log: %s", e)
def close(self) -> None:
"""Close the application and exit."""
if self._lock is not None:
@ -428,8 +306,16 @@ class ScreenLocker(
if __name__ == "__main__":
# Check for --production flag
demo_mode = True # Default to demo mode for safety
if "--status" in sys.argv:
from screen_locker._status import run_status
# Bypass __init__ (no UI) — only log_file and workout_data are needed.
_sl = object.__new__(ScreenLocker)
_sl.log_file = Path(__file__).resolve().parent / "workout_log.json"
_sl.workout_data = {}
run_status(_sl)
demo_mode = True
verify_only = "--verify-workout" in sys.argv
if "--production" in sys.argv:

View File

@ -135,7 +135,7 @@ def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
"""Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file."""
target = tmp_path / "scheduled_skips.json"
with patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
target,
):
yield
@ -231,6 +231,7 @@ def create_locker(
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
patch.object(ScreenLocker, "_scan_and_fill_week_runnerup", return_value=0),
):
return ScreenLocker(
demo_mode=demo_mode,

View File

@ -110,6 +110,34 @@ class TestIsEarlyBirdTime:
locker = self._locker(mock_tk, tmp_path, 540)
assert locker._is_early_bird_time() is False
def test_extended_window_ends_at_9am(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""When has_extended_early_bird is True, window closes at 09:00 (540 min)."""
locker = self._locker(mock_tk, tmp_path, 539) # 08:59 — still inside
with patch(
"screen_locker._early_bird.has_extended_early_bird",
return_value=True,
):
assert locker._is_early_bird_time() is True
def test_extended_window_closed_at_9am(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Extended window excludes exactly 09:00 (540 min)."""
locker = self._locker(mock_tk, tmp_path, 540) # 09:00 — exclusive end
with patch(
"screen_locker._early_bird.has_extended_early_bird",
return_value=True,
):
assert locker._is_early_bird_time() is False
def test_midnight(
self,
mock_tk: MagicMock,
@ -222,7 +250,7 @@ class TestSaveEarlyBirdLog:
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
):
locker._save_early_bird_log()

View File

@ -41,7 +41,7 @@ class TestTryAutoUpgradeEarlyBird:
MagicMock(return_value=True),
)
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
):
result = locker._try_auto_upgrade_early_bird()
@ -117,7 +117,7 @@ class TestHasLoggedTodayEarlyBird:
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with patch(
"screen_locker.screen_lock.verify_entry_hmac",
"screen_locker._log_mixin.verify_entry_hmac",
return_value=True,
):
assert locker.has_logged_today() is False
@ -149,7 +149,7 @@ class TestInitEarlyBirdFlow:
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=False,
),
pytest.raises(SystemExit),

View File

@ -0,0 +1,286 @@
"""Tests for _extra_benefits module (streak, skip credits, EB extension)."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._extra_benefits import (
_load_state,
_save_state,
consume_skip_credit,
current_streak,
has_extended_early_bird,
has_skip_credit,
process_week_transition,
)
if TYPE_CHECKING:
from pathlib import Path
class TestLoadState:
"""Tests for _load_state helper."""
def test_returns_empty_dict_when_file_missing(self, tmp_path: Path) -> None:
"""Non-existent file returns empty dict (line 29 — the missing-file branch)."""
result = _load_state(tmp_path / "nonexistent.json")
assert result == {}
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
"""Valid JSON file returns the parsed dict."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3}))
assert _load_state(f) == {"skip_credits": 3}
def test_returns_empty_on_oserror(self) -> None:
"""OSError during read is caught and returns empty dict (lines 33-34)."""
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_path.open.side_effect = OSError("read fail")
assert _load_state(mock_path) == {}
def test_returns_empty_on_invalid_json(self, tmp_path: Path) -> None:
"""Corrupt JSON is caught and returns empty dict (lines 33-34)."""
f = tmp_path / "state.json"
f.write_text("not-json{{{")
assert _load_state(f) == {}
class TestSaveState:
"""Tests for _save_state helper."""
def test_saves_state_to_file(self, tmp_path: Path) -> None:
"""Valid path writes JSON content (lines 39-41)."""
f = tmp_path / "state.json"
_save_state(f, {"skip_credits": 2})
assert json.loads(f.read_text())["skip_credits"] == 2
def test_logs_warning_on_oserror(self) -> None:
"""OSError during write is caught as warning, does not raise (lines 42-43)."""
mock_path = MagicMock()
mock_path.open.side_effect = OSError("write fail")
_save_state(mock_path, {"key": "val"}) # must not raise
class TestProcessWeekTransition:
"""Tests for process_week_transition."""
_PAST_WEEK = "2020-W01"
def test_returns_empty_when_already_processed_this_week(
self, tmp_path: Path
) -> None:
"""Early return when ISO week already processed (line 63)."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_processed_iso_week": f"{year}-W{week:02d}"}))
assert process_week_transition(tmp_path / "log.json", f) == []
def test_awards_credits_for_5plus_workouts(self, tmp_path: Path) -> None:
"""5+ workouts in previous week: streak += 1, skip_credits += extra (lines 87-96)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0,
"skip_credits": 0,
"extended_early_bird_iso_weeks": [],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=6
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert len(rewards) >= 1
assert "+2 skip credit" in rewards[0]
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 1
assert state["skip_credits"] == 2 # 6 4
def test_awards_milestone_bonus_at_4_week_streak(self, tmp_path: Path) -> None:
"""Streak reaches multiple of 4: +1 bonus skip credit (lines 97-99)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 3,
"skip_credits": 0,
"extended_early_bird_iso_weeks": [],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=5
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert any("milestone" in r for r in rewards)
state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 4
assert state["skip_credits"] == 2 # 1 extra + 1 milestone
def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None:
"""5+ workouts mark current ISO week as extended EB (line 91-92)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"extended_early_bird_iso_weeks": [],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=5
):
process_week_transition(tmp_path / "log.json", f)
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
state = json.loads(f.read_text())
assert f"{year}-W{week:02d}" in state["extended_early_bird_iso_weeks"]
def test_resets_streak_for_fewer_than_5_workouts(self, tmp_path: Path) -> None:
"""< 5 workouts in previous week resets streak and logs message (lines 100-103)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 2,
"skip_credits": 3,
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=3
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert any("Streak reset" in r for r in rewards)
assert json.loads(f.read_text())["consecutive_5plus_weeks"] == 0
def test_no_reset_message_when_streak_was_zero(self, tmp_path: Path) -> None:
"""Zero streak + < 5 workouts: no reset message (line 101 branch False)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0,
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=2
):
rewards = process_week_transition(tmp_path / "log.json", f)
assert not any("Streak reset" in r for r in rewards)
def test_fresh_state_file_processed_on_new_week(self, tmp_path: Path) -> None:
"""No state file: _load_state returns {} and transition runs (covers line 29)."""
f = tmp_path / "nonexistent.json"
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=4
):
process_week_transition(tmp_path / "log.json", f)
assert f.exists() # state file created
def test_duplicate_eb_week_not_added_twice(self, tmp_path: Path) -> None:
"""Current week already in EB list: not added again (line 91 branch False)."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"extended_early_bird_iso_weeks": [current_week],
}
)
)
with patch(
"screen_locker._extra_benefits.count_weekly_workouts", return_value=5
):
process_week_transition(tmp_path / "log.json", f)
state = json.loads(f.read_text())
assert state["extended_early_bird_iso_weeks"].count(current_week) == 1
class TestCurrentStreak:
"""Tests for current_streak."""
def test_returns_zero_when_file_missing(self, tmp_path: Path) -> None:
"""Missing file falls through _load_state → default 0."""
assert current_streak(tmp_path / "nonexistent.json") == 0
def test_returns_stored_streak(self, tmp_path: Path) -> None:
"""Stored streak value is returned correctly."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"consecutive_5plus_weeks": 5}))
assert current_streak(f) == 5
class TestHasSkipCredit:
"""Tests for has_skip_credit."""
def test_returns_false_when_no_credits(self, tmp_path: Path) -> None:
"""Zero credits → False."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0}))
assert has_skip_credit(f) is False
def test_returns_true_when_credits_available(self, tmp_path: Path) -> None:
"""Non-zero credits → True."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 2}))
assert has_skip_credit(f) is True
class TestConsumeSkipCredit:
"""Tests for consume_skip_credit."""
def test_decrements_credit_count(self, tmp_path: Path) -> None:
"""Credits > 0: decrement by 1 (lines 129-133)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3}))
consume_skip_credit(f)
assert json.loads(f.read_text())["skip_credits"] == 2
def test_does_nothing_when_no_credits(self, tmp_path: Path) -> None:
"""Credits == 0: no decrement (line 131 branch False)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0}))
consume_skip_credit(f)
assert json.loads(f.read_text())["skip_credits"] == 0
class TestHasExtendedEarlyBird:
"""Tests for has_extended_early_bird."""
def test_returns_false_when_current_week_not_in_list(self, tmp_path: Path) -> None:
"""Current ISO week absent from list → False."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2020-W01"]}))
assert has_extended_early_bird(f) is False
def test_returns_true_when_current_week_is_in_list(self, tmp_path: Path) -> None:
"""Current ISO week present in list → True."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json"
f.write_text(json.dumps({"extended_early_bird_iso_weeks": [current_week]}))
assert has_extended_early_bird(f) is True

View File

@ -128,7 +128,7 @@ class TestHasLoggedToday:
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with patch(
"screen_locker.screen_lock.verify_entry_hmac",
"screen_locker._log_mixin.verify_entry_hmac",
return_value=True,
):
assert locker.has_logged_today() is True
@ -149,7 +149,7 @@ class TestHasLoggedToday:
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with patch(
"screen_locker.screen_lock.verify_entry_hmac",
"screen_locker._log_mixin.verify_entry_hmac",
return_value=False,
):
assert locker.has_logged_today() is False
@ -171,11 +171,11 @@ class TestHasLoggedToday:
locker.log_file = log_file
with (
patch(
"screen_locker.screen_lock.verify_entry_hmac",
"screen_locker._log_mixin.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
),
):
@ -198,11 +198,11 @@ class TestHasLoggedToday:
locker.log_file = log_file
with (
patch(
"screen_locker.screen_lock.verify_entry_hmac",
"screen_locker._log_mixin.verify_entry_hmac",
return_value=False,
),
patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value="some-signature",
),
):
@ -238,7 +238,7 @@ class TestSaveWorkoutLog:
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value="abc123",
):
locker.save_workout_log()
@ -263,7 +263,7 @@ class TestSaveWorkoutLog:
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
):
locker.save_workout_log()
@ -287,7 +287,7 @@ class TestSaveWorkoutLog:
locker.log_file = log_file
locker.workout_data = {"type": "strength"}
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value="sig",
):
locker.save_workout_log()
@ -312,7 +312,7 @@ class TestSaveWorkoutLog:
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value="sig",
):
locker.save_workout_log()
@ -335,7 +335,7 @@ class TestSaveWorkoutLog:
locker.log_file = log_file
locker.workout_data = {"type": "running"}
with patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value="sig",
):
# Should not raise, just log warning

View File

@ -59,7 +59,7 @@ class TestAutoUpgradeSickDay:
return_value=True,
) as mock_adjust,
patch(
"screen_locker.screen_lock.compute_entry_hmac",
"screen_locker._log_mixin.compute_entry_hmac",
return_value="sig",
),
):

View File

@ -63,9 +63,14 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test not_verified result shows retry and sick buttons."""
"""Test not_verified result tries RunnerUp fallback then shows retry+sick."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
object.__setattr__(
locker,
"_start_runnerup_fallback",
MagicMock(side_effect=lambda cb: cb()),
)
locker._handle_startup_phone_result(
"not_verified", "No workout found on phone today"
)
@ -95,14 +100,19 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test stale result shows retry and sick buttons."""
"""Test stale result tries RunnerUp fallback then shows retry+sick."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
object.__setattr__(
locker,
"_start_runnerup_fallback",
MagicMock(side_effect=lambda cb: cb()),
)
locker._handle_startup_phone_result("stale", "Workout too old")
locker._show_retry_and_sick.assert_called_once()
call_args = locker._show_retry_and_sick.call_args[0][0]
assert "reason: stale" in call_args.lower()
assert "workout too old" in call_args.lower()
def test_handle_startup_no_exercises_shows_retry_and_sick(
self,
@ -110,14 +120,19 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no_exercises result shows retry and sick buttons."""
"""Test no_exercises result tries RunnerUp fallback then shows retry+sick."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_retry_and_sick", MagicMock())
object.__setattr__(
locker,
"_start_runnerup_fallback",
MagicMock(side_effect=lambda cb: cb()),
)
locker._handle_startup_phone_result("no_exercises", "No exercises found")
locker._show_retry_and_sick.assert_called_once()
call_args = locker._show_retry_and_sick.call_args[0][0]
assert "reason: no_exercises" in call_args.lower()
assert "no exercises found" in call_args.lower()
def test_handle_startup_clock_tampered_shows_retry_and_sick(
self,
@ -143,9 +158,14 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test no_phone result triggers penalty with default retry+sick callback."""
"""Test no_phone result tries RunnerUp fallback then shows penalty."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
object.__setattr__(
locker,
"_start_runnerup_fallback",
MagicMock(side_effect=lambda cb: cb()),
)
locker._handle_startup_phone_result("no_phone", "No phone")
@ -157,9 +177,14 @@ class TestStartPhoneCheck:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Test error result triggers penalty with default retry+sick callback."""
"""Test error result tries RunnerUp fallback then shows penalty."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_show_phone_penalty", MagicMock())
object.__setattr__(
locker,
"_start_runnerup_fallback",
MagicMock(side_effect=lambda cb: cb()),
)
locker._handle_startup_phone_result("error", "DB not found")

View File

@ -0,0 +1,380 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
import shutil
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
"""Write TCX content to a temp file and return the path string."""
p = tmp_path / name
p.write_text(content)
return str(p)
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------
class TestValidateRunnerupData:
"""Tests for _validate_runnerup_data (lines 388-411)."""
def test_wrong_sport_returns_wrong_sport_status(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Sport not in RUNNERUP_ACCEPTED_SPORTS → wrong_sport."""
locker = create_locker(mock_tk, tmp_path)
# Sport 6 = Gym, not accepted
status, msg = locker._validate_runnerup_data(
{"sport": 6, "duration_seconds": 3600, "distance_m": 6000}
)
assert status == "wrong_sport"
assert "Gym" in msg
def test_unknown_sport_number_shown_in_message(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Unknown sport integer falls back to 'unknown(N)' label."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 99, "duration_seconds": 3600, "distance_m": 6000}
)
assert status == "wrong_sport"
assert "unknown(99)" in msg
def test_too_short_duration_returns_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Duration below MIN_RUN_DURATION_MINUTES → too_short with 'min' in message."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 60, "distance_m": 6000}
)
assert status == "too_short"
assert "min" in msg
def test_too_short_distance_returns_too_short(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Distance below MIN_RUN_DISTANCE_KM → too_short with 'km' in message."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2400, "distance_m": 100}
)
assert status == "too_short"
assert "km" in msg
def test_valid_run_returns_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Sufficient sport, duration, distance → verified with sport name in message."""
locker = create_locker(mock_tk, tmp_path)
status, msg = locker._validate_runnerup_data(
{"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
)
assert status == "verified"
assert "Running" in msg
def test_orienteering_accepted(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Sport 3 (Orienteering) is in RUNNERUP_ACCEPTED_SPORTS."""
locker = create_locker(mock_tk, tmp_path)
status, _ = locker._validate_runnerup_data(
{"sport": 3, "duration_seconds": 2400, "distance_m": 6000}
)
assert status == "verified"
# ---------------------------------------------------------------------------
# _parse_tcx
# ---------------------------------------------------------------------------
class TestParseTcx:
"""Tests for _parse_tcx (lines 109-134)."""
def test_parses_valid_running_tcx(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Valid Running TCX returns correct sport/duration/distance dict."""
locker = create_locker(mock_tk, tmp_path)
path = _write_tcx(tmp_path, _TCX_RUNNING)
result = locker._parse_tcx(path)
assert result is not None
assert result["sport"] == 0 # Running
assert result["duration_seconds"] == 2400
assert result["distance_m"] == 6000.0
def test_parse_error_returns_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Malformed XML is caught by ParseError; returns None."""
locker = create_locker(mock_tk, tmp_path)
path = _write_tcx(tmp_path, "<not-valid xml << ")
assert locker._parse_tcx(path) is None
def test_missing_activity_element_returns_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""TCX with no <Activity> element returns None."""
locker = create_locker(mock_tk, tmp_path)
tcx = """\
<?xml version="1.0"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
</TrainingCenterDatabase>
"""
path = _write_tcx(tmp_path, tcx)
assert locker._parse_tcx(path) is None
def test_multi_lap_sums_correctly(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Two laps: totals are summed across both."""
locker = create_locker(mock_tk, tmp_path)
path = _write_tcx(tmp_path, _TCX_MULTI_LAP)
result = locker._parse_tcx(path)
assert result is not None
assert result["duration_seconds"] == 2400
assert result["distance_m"] == 6000.0
# ---------------------------------------------------------------------------
# _find_runnerup_exports_for_date
# ---------------------------------------------------------------------------
class TestFindRunnerupExportsForDate:
"""Tests for _find_runnerup_exports_for_date (lines 77-88)."""
def test_returns_empty_when_adb_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_run_adb returning False → empty list."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, "")))
assert locker._find_runnerup_exports_for_date("2024-03-15") == []
def test_returns_empty_when_no_matching_files(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""ADB listing with no date-matching .tcx files → empty list."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(return_value=(True, "RunnerUp_2024-01-01-10-00-00.tcx\n")),
)
assert locker._find_runnerup_exports_for_date("2024-03-15") == []
def test_returns_matching_tcx_files(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Matching filename with date string → path included in result."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_run_adb",
MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")),
)
result = locker._find_runnerup_exports_for_date("2024-03-15")
assert len(result) >= 1
assert "2024-03-15" in result[0]
assert result[0].endswith(".tcx")
def test_deduplicates_across_dirs(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Same remote path appearing in two dirs is not duplicated."""
locker = create_locker(mock_tk, tmp_path)
# Both export dirs return the same filename (different dirs → different paths)
object.__setattr__(
locker,
"_run_adb",
MagicMock(return_value=(True, "RunnerUp_2024-03-15-10-30-00_act.tcx\n")),
)
result = locker._find_runnerup_exports_for_date("2024-03-15")
# Paths come from different dirs so both are included, but no duplicates
assert len(result) == len(set(result))
def test_skips_empty_listing(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Empty stdout from ADB is skipped without adding entries."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, " \n")))
assert locker._find_runnerup_exports_for_date("2024-03-15") == []
# ---------------------------------------------------------------------------
# _pull_and_parse_tcx
# ---------------------------------------------------------------------------
class TestPullAndParseTcx:
"""Tests for _pull_and_parse_tcx (lines 92-101)."""
def test_returns_none_when_pull_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Failed adb pull → None returned."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, "")))
assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None
def test_returns_none_when_file_not_written(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""adb pull succeeds but local file absent (race) → None."""
locker = create_locker(mock_tk, tmp_path)
# _run_adb returns True but does not actually write the file.
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(True, "")))
assert locker._pull_and_parse_tcx("/sdcard/some.tcx") is None
def test_returns_parsed_data_on_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Successful pull + valid TCX → parsed activity dict."""
locker = create_locker(mock_tk, tmp_path)
tcx_src = tmp_path / "source.tcx"
tcx_src.write_text(_TCX_RUNNING)
def _fake_pull(args: list[str]) -> tuple[bool, str]:
if args[0] == "pull":
shutil.copy(str(tcx_src), args[2])
return True, ""
return True, ""
object.__setattr__(locker, "_run_adb", MagicMock(side_effect=_fake_pull))
result = locker._pull_and_parse_tcx("/sdcard/activity.tcx")
assert result is not None
assert result["sport"] == 0
assert result["duration_seconds"] == 2400
# ---------------------------------------------------------------------------
# _verify_runnerup_via_files
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,172 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
"""Write TCX content to a temp file and return the path string."""
p = tmp_path / name
p.write_text(content)
return str(p)
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------
class TestVerifyRunnerupViaFiles:
"""Tests for _verify_runnerup_via_files (lines 147-165)."""
def test_returns_none_when_no_exports_found(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No exports for today → None (caller tries DB path)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
assert locker._verify_runnerup_via_files() is None
def test_returns_verified_when_file_passes(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""First valid file → verified immediately."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
status, _ = locker._verify_runnerup_via_files()
assert status == "verified"
def test_returns_best_when_no_file_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Files found but none verified → returns first non-None validation result."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 60, "distance_m": 6000}
),
)
result = locker._verify_runnerup_via_files()
assert result is not None
status, _ = result
assert status == "too_short"
def test_returns_fallback_when_all_files_unreadable(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_pull_and_parse_tcx returns None for every file → fallback not_verified."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None))
status, _ = locker._verify_runnerup_via_files()
assert status == "not_verified"
# ---------------------------------------------------------------------------
# _scan_and_fill_week_runnerup
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,354 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
"""Write TCX content to a temp file and return the path string."""
p = tmp_path / name
p.write_text(content)
return str(p)
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------
class TestScanAndFillWeekRunnerup:
"""Tests for _scan_and_fill_week_runnerup (lines 186-248)."""
def test_returns_zero_when_no_phone(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No ADB device → 0 filled."""
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._scan_and_fill_week_runnerup(log_file) == 0
def test_returns_zero_when_all_days_already_logged(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""All days this week already have counted workouts → 0 new fills."""
from datetime import date, timedelta
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
# Fill Mon-today with phone_verified entries.
today = date.today()
monday = today - timedelta(days=today.weekday())
logs: dict[str, Any] = {}
cur = monday
while cur <= today:
logs[cur.strftime("%Y-%m-%d")] = {
"workout_data": {"type": "phone_verified"}
}
cur += timedelta(days=1)
log_file.write_text(json.dumps(logs))
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker, "_find_runnerup_exports_for_date", MagicMock(return_value=[])
)
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_fills_gap_for_unlogged_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Gap in log + exports found + validated → entry written, count > 0."""
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,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
object.__setattr__(
locker,
"_validate_runnerup_data",
MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")),
)
with patch(
"screen_locker._runnerup_verification.compute_entry_hmac",
return_value="sig",
):
result = locker._scan_and_fill_week_runnerup(log_file)
assert result > 0
logs = json.loads(log_file.read_text())
# At least one date should have been filled.
types = [
v.get("workout_data", {}).get("type")
for v in logs.values()
if isinstance(v, dict)
]
assert "runnerup_verified" in types
def test_skips_date_when_no_exports(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No exports for a date → date skipped, count stays 0."""
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,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_skips_unreadable_tcx(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_pull_and_parse_tcx returns None → remote skipped, not filled."""
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,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(locker, "_pull_and_parse_tcx", MagicMock(return_value=None))
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_skips_not_verified_export(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_validate_runnerup_data returns not-verified → date not filled."""
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,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 60, "distance_m": 6000}
),
)
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_returns_zero_on_write_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""OSError writing log after fill → returns 0 (lines 241-246)."""
locker = create_locker(mock_tk, tmp_path)
# Can't patch PosixPath.open (read-only slot), so wrap it in a
# tiny class that delegates reads but raises on writes.
real_log = tmp_path / "log.json"
real_log.write_text("{}")
class _FailWrite:
def open(self, mode: str = "r", **kw):
if mode == "w":
msg = "disk full"
raise OSError(msg)
return real_log.open(mode, **kw)
fail_log = _FailWrite()
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
object.__setattr__(
locker,
"_validate_runnerup_data",
MagicMock(return_value=("verified", "ok")),
)
with patch(
"screen_locker._runnerup_verification.compute_entry_hmac",
return_value="sig",
):
result = locker._scan_and_fill_week_runnerup(fail_log)
assert result == 0
def test_handles_corrupt_log_file(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Corrupt log JSON → starts with empty dict, still works."""
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
log_file.write_text("not-json")
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
# Should not raise; returns 0 (no exports found).
assert locker._scan_and_fill_week_runnerup(log_file) == 0
def test_hmac_none_still_fills_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""HMAC key absent (compute_entry_hmac returns None) → entry still written."""
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,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/run.tcx"]),
)
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(
return_value={"sport": 0, "duration_seconds": 2400, "distance_m": 6000}
),
)
object.__setattr__(
locker,
"_validate_runnerup_data",
MagicMock(return_value=("verified", "Running: 6.0 km in 40 min")),
)
with patch(
"screen_locker._runnerup_verification.compute_entry_hmac",
return_value=None,
):
result = locker._scan_and_fill_week_runnerup(log_file)
assert result > 0
# No "hmac" key when signature is None.
logs = json.loads(log_file.read_text())
for entry in logs.values():
assert "hmac" not in entry
# ---------------------------------------------------------------------------
# _find_runnerup_package
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,323 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
import os
import shutil
import sqlite3
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
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
"""Write TCX content to a temp file and return the path string."""
p = tmp_path / name
p.write_text(content)
return str(p)
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------
class TestFindRunnerupPackage:
"""Tests for _find_runnerup_package (lines 256-260)."""
def test_returns_none_when_no_package_found(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No package installed → None."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_adb_shell", MagicMock(return_value=(True, "")))
assert locker._find_runnerup_package() is None
def test_returns_first_installed_package(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""First package found → returned."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_adb_shell",
MagicMock(return_value=(True, "package:org.runnerup")),
)
result = locker._find_runnerup_package()
assert result == "org.runnerup"
# ---------------------------------------------------------------------------
# _cleanup_runnerup_sdcard
# ---------------------------------------------------------------------------
class TestCleanupRunnerupSdcard:
"""Tests for _cleanup_runnerup_sdcard (lines 315-316)."""
def test_calls_adb_shell_for_each_suffix(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Cleanup invokes _adb_shell for '', '-wal', '-shm'."""
locker = create_locker(mock_tk, tmp_path)
mock_shell = MagicMock(return_value=(True, ""))
object.__setattr__(locker, "_adb_shell", mock_shell)
locker._cleanup_runnerup_sdcard()
assert mock_shell.call_count == 3
# ---------------------------------------------------------------------------
# _pull_runnerup_db
# ---------------------------------------------------------------------------
class TestPullRunnerupDb:
"""Tests for _pull_runnerup_db (lines 272-311)."""
def test_returns_none_when_package_not_found(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No RunnerUp package installed → None."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_find_runnerup_package", MagicMock(return_value=None)
)
assert locker._pull_runnerup_db() is None
def test_returns_none_when_cp_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Root cp fails → None (cleanup still called)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_package",
MagicMock(return_value="org.runnerup"),
)
object.__setattr__(
locker, "_adb_shell", MagicMock(return_value=(False, "permission denied"))
)
assert locker._pull_runnerup_db() is None
def test_returns_none_when_pull_fails(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""cp succeeds but adb pull fails → None, cleanup called."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_find_runnerup_package",
MagicMock(return_value="org.runnerup"),
)
object.__setattr__(locker, "_adb_shell", MagicMock(return_value=(True, "")))
object.__setattr__(locker, "_run_adb", MagicMock(return_value=(False, "")))
mock_cleanup = MagicMock()
object.__setattr__(locker, "_cleanup_runnerup_sdcard", mock_cleanup)
assert locker._pull_runnerup_db() is None
mock_cleanup.assert_called()
def test_returns_local_path_on_success(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Successful cp + pull + sidecar pulls → returns local db path string."""
locker = create_locker(mock_tk, tmp_path)
# Create a placeholder db so _pull returns a real path.
def _fake_run_adb(args: list[str]) -> tuple[bool, str]:
if args[0] == "pull" and args[2].endswith(".db"):
# Create the local file so the caller finds it.
open(args[2], "w").close()
return True, ""
object.__setattr__(
locker,
"_find_runnerup_package",
MagicMock(return_value="org.runnerup"),
)
object.__setattr__(locker, "_adb_shell", MagicMock(return_value=(True, "")))
object.__setattr__(locker, "_run_adb", MagicMock(side_effect=_fake_run_adb))
object.__setattr__(locker, "_cleanup_runnerup_sdcard", MagicMock())
result = locker._pull_runnerup_db()
assert result is not None
assert result.endswith(".db")
# Cleanup temp dir.
if result and os.path.exists(result):
shutil.rmtree(os.path.dirname(result), ignore_errors=True)
# ---------------------------------------------------------------------------
# _query_todays_run
# ---------------------------------------------------------------------------
class TestQueryTodaysRun:
"""Tests for _query_todays_run (lines 328-355)."""
def _make_db(self, tmp_path: Path) -> str:
"""Create a minimal RunnerUp DB with one activity for today."""
import time
db_path = str(tmp_path / "runnerup.db")
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE activity "
"(start_time REAL, distance REAL, time REAL, type INTEGER, deleted INTEGER)"
)
# Insert a valid activity for today (sport=0, Running).
now_ms = time.time()
conn.execute(
"INSERT INTO activity VALUES (?, ?, ?, ?, ?)",
(now_ms, 6000.0, 2400.0, 0, 0),
)
return db_path
def test_returns_none_for_no_activity_today(self, tmp_path: Path) -> None:
"""Empty DB → None."""
db_path = str(tmp_path / "empty.db")
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE activity "
"(start_time REAL, distance REAL, time REAL, type INTEGER, deleted INTEGER)"
)
# Need a locker instance to call the method.
import tkinter as tk
from unittest.mock import MagicMock
mock_tk = MagicMock()
mock_tk.Tk.return_value = MagicMock()
mock_tk.Tk.return_value.winfo_screenwidth.return_value = 1920
mock_tk.Tk.return_value.winfo_screenheight.return_value = 1080
mock_tk.TclError = tk.TclError
with (
patch("screen_locker.screen_lock.tk", mock_tk),
patch(
"screen_locker.screen_lock.GateRoot",
return_value=mock_tk.Tk.return_value,
),
patch("screen_locker.screen_lock.sys.exit"),
):
locker = create_locker(mock_tk, tmp_path)
result = locker._query_todays_run(db_path)
assert result is None
def test_returns_activity_dict_for_todays_run(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""DB with a today activity → dict with expected keys."""
locker = create_locker(mock_tk, tmp_path)
db_path = self._make_db(tmp_path)
result = locker._query_todays_run(db_path)
assert result is not None
assert "sport" in result
assert result["sport"] == 0
assert result["distance_m"] == 6000.0
def test_returns_none_on_sqlite_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Corrupt DB → sqlite3.Error caught; returns None."""
locker = create_locker(mock_tk, tmp_path)
corrupt_db = str(tmp_path / "corrupt.db")
with open(corrupt_db, "w") as f:
f.write("this is not a sqlite database")
result = locker._query_todays_run(corrupt_db)
assert result is None
# ---------------------------------------------------------------------------
# _verify_runnerup_via_db
# ---------------------------------------------------------------------------

View File

@ -0,0 +1,371 @@
"""Tests for RunnerUpVerificationMixin in _runnerup_verification.py."""
from __future__ import annotations
import os
import shutil
import sqlite3
import tempfile
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
# Minimal valid TCX XML for a 40-minute, 6-km run.
_TCX_RUNNING = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>2400.0</TotalTimeSeconds>
<DistanceMeters>6000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# TCX with an unrecognised sport tag (not in RUNNERUP_ACCEPTED_SPORTS).
_TCX_GYM = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Gym">
<Lap>
<TotalTimeSeconds>3600.0</TotalTimeSeconds>
<DistanceMeters>0.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# Two laps that together make a valid run.
_TCX_MULTI_LAP = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
<Lap>
<TotalTimeSeconds>1200.0</TotalTimeSeconds>
<DistanceMeters>3000.0</DistanceMeters>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_tcx(tmp_path: Path, content: str, name: str = "activity.tcx") -> str:
"""Write TCX content to a temp file and return the path string."""
p = tmp_path / name
p.write_text(content)
return str(p)
# ---------------------------------------------------------------------------
# _validate_runnerup_data
# ---------------------------------------------------------------------------
class TestVerifyRunnerupViaDb:
"""Tests for _verify_runnerup_via_db (lines 364-376)."""
def test_returns_not_verified_when_no_db(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_pull_runnerup_db returns None → not_verified."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(locker, "_pull_runnerup_db", MagicMock(return_value=None))
status, _ = locker._verify_runnerup_via_db()
assert status == "not_verified"
def test_returns_not_verified_when_no_run_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""DB pulled but no activity found → not_verified."""
db_tmp = tempfile.mkdtemp(prefix="runnerup_test_")
db_path = os.path.join(db_tmp, "runnerup.db")
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE activity "
"(start_time REAL, distance REAL, time REAL, type INTEGER, deleted INTEGER)"
)
try:
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_pull_runnerup_db", MagicMock(return_value=db_path)
)
status, _ = locker._verify_runnerup_via_db()
finally:
shutil.rmtree(db_tmp, ignore_errors=True)
assert status == "not_verified"
def test_returns_verified_for_valid_db_run(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""DB with valid run → validated, returns verified."""
import time
db_tmp = tempfile.mkdtemp(prefix="runnerup_test_")
db_path = os.path.join(db_tmp, "runnerup.db")
with sqlite3.connect(db_path) as conn:
conn.execute(
"CREATE TABLE activity "
"(start_time REAL, distance REAL, time REAL, type INTEGER, deleted INTEGER)"
)
conn.execute(
"INSERT INTO activity VALUES (?, ?, ?, ?, ?)",
(time.time(), 6000.0, 2400.0, 0, 0),
)
try:
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_pull_runnerup_db", MagicMock(return_value=db_path)
)
status, _ = locker._verify_runnerup_via_db()
finally:
shutil.rmtree(db_tmp, ignore_errors=True)
assert status == "verified"
# ---------------------------------------------------------------------------
# _verify_runnerup_workout (entry point)
# ---------------------------------------------------------------------------
class TestVerifyRunnerupWorkout:
"""Tests for _verify_runnerup_workout (lines 440-447 and 431)."""
def test_returns_clock_tampered_on_skew(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Clock skew detected → clock_tampered without further checks."""
locker = create_locker(mock_tk, tmp_path)
with patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(False, "Clock is off"),
):
status, msg = locker._verify_runnerup_workout()
assert status == "clock_tampered"
assert "Clock" in msg
def test_returns_no_phone_when_device_absent(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No ADB device → no_phone."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=False),
):
status, _ = locker._verify_runnerup_workout()
assert status == "no_phone"
def test_returns_file_result_when_exports_exist(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""File-based verification succeeds → result returned (line 431 logged)."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=True),
patch.object(
locker,
"_verify_runnerup_via_files",
return_value=("verified", "Running: 6 km in 40 min"),
),
):
status, _msg = locker._verify_runnerup_workout()
assert status == "verified"
def test_falls_back_to_db_when_no_files(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""No file exports → DB path tried."""
locker = create_locker(mock_tk, tmp_path)
with (
patch(
"screen_locker._runnerup_verification.check_clock_skew",
return_value=(True, "ok"),
),
patch.object(locker, "_has_adb_device", return_value=True),
patch.object(locker, "_verify_runnerup_via_files", return_value=None),
patch.object(
locker,
"_verify_runnerup_via_db",
return_value=("not_verified", "no run today"),
) as mock_db,
):
status, _ = locker._verify_runnerup_workout()
assert status == "not_verified"
mock_db.assert_called_once()
# ---------------------------------------------------------------------------
# Branch-coverage gap fixes
# ---------------------------------------------------------------------------
class TestBranchCoverageGaps:
"""Targeted tests for uncovered branches in _runnerup_verification.py."""
# ---- 86->82: inner for-loop iterates >1 time in _find_runnerup_exports_for_date
def test_multi_file_listing_loops_inner_for(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Duplicate filename in ls output: second occurrence hits 'already in found'
branch (86->82 the False branch of 'if remote not in found:')."""
locker = create_locker(mock_tk, tmp_path)
# Same file listed twice → second encounter hits the dedup False-branch
dup_files = (
"RunnerUp_2024-03-15-08-00-00_act.tcx\n"
"RunnerUp_2024-03-15-08-00-00_act.tcx\n"
)
object.__setattr__(
locker,
"_run_adb",
MagicMock(return_value=(True, dup_files)),
)
result = locker._find_runnerup_exports_for_date("2024-03-15")
# Dedup: only one path in the result
assert len(result) >= 1
# ---- 129->131 and 131->126: _parse_tcx with missing TotalTimeSeconds / DistanceMeters
def test_parse_tcx_missing_time_and_distance_elements(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Lap with no TotalTimeSeconds or DistanceMeters: both false-branches hit."""
locker = create_locker(mock_tk, tmp_path)
tcx = """\
<?xml version="1.0" encoding="UTF-8"?>
<TrainingCenterDatabase
xmlns="http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2">
<Activities>
<Activity Sport="Running">
<Lap>
</Lap>
</Activity>
</Activities>
</TrainingCenterDatabase>
"""
path = _write_tcx(tmp_path, tcx, "empty_lap.tcx")
result = locker._parse_tcx(path)
# Should still return a dict (0 seconds, 0 m) not None
assert result is not None
assert result["duration_seconds"] == 0
assert result["distance_m"] == 0.0
# ---- 161->154: _verify_runnerup_via_files iterates over multiple exports
def test_verify_via_files_loops_over_multiple_exports(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Two non-verified exports: first sets best (162), second hits the False branch
of 'if best is None:' (161->154), exercising the loop-continue with best set."""
locker = create_locker(mock_tk, tmp_path)
# Both exports return non-verified data (too_short).
# Iteration 1: best is None → sets best → loop continues to export 2.
# Iteration 2: best is NOT None → False branch of 'if best is None:' (161->154).
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=["/sdcard/a.tcx", "/sdcard/b.tcx"]),
)
short_run = {"sport": 0, "duration_seconds": 60, "distance_m": 6000}
object.__setattr__(
locker,
"_pull_and_parse_tcx",
MagicMock(return_value=short_run),
)
result = locker._verify_runnerup_via_files()
assert result is not None
status, _ = result
assert status == "too_short"
# ---- 203->209: non-dict log entry in _scan_and_fill_week_runnerup
def test_scan_skips_non_dict_log_entries(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Log entry that is not a dict: isinstance branch False → line 209 reached."""
import datetime as dt
locker = create_locker(mock_tk, tmp_path)
log_file = tmp_path / "log.json"
today = dt.date.today()
# Store today's entry as a plain string (not a dict) to trigger branch 203->209
log_file.write_text(
__import__("json").dumps({today.strftime("%Y-%m-%d"): "legacy_value"})
)
object.__setattr__(locker, "_has_adb_device", MagicMock(return_value=True))
object.__setattr__(
locker,
"_find_runnerup_exports_for_date",
MagicMock(return_value=[]),
)
# Should not raise; no exports → 0 filled
assert locker._scan_and_fill_week_runnerup(log_file) == 0

View File

@ -33,7 +33,7 @@ class TestIsScheduledSkipToday:
locker = self._make_locker(mock_tk, tmp_path)
skip_file = tmp_path / "scheduled_skips.json"
with patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
@ -50,7 +50,7 @@ class TestIsScheduledSkipToday:
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text(json.dumps([today]))
with patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is True
@ -66,7 +66,7 @@ class TestIsScheduledSkipToday:
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text(json.dumps(["1999-01-01", "2000-06-15"]))
with patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
@ -82,7 +82,7 @@ class TestIsScheduledSkipToday:
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text("{not valid json}")
with patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False
@ -99,7 +99,7 @@ class TestIsScheduledSkipToday:
skip_file.write_text("[]")
with (
patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
skip_file,
),
patch("builtins.open", side_effect=OSError("permission denied")),
@ -117,7 +117,7 @@ class TestIsScheduledSkipToday:
skip_file = tmp_path / "scheduled_skips.json"
skip_file.write_text("[]")
with patch(
"screen_locker.screen_lock.SCHEDULED_SKIPS_FILE",
"screen_locker._log_mixin.SCHEDULED_SKIPS_FILE",
skip_file,
):
assert locker._is_scheduled_skip_today() is False

View File

@ -0,0 +1,306 @@
"""Tests targeting remaining screen_lock.py coverage gaps."""
from __future__ import annotations
from datetime import datetime, timezone
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 TestCheckNonVerifyExitsExtras:
"""Tests for _check_non_verify_exits coverage gaps (lines 228, 233, 251-254)."""
def test_logs_auto_filled_runnerup_entries(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_scan_and_fill_week_runnerup > 0 + bonus > 0 → bonus logger.info (line 188)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=2),
)
# Short-circuit _check_today_state_exits so the test is time-independent.
object.__setattr__(
locker,
"_check_today_state_exits",
MagicMock(return_value=False),
)
object.__setattr__(
locker,
"_adjust_shutdown_time_by",
MagicMock(return_value=True),
)
with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
patch(
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[0, 5]
),
patch(
"screen_locker.screen_lock.process_week_transition",
return_value=[],
),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=True),
patch("screen_locker.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
locker._adjust_shutdown_time_by.assert_called_once_with(1) # bonus = 5-max(4,0)
def test_auto_fill_no_bonus_when_min_not_exceeded(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""n_filled > 0 but new_count <= min → bonus=0 → branch 187->190 (no bonus log)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=1),
)
object.__setattr__(
locker,
"_check_today_state_exits",
MagicMock(return_value=False),
)
with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
# prev=2, new=3 → bonus=max(0,3-max(4,2))=0 → no bonus logger call
patch(
"screen_locker.screen_lock.count_weekly_workouts", side_effect=[2, 3]
),
patch(
"screen_locker.screen_lock.process_week_transition",
return_value=[],
),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch("screen_locker.screen_lock.has_skip_credit", return_value=False),
patch("screen_locker.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
def test_logs_weekly_reward_message(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""process_week_transition returning messages → logger.info at line 233."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=0),
)
with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
patch(
"screen_locker.screen_lock.process_week_transition",
return_value=["🎉 +1 skip credit for 5-workout week!"],
),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=True),
patch("screen_locker.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
def test_uses_skip_credit_when_minimum_not_met(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""has_skip_credit True + weekly min not met → consume credit and exit (251-254)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=0),
)
# Prevent time-dependent early-exit that would skip the skip-credit branch.
object.__setattr__(
locker,
"_check_today_state_exits",
MagicMock(return_value=False),
)
mock_exit = MagicMock()
with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
patch(
"screen_locker.screen_lock.process_week_transition",
return_value=[],
),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch("screen_locker.screen_lock.has_skip_credit", return_value=True),
patch("screen_locker.screen_lock.consume_skip_credit"),
patch("screen_locker.screen_lock.sys.exit", mock_exit),
):
locker._check_non_verify_exits()
mock_exit.assert_called_once_with(0)
class TestTryAutoUpgradeSickDayRunnerUp:
"""Tests for RunnerUp paths in _try_auto_upgrade_sick_day (lines 273-286)."""
def test_runnerup_exception_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""OSError from _verify_runnerup_workout → returns False (lines 273-275)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(side_effect=OSError("adb fail")),
)
assert locker._try_auto_upgrade_sick_day() is False
def test_runnerup_verified_saves_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp returns verified → saves runnerup_verified entry (lines 281-286)."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {}
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("verified", "Running: 6 km in 40 min")),
)
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
result = locker._try_auto_upgrade_sick_day()
assert result is True
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
data = json.loads(log_file.read_text())
assert data[today]["workout_data"]["type"] == "runnerup_verified"
assert data[today]["workout_data"]["after_sick_day"] == "true"
def test_runnerup_not_verified_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp not verified → returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_sick_day() is False
class TestTryAutoUpgradeEarlyBirdRunnerUp:
"""Tests for RunnerUp paths in screen_lock.py _try_auto_upgrade_early_bird (305-318)."""
def test_runnerup_exception_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RuntimeError from _verify_runnerup_workout → returns False (lines 305-307)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(side_effect=RuntimeError("adb gone")),
)
assert locker._try_auto_upgrade_early_bird() is False
def test_runnerup_verified_saves_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp returns verified → saves runnerup_verified entry (lines 313-318)."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {}
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("verified", "Running: 6 km in 40 min")),
)
object.__setattr__(
locker, "_adjust_shutdown_time_later", MagicMock(return_value=True)
)
with patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None):
result = locker._try_auto_upgrade_early_bird()
assert result is True
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
data = json.loads(log_file.read_text())
assert data[today]["workout_data"]["type"] == "runnerup_verified"
assert data[today]["workout_data"]["after_early_bird"] == "true"
def test_runnerup_not_verified_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""RunnerUp not verified → returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_verify_phone_workout",
MagicMock(return_value=("no_phone", "no phone")),
)
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no run")),
)
assert locker._try_auto_upgrade_early_bird() is False

View File

@ -0,0 +1,183 @@
"""Tests targeting remaining screen_lock.py coverage gaps."""
from __future__ import annotations
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 TestUnlockScreenExtras:
"""Tests for unlock_screen extra-workout bonus and streak display (360-389)."""
def _setup_unlock(
self,
mock_tk: MagicMock,
tmp_path: Path,
weekly_count: int = 5,
streak: int = 0,
adjust_ok: bool = True,
):
"""Create a locker ready to call unlock_screen."""
log_file = tmp_path / "workout_log.json"
log_file.write_text("{}")
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
locker.workout_data = {"type": "phone_verified"}
object.__setattr__(
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=False)
)
object.__setattr__(
locker, "_clear_debt_on_verified_workout", MagicMock(return_value=None)
)
object.__setattr__(
locker,
"_adjust_shutdown_time_by",
MagicMock(return_value=adjust_ok),
)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(return_value=(22, 22, 5)),
)
return locker
def test_extra_workout_bonus_shown(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""weekly_count > 4 + adjust succeeds → extra_bonus_delta calculated (360-364)."""
locker = self._setup_unlock(mock_tk, tmp_path, weekly_count=5)
with (
patch(
"screen_locker.screen_lock.count_weekly_workouts",
return_value=5,
),
patch(
"screen_locker.screen_lock.current_streak",
return_value=0,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
def test_extra_bonus_delta_displayed(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""extra_bonus_delta > 0 → _text called with 'Extra workout' (lines 375-376)."""
locker = self._setup_unlock(mock_tk, tmp_path)
# Simulate before=22, after=23 → delta=1
old_cfg = (22, 22, 5)
new_cfg = (23, 23, 5)
locker._read_shutdown_config.side_effect = [old_cfg, new_cfg]
text_calls: list[str] = []
def _capture_text(msg: str, **kw: object) -> None:
text_calls.append(msg)
object.__setattr__(locker, "_text", _capture_text)
with (
patch(
"screen_locker.screen_lock.count_weekly_workouts",
return_value=5,
),
patch(
"screen_locker.screen_lock.current_streak",
return_value=0,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
assert any("Extra workout" in c for c in text_calls)
def test_streak_displayed_when_nonzero(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""streak >= 1 → _text shows streak line (line 389)."""
locker = self._setup_unlock(mock_tk, tmp_path, weekly_count=3, adjust_ok=False)
text_calls: list[str] = []
def _capture_text(msg: str, **kw: object) -> None:
text_calls.append(msg)
object.__setattr__(locker, "_text", _capture_text)
with (
patch(
"screen_locker.screen_lock.count_weekly_workouts",
return_value=3,
),
patch(
"screen_locker.screen_lock.current_streak",
return_value=3,
),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
assert any("streak" in c.lower() for c in text_calls)
def test_extra_bonus_skipped_when_old_cfg_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""old_cfg is None → branch 361->366: bonus block skipped, delta stays 0."""
locker = self._setup_unlock(mock_tk, tmp_path)
# _read_shutdown_config returns None → condition at 361 is False
locker._read_shutdown_config.return_value = None
with (
patch(
"screen_locker.screen_lock.count_weekly_workouts",
return_value=5,
),
patch("screen_locker.screen_lock.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()
# No assertion beyond "no crash" — we just needed the branch executed.
def test_extra_bonus_skipped_when_new_cfg_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""new_cfg is None → branch 363->366: delta stays 0 even after adjust."""
locker = self._setup_unlock(mock_tk, tmp_path)
# First call (old_cfg): valid; second call (new_cfg after adjust): None
locker._read_shutdown_config.side_effect = [(22, 22, 5), None]
with (
patch(
"screen_locker.screen_lock.count_weekly_workouts",
return_value=5,
),
patch("screen_locker.screen_lock.current_streak", return_value=0),
patch("screen_locker._log_mixin.compute_entry_hmac", return_value=None),
):
locker.unlock_screen()

View File

@ -0,0 +1,159 @@
"""Tests for _shutdown_base module (daily shutdown base reset)."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from screen_locker._shutdown_base import get_base_hours, reset_to_base_if_new_day
if TYPE_CHECKING:
from pathlib import Path
class TestGetBaseHours:
"""Tests for get_base_hours."""
def test_returns_defaults_when_file_missing(self, tmp_path: Path) -> None:
"""Missing file → (21, 21) without errors (lines 29-30)."""
assert get_base_hours(tmp_path / "nonexistent.json") == (21, 21)
def test_returns_stored_hours(self, tmp_path: Path) -> None:
"""Valid file with custom hours → exact values (lines 31-37)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"base_mon_wed_hour": 22, "base_thu_sun_hour": 20}))
assert get_base_hours(f) == (22, 20)
def test_returns_defaults_on_corrupt_json(self, tmp_path: Path) -> None:
"""Corrupt JSON → (21, 21) via except (lines 38-39)."""
f = tmp_path / "state.json"
f.write_text("not-json")
assert get_base_hours(f) == (21, 21)
def test_returns_defaults_on_oserror(self) -> None:
"""OSError on open → (21, 21) via except (lines 38-39)."""
mock_path = MagicMock()
mock_path.exists.return_value = True
mock_path.open.side_effect = OSError("read fail")
assert get_base_hours(mock_path) == (21, 21)
def test_uses_default_when_key_missing(self, tmp_path: Path) -> None:
"""Keys absent in JSON → each defaults to 21."""
f = tmp_path / "state.json"
f.write_text(json.dumps({}))
assert get_base_hours(f) == (21, 21)
class TestResetToBaseIfNewDay:
"""Tests for reset_to_base_if_new_day."""
def _make_mixin(self, write_ok: bool = True) -> MagicMock:
"""Build a minimal mixin mock."""
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (21, 21, 5)
mixin._write_shutdown_config.return_value = write_ok
return mixin
def test_returns_false_when_already_reset_today(self, tmp_path: Path) -> None:
"""Same-day last_reset_date → early False return (line 63)."""
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": today}))
assert reset_to_base_if_new_day(f, self._make_mixin()) is False
def test_resets_when_new_day(self, tmp_path: Path) -> None:
"""Different last_reset_date → reset performed, returns True (lines 67-100)."""
f = tmp_path / "state.json"
f.write_text(
json.dumps(
{
"last_reset_date": "2000-01-01",
"base_mon_wed_hour": 21,
"base_thu_sun_hour": 21,
}
)
)
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True)
def test_resets_when_no_state_file(self, tmp_path: Path) -> None:
"""No state file → treated as new day, reset performed (lines 67-100)."""
f = tmp_path / "nonexistent.json"
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin) is True
mixin._write_shutdown_config.assert_called_once()
def test_returns_false_when_write_config_fails(self, tmp_path: Path) -> None:
"""_write_shutdown_config returns False → reset fails (lines 74-76)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
mixin = self._make_mixin(write_ok=False)
assert reset_to_base_if_new_day(f, mixin) is False
def test_uses_default_morning_end_when_config_is_none(self, tmp_path: Path) -> None:
"""_read_shutdown_config returns None → morning_end defaults to 5 (line 71 else)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
mixin = MagicMock()
mixin._read_shutdown_config.return_value = None
mixin._write_shutdown_config.return_value = True
reset_to_base_if_new_day(f, mixin)
mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True)
def test_clears_sick_day_state_file_on_reset(self, tmp_path: Path) -> None:
"""Existing sick-day file is deleted during reset (lines 79-82)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
sick_file = tmp_path / "sick.json"
sick_file.write_text("{}")
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=sick_file) is True
assert not sick_file.exists()
def test_handles_oserror_on_sick_file_unlink(self, tmp_path: Path) -> None:
"""OSError when removing sick-day file is logged but reset still returns True (lines 83-86)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
sick_mock = MagicMock()
sick_mock.exists.return_value = True
sick_mock.unlink.side_effect = OSError("busy")
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=sick_mock) is True
def test_handles_corrupt_state_file_gracefully(self, tmp_path: Path) -> None:
"""Corrupt state file treated as no date → reset runs (lines 64-65 except branch)."""
f = tmp_path / "state.json"
f.write_text("not-json")
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin) is True
def test_handles_oserror_on_state_write(self, tmp_path: Path) -> None:
"""OSError writing the new state file is caught; function still returns True (lines 96-97)."""
# Use a mock path that fails only on "w" opens.
state_mock = MagicMock()
state_mock.exists.return_value = False # triggers fresh-reset path
state_mock.open.side_effect = OSError("disk full")
mixin = self._make_mixin()
# _write_shutdown_config succeeds, so True is returned even if state write fails.
assert reset_to_base_if_new_day(state_mock, mixin) is True
def test_sick_day_state_file_not_deleted_when_absent(self, tmp_path: Path) -> None:
"""No sick-day file passed → branch skipped, no AttributeError (line 79 branch False)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=None) is True
def test_sick_day_file_not_deleted_when_it_doesnt_exist(
self, tmp_path: Path
) -> None:
"""sick_day_state_file passed but doesn't exist → .unlink() not called (line 79 .exists() False)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
sick_file = tmp_path / "nonexistent_sick.json"
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin, sick_day_state_file=sick_file) is True
assert not sick_file.exists()

View File

@ -145,3 +145,84 @@ class TestWriteRestoredConfig:
):
locker._write_restored_config(21, 20, "2026-03-20")
assert not state_file.exists()
class TestAdjustShutdownTimeBy:
"""Tests for _adjust_shutdown_time_by method (extra-workout bonus)."""
def test_adjusts_time_successfully(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Normal path: reads config, increments both hours, writes back."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
assert locker._adjust_shutdown_time_by(1) is True
locker._write_shutdown_config.assert_called_once_with(22, 22, 5, restore=True)
def test_caps_hours_at_24(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Hours are capped at 24 (midnight-safe shutdown)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(23, 23, 5))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
locker._adjust_shutdown_time_by(2)
locker._write_shutdown_config.assert_called_once_with(24, 24, 5, restore=True)
def test_returns_false_when_config_is_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""_read_shutdown_config returns None → return False immediately."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=None)
)
assert locker._adjust_shutdown_time_by(1) is False
def test_returns_false_on_oserror(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""OSError during read is caught; returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=OSError("permission denied")),
)
assert locker._adjust_shutdown_time_by(1) is False
def test_returns_false_on_value_error(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""ValueError during processing is caught; returns False."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=ValueError("bad value")),
)
assert locker._adjust_shutdown_time_by(1) is False

View File

@ -0,0 +1,390 @@
"""Tests for screen_locker._status.run_status()."""
from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._status import _load_extra_benefits, _load_log, run_status
if TYPE_CHECKING:
import pytest
# ---------------------------------------------------------------------------
# _load_log helpers
# ---------------------------------------------------------------------------
class TestLoadLog:
"""Tests for _load_log."""
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
"""Non-existent file → {}."""
assert _load_log(tmp_path / "nope.json") == {}
def test_valid_json_returned(self, tmp_path: Path) -> None:
"""Valid JSON file → contents."""
f = tmp_path / "log.json"
f.write_text(json.dumps({"2026-06-01": {"x": 1}}))
assert _load_log(f) == {"2026-06-01": {"x": 1}}
def test_invalid_json_returns_empty(self, tmp_path: Path) -> None:
"""Corrupt JSON → {}."""
f = tmp_path / "log.json"
f.write_text("{not json}")
assert _load_log(f) == {}
def test_oserror_returns_empty(self, tmp_path: Path) -> None:
"""OSError on open → {}."""
f = tmp_path / "log.json"
f.write_text("{}")
with patch("builtins.open", side_effect=OSError("perm")):
assert _load_log(f) == {}
# ---------------------------------------------------------------------------
# _load_extra_benefits helpers
# ---------------------------------------------------------------------------
class TestLoadExtraBenefits:
"""Tests for _load_extra_benefits."""
def test_missing_file_returns_empty(self, tmp_path: Path) -> None:
"""Non-existent EXTRA_BENEFITS_FILE → {}."""
with patch("screen_locker._status.EXTRA_BENEFITS_FILE", tmp_path / "nope.json"):
assert _load_extra_benefits() == {}
def test_valid_json_returned(self, tmp_path: Path) -> None:
"""Valid JSON → dict."""
f = tmp_path / "eb.json"
f.write_text(json.dumps({"skip_credits": 2}))
with patch("screen_locker._status.EXTRA_BENEFITS_FILE", f):
assert _load_extra_benefits() == {"skip_credits": 2}
def test_invalid_json_returns_empty(self, tmp_path: Path) -> None:
"""ValueError (invalid JSON) → {}."""
f = tmp_path / "eb.json"
f.write_text("{bad}")
with patch("screen_locker._status.EXTRA_BENEFITS_FILE", f):
assert _load_extra_benefits() == {}
def test_oserror_returns_empty(self, tmp_path: Path) -> None:
"""OSError on read_text → {}."""
f = tmp_path / "eb.json"
f.write_text("{}")
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", f),
patch.object(Path, "read_text", side_effect=OSError("perm")),
):
assert _load_extra_benefits() == {}
# ---------------------------------------------------------------------------
# run_status integration tests
# ---------------------------------------------------------------------------
def _make_locker(
log_file: Path,
*,
n_filled: int = 0,
bonus_applied: bool = False,
cfg: tuple | None = (22, 22, 5),
) -> SimpleNamespace:
"""Build a minimal locker-like namespace for run_status."""
locker = SimpleNamespace(
log_file=log_file,
workout_data={},
)
locker._scan_and_fill_week_runnerup = MagicMock(return_value=n_filled)
locker._adjust_shutdown_time_by = MagicMock(return_value=bonus_applied)
locker._read_shutdown_config = MagicMock(return_value=cfg)
return locker
class TestRunStatusNormal:
"""Tests for run_status display paths (no workouts in log)."""
def test_empty_log_no_fill(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Empty log, no RunnerUp fill → 'No new workouts found', need-more message."""
eb_file = tmp_path / "eb.json"
log_file = tmp_path / "log.json"
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("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "No new workouts found" in out
assert "Need" in out
def test_shutdown_config_printed(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Shutdown config present → shutdown time line shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", cfg=(22, 22, 5))
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=0),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Shutdown tonight" in out
assert "22:00" in out
def test_no_shutdown_config(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Shutdown config None → no shutdown line."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", cfg=None)
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=0),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Shutdown tonight" not in out
def test_skip_credits_and_streak_shown(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""skip_credits=3, streak=2, eb_ext=True → shown in output."""
eb_file = tmp_path / "eb.json"
eb_file.write_text(json.dumps({"skip_credits": 3}))
locker = _make_locker(tmp_path / "log.json", n_filled=0)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=2),
patch("screen_locker._status.has_extended_early_bird", return_value=True),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Skip credits banked : 3" in out
assert "Streak (5+ wks) : 2" in out
assert "Yes — until 09:00" in out
class TestRunStatusWorkoutLog:
"""Tests for per-day log display and counted/uncounted workout marking."""
def test_counted_entry_shown_with_checkmark(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Log entry with counted type → ✓ mark printed."""
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": "runnerup_verified",
"source": "run.tcx",
}
}
}
)
)
eb_file = tmp_path / "eb.json"
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("screen_locker._status.count_weekly_workouts", return_value=1),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "" in out
assert "runnerup_verified" in out
assert "run.tcx" in out
def test_uncounted_entry_shown_with_x(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Log entry with uncounted type → ✗ mark printed."""
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": "early_bird", "source": ""}}})
)
eb_file = tmp_path / "eb.json"
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("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "early_bird" in out
class TestRunStatusFill:
"""Tests for RunnerUp scan paths in run_status."""
def test_fill_with_bonus_applied(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown."""
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("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Auto-filled 2 workout(s)" in out
def test_fill_bonus_pending_when_adjust_fails(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled > 0, bonus > 0, adjust returns False → 'bonus pending' shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=2, 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=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "bonus pending" in out
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."""
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("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "shutdown bonus" not in out
class TestRunStatusMinimumStatus:
"""Tests for the 'remaining/extra/exactly met' summary lines."""
def test_extra_above_minimum(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""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).
"""
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=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "above minimum" in out
def test_exactly_at_minimum(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""after_count == WEEKLY_WORKOUT_MINIMUM → 'met exactly' line.
n_filled=1 so after_count = count_weekly_workouts() = 4 = WEEKLY_WORKOUT_MINIMUM.
bonus = max(0, 4 - max(4, 0)) = 0, so no bonus line is printed.
"""
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=4),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Weekly minimum met exactly" in out
def test_sys_exit_called(self, tmp_path: Path) -> None:
"""run_status always calls sys.exit(0)."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=0)
mock_exit = MagicMock()
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=0),
patch("sys.exit", mock_exit),
):
run_status(locker)
mock_exit.assert_called_once_with(0)
def test_loop_breaks_on_future_day(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Pin today to Monday so the loop hits d > today on day 2, covering line 64."""
from datetime import datetime, timezone
fake_now = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
class _FakeDatetime(datetime):
@classmethod
def now(cls, tz=None): # type: ignore[override]
return fake_now.astimezone(tz) if tz else fake_now
with (
patch("screen_locker._status.datetime", _FakeDatetime),
patch("screen_locker._status.EXTRA_BENEFITS_FILE", tmp_path / "eb.json"),
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=0),
patch("sys.exit"),
):
run_status(_make_locker(tmp_path / "log.json", n_filled=0))
out = capsys.readouterr().out
assert "Mon Jun 22" in out
assert "Tue Jun 23" not in out

View File

@ -3,7 +3,7 @@
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock
from unittest.mock import MagicMock, patch
from screen_locker.tests.conftest import create_locker
@ -33,3 +33,114 @@ class TestUpdateSickCountdownAtZero:
assert locker.workout_data["type"] == "sick_day"
assert locker.workout_data["note"] == "Sick day - shutdown moved earlier"
locker.unlock_screen.assert_called_once()
class TestStartRunnerupFallback:
"""Tests for _start_runnerup_fallback (lines 114-121)."""
def test_submits_verify_and_stores_future(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Fallback sets up future and on_failure, then calls _poll_runnerup_fallback."""
locker = create_locker(mock_tk, tmp_path)
on_failure = MagicMock()
mock_future = MagicMock()
mock_executor = MagicMock()
mock_executor.submit.return_value = mock_future
object.__setattr__(
locker,
"_verify_runnerup_workout",
MagicMock(return_value=("not_verified", "no")),
)
with (
patch(
"screen_locker._ui_flows.ThreadPoolExecutor",
return_value=mock_executor,
),
patch.object(locker, "_poll_runnerup_fallback"),
):
locker._start_runnerup_fallback(on_failure)
assert locker._runnerup_future is mock_future
assert locker._runnerup_on_failure is on_failure
class TestPollRunnerupFallback:
"""Tests for _poll_runnerup_fallback (lines 125-139)."""
def test_routes_to_unlock_when_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Future done + verified → sets workout_data and schedules unlock (lines 127-135)."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("verified", "Running: 6.0 km in 40 min")
locker._runnerup_future = mock_future
locker._runnerup_on_failure = MagicMock()
locker.workout_data = {}
object.__setattr__(locker, "unlock_screen", MagicMock())
locker._poll_runnerup_fallback()
assert locker.workout_data["type"] == "runnerup_verified"
assert locker.workout_data["source"] == "Running: 6.0 km in 40 min"
locker.root.after.assert_called()
def test_calls_on_failure_when_not_verified(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Future done + non-verified → on_failure callback invoked (lines 136-137)."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("no_phone", "no phone connected")
on_failure = MagicMock()
locker._runnerup_future = mock_future
locker._runnerup_on_failure = on_failure
locker._poll_runnerup_fallback()
on_failure.assert_called_once()
def test_schedules_retry_when_future_not_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Future still running → schedule next poll after 500 ms (lines 138-139)."""
locker = create_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = False
locker._runnerup_future = mock_future
locker._poll_runnerup_fallback()
locker.root.after.assert_called_with(500, locker._poll_runnerup_fallback)
def test_schedules_retry_when_future_is_none(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""None future (not started yet) → poll again in 500 ms."""
locker = create_locker(mock_tk, tmp_path)
locker._runnerup_future = None
locker._poll_runnerup_fallback()
locker.root.after.assert_called_with(500, locker._poll_runnerup_fallback)

View File

@ -22,7 +22,7 @@ class TestWakeSkipIntegration:
) -> None:
"""Screen locker exits if wake alarm granted workout skip today."""
with patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=True,
):
create_locker(mock_tk, tmp_path, has_logged=False)
@ -37,7 +37,7 @@ class TestWakeSkipIntegration:
) -> None:
"""Screen locker proceeds normally if no wake skip active."""
with patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=False,
):
locker = create_locker(mock_tk, tmp_path, has_logged=False)
@ -53,7 +53,7 @@ class TestWakeSkipIntegration:
) -> None:
"""has_logged_today exits before wake skip is even checked."""
with patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=True,
):
create_locker(mock_tk, tmp_path, has_logged=True)
@ -69,7 +69,7 @@ class TestWakeSkipIntegration:
) -> None:
"""verify_only mode checks sick day log, not wake skip."""
with patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=True,
):
create_locker(

View File

@ -98,7 +98,7 @@ class TestCheckTodayStateExits:
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=True,
),
):
@ -117,7 +117,7 @@ class TestCheckTodayStateExits:
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=False,
),
patch.object(locker, "_is_early_bird_time", return_value=True),
@ -138,7 +138,7 @@ class TestCheckTodayStateExits:
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker.screen_lock.has_workout_skip_today",
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=False,
),
patch.object(locker, "_is_early_bird_time", return_value=False),

4
scripts/check_file_length.py Normal file → Executable file
View File

@ -13,12 +13,10 @@ def main() -> int:
try:
with open(filepath, encoding="utf-8", errors="replace") as fh:
count = sum(1 for _ in fh)
except OSError as exc:
print(f"ERROR reading {filepath}: {exc}", file=sys.stderr)
except OSError:
failed = True
continue
if count > MAX_LINES:
print(f"{filepath}: {count} lines (max {MAX_LINES})")
failed = True
return 1 if failed else 0

View File

@ -1,6 +1,6 @@
Why:
Stronglift app on my rooted device stopped working, we need a new app for tracking workout
on that note please disable screen locker functionality untill app is ready to go and fully tested functionally
on that note please disable screen locker functionality until app is ready to go and fully tested functionally
Functional Requirements:
Tracks workouts (current {sets}x{reps}x{weight (in kg)}):
@ -17,9 +17,9 @@ Functional Requirements:
Situp 3x30x10
Exercisees succeded means that the user was able to do ALL sets with ALL reps
Exercisees succeeded means that the user was able to do ALL sets with ALL reps
Automatically increases weight (in increments of 2.5kg) or number of reps (if maximum weight of 27.5 kg was reached for given exercise (see Dumbbell Romanian Deadlift) Situp has maximum weight of 10kg)
if user succeeded in doign this exercise in a continous way for between 1-5 days in a row (selectable by user unless max weight reached in which case 27.5 kg should always be chosen)
if user succeeded in doing this exercise in a continuous way for between 1-5 days in a row (selectable by user unless max weight reached in which case 27.5 kg should always be chosen)
automatically decreases weight (in decrements of 2.5kg) if user failed to do the exercise for 1-5 days in a row (selectable by user)
automatically decreases weight if user had a break from using the app
Tracks how much time workout took -> Fully automatically, user CANNOT set it manually
@ -29,7 +29,7 @@ The user selects exercise as done by tapping on a circle with number of reps for
the user failed to do the exercise and it decreases the rep by "1" tappign again further reduces this count, if user holds finger over the specific circle they can reset the state of this circle (which should
cancel any failed/succeed state)
shows history of workouts and a graph for showing progress
Crucial: The app should be able to comunicate with this pc (arch linux) and inform it if the user had succeed to do the exercises and transfer full info about todays workout to the pc, crucialy:
Crucial: The app should be able to communicate with this pc (arch linux) and inform it if the user had succeed to do the exercises and transfer full info about todays workout to the pc, crucially:
time and how many sets reps and weight was done, change screen locker if needed
The app should be capable of working in the background without any problem and display status notifications allowing user to click on "done rep" from the status bar
After break time is over app should play a sound and vibrate the phone and generally point the user attention towards the app

View File

@ -1,8 +1,8 @@
This is a continouation from design.md file with what is left to be done and what new ideas came to me since the last time arranged in order of importance
Crucial (max 1 feature):
If user starts workout and later either exit the app completely or clicks the arrow in upper left the workout gets reseted completely, all progress is lost this is very bad
once user starts workout only by tapping finish and confriming that they INDEED finished workout should end it OR if user clicks and confirms RESET button, NOTHING ELSE
If user starts workout and later either exit the app completely or clicks the arrow in upper left the workout gets reset completely, all progress is lost this is very bad
once user starts workout only by tapping finish and confirming that they INDEED finished workout should end it OR if user clicks and confirms RESET button, NOTHING ELSE
High (max 2 features):
adds breaks between REPS (3 minutes if REP succeeded (as in all reps were done) and 5 minutes if it failed) <-- currently app ads breaks between SETS which wrong