screen_locker: add weekly check feature with UI integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-28 07:04:18 +02:00
parent e34b513ced
commit 1172aff8fb
7 changed files with 1164 additions and 34 deletions

View File

@ -11,6 +11,10 @@ from python_pkg.screen_locker._constants import (
PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_DEMO,
PHONE_PENALTY_DELAY_PRODUCTION, PHONE_PENALTY_DELAY_PRODUCTION,
) )
from python_pkg.screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
)
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Callable from collections.abc import Callable
@ -72,33 +76,31 @@ class UIFlowsMixin:
self.workout_data["type"] = "phone_verified" self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message self.workout_data["source"] = message
self.clear_container() self.clear_container()
self._label( self._label("✓ Workout Verified!", font_size=42, color="#00cc44", pady=30)
"\u2713 Workout Verified!", font_size=42, color="#00cc44", pady=30
)
self._text(message, font_size=20, color="#aaffaa") self._text(message, font_size=20, color="#aaffaa")
self._text("Unlocking...", font_size=18, color="#888888") self._text("Unlocking...", font_size=18, color="#888888")
unlock_delay = 1500 if self.demo_mode else 2000 unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen) self.root.after(unlock_delay, self.unlock_screen)
elif status == "too_short": elif status == "too_short":
self._show_retry_and_sick( self._show_retry_and_sick(
f"\u274c {message}\n\n" f" {message}\n\n"
"Your workout was too short!\n" "Your workout was too short!\n"
"Actually do the full workout, don't just\n" "Actually do the full workout, don't just\n"
"spam through the exercises.", "spam through the exercises.",
) )
elif status in ("stale", "no_exercises"): elif status in ("stale", "no_exercises"):
self._show_retry_and_sick( self._show_retry_and_sick(
f"\u274c {message}\n\nReason: {status}", f" {message}\n\nReason: {status}",
) )
elif status == "clock_tampered": elif status == "clock_tampered":
self._show_retry_and_sick( self._show_retry_and_sick(
f"\u274c {message}\n\n" f" {message}\n\n"
"System clock appears to be manipulated.\n" "System clock appears to be manipulated.\n"
"Fix your system time and try again.", "Fix your system time and try again.",
) )
elif status == "not_verified": elif status == "not_verified":
self._show_retry_and_sick( self._show_retry_and_sick(
f"\u274c {message}\n\n" f" {message}\n\n"
"StrongLifts shows no workout today.\n" "StrongLifts shows no workout today.\n"
"Go do your workout first!", "Go do your workout first!",
) )
@ -116,7 +118,7 @@ class UIFlowsMixin:
return "Shutdown time already adjusted today", "#ffaa00" return "Shutdown time already adjusted today", "#ffaa00"
if self._adjust_shutdown_time_earlier(): if self._adjust_shutdown_time_earlier():
return ( return (
"Shutdown time moved 1.5 hours earlier \u2713\n(Will revert tomorrow)" "Shutdown time moved 1.5 hours earlier \n(Will revert tomorrow)"
), "#00aa00" ), "#00aa00"
return "Could not adjust shutdown time (check permissions)", "#ff4444" return "Could not adjust shutdown time (check permissions)", "#ff4444"
@ -313,7 +315,7 @@ class UIFlowsMixin:
self.save_workout_log() self.save_workout_log()
self.clear_container() self.clear_container()
self._label( self._label(
"\u2713 Workout Verified!", " Workout Verified!",
font_size=42, font_size=42,
color="#00cc44", color="#00cc44",
pady=30, pady=30,
@ -354,3 +356,97 @@ class UIFlowsMixin:
command=self.close, command=self.close,
width=12, width=12,
).pack(side="left", padx=10) ).pack(side="left", padx=10)
# ------------------------------------------------------------------
# Relaxed-day flow (Tue/Wed/Thu — optional, no penalty for skipping)
# ------------------------------------------------------------------
def _start_relaxed_day_flow(self) -> None:
"""Show optional workout prompt for relaxed days (Tue-Thu).
The screen is not locked the user can skip freely or voluntarily
import a Stronglift workout that counts toward the weekly minimum.
"""
count = count_weekly_workouts(self.log_file)
self.clear_container()
self._label(
"Optional Day (Tue / Wed / Thu)",
font_size=30,
color="#ffaa00",
pady=20,
)
self._text(
f"Weekly workouts: {count} / {WEEKLY_WORKOUT_MINIMUM}\n"
"No penalty for skipping today.",
font_size=20,
color="#aaaaaa",
pady=10,
)
frame = self._button_row()
self._button(
frame,
"Skip — No Penalty",
bg="#006600",
command=self.close,
width=18,
).pack(side="left", padx=10)
self._button(
frame,
"Log Stronglift Workout",
bg="#0066cc",
command=self._start_relaxed_phone_check,
width=20,
).pack(side="left", padx=10)
def _start_relaxed_phone_check(self) -> None:
"""Run Stronglift check in relaxed mode (no screen grab, no sick option)."""
self.clear_container()
self._label("Checking phone...", font_size=36, color="#ffaa00", pady=30)
self._text("Looking for today's workout in StrongLifts...", font_size=18)
executor = ThreadPoolExecutor(max_workers=1)
self._phone_future = executor.submit(self._verify_phone_workout)
executor.shutdown(wait=False)
self._poll_relaxed_phone_check()
def _poll_relaxed_phone_check(self) -> None:
"""Poll background phone check in relaxed-day mode."""
if self._phone_future is not None and self._phone_future.done():
status, message = self._phone_future.result()
self._handle_relaxed_phone_result(status, message)
else:
self.root.after(500, self._poll_relaxed_phone_check)
def _handle_relaxed_phone_result(self, status: str, message: str) -> None:
"""Route phone check result in relaxed-day mode.
On success saves the workout (counts toward weekly total) then closes.
On failure shows retry and close no sick option since skipping is free.
"""
if status == "verified":
self.workout_data["type"] = "phone_verified"
self.workout_data["source"] = message
unlock_delay = 1500 if self.demo_mode else 2000
self.root.after(unlock_delay, self.unlock_screen)
else:
self._show_relaxed_retry(message, status)
def _show_relaxed_retry(self, message: str, status: str) -> None:
"""Show retry and skip-close when workout not found in relaxed mode."""
self.clear_container()
self._label("No Workout Found", font_size=36, color="#ff4444", pady=20)
self._text(f"{message}\n\nReason: {status}", color="#ffaa00")
frame = self._button_row()
self._button(
frame,
"TRY AGAIN",
bg="#0066cc",
command=self._start_relaxed_phone_check,
width=12,
).pack(side="left", padx=10)
self._button(
frame,
"Close (Skip)",
bg="#006600",
command=self.close,
width=14,
).pack(side="left", padx=10)

View File

@ -0,0 +1,108 @@
"""Weekly workout count and day-of-week mode detection for the screen locker.
On Tue/Wed/Thu (relaxed days) the lock is optional: the user can skip
without any penalty, or voluntarily import a Stronglift workout which
will count toward the weekly minimum.
On Fri/Sat/Sun/Mon (enforced days) the lock fires unless the user has
already logged at least WEEKLY_WORKOUT_MINIMUM verified workouts in the
current ISO week (Mon-Sun).
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
import json
import logging
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
WEEKLY_WORKOUT_MINIMUM: int = 4
# Python weekday(): Mon=0, Tue=1, Wed=2, Thu=3, Fri=4, Sat=5, Sun=6
_RELAXED_WEEKDAYS: frozenset[int] = frozenset({1, 2, 3}) # Tue, Wed, Thu
# Only phone-verified workouts count toward the weekly minimum.
_COUNTED_WORKOUT_TYPES: frozenset[str] = frozenset({"phone_verified"})
def is_relaxed_day(*, today: datetime | None = None) -> bool:
"""Return True if today is a relaxed day (Tue, Wed, or Thu).
Args:
today: Override for the current local datetime (for testing).
Returns:
True when the current weekday is Tuesday, Wednesday, or Thursday.
"""
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
return dt.weekday() in _RELAXED_WEEKDAYS
def count_weekly_workouts(
log_file: Path,
*,
today: datetime | None = None,
) -> int:
"""Count phone-verified workouts logged in the current ISO week (Mon-Sun).
Args:
log_file: Path to ``workout_log.json``.
today: Override for the current local datetime (for testing).
Returns:
Number of ``phone_verified`` entries whose date falls within the
current ISO week, up to and including today.
"""
dt = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
week_start = (dt - timedelta(days=dt.weekday())).date()
today_date = dt.date()
if not log_file.exists():
return 0
try:
with log_file.open() as f:
logs: dict[str, Any] = json.load(f)
except (OSError, json.JSONDecodeError):
_logger.warning("Could not read workout log for weekly count")
return 0
count = 0
for date_str, entry in logs.items():
try:
entry_date = (
datetime.strptime(date_str, "%Y-%m-%d")
.replace(tzinfo=timezone.utc)
.date()
)
except ValueError:
continue
if not (week_start <= entry_date <= today_date):
continue
if not isinstance(entry, dict):
continue
wtype = entry.get("workout_data", {}).get("type", "")
if wtype in _COUNTED_WORKOUT_TYPES:
count += 1
return count
def has_weekly_minimum(
log_file: Path,
*,
today: datetime | None = None,
) -> bool:
"""Return True if the weekly workout minimum has already been reached.
Args:
log_file: Path to ``workout_log.json``.
today: Override for the current local datetime (for testing).
Returns:
True when ``count_weekly_workouts`` >= ``WEEKLY_WORKOUT_MINIMUM``.
"""
return count_weekly_workouts(log_file, today=today) >= WEEKLY_WORKOUT_MINIMUM

View File

@ -64,6 +64,12 @@ class WindowSetupMixin:
) )
close_btn.place(x=10, y=10) close_btn.place(x=10, y=10)
def _setup_relaxed_day_window(self) -> None:
"""Configure a small non-locking window for the optional Tue-Thu prompt."""
self.root.geometry("700x450")
self.root.configure(bg="#1a1a1a", cursor="arrow")
self.root.protocol("WM_DELETE_WINDOW", self.close)
def _grab_input(self) -> None: def _grab_input(self) -> None:
"""Force input focus to the locker window.""" """Force input focus to the locker window."""
self.root.update_idletasks() self.root.update_idletasks()

View File

@ -38,6 +38,11 @@ from python_pkg.screen_locker._phone_verification import PhoneVerificationMixin
from python_pkg.screen_locker._shutdown import ShutdownMixin from python_pkg.screen_locker._shutdown import ShutdownMixin
from python_pkg.screen_locker._sick_dialog import SickDialogMixin from python_pkg.screen_locker._sick_dialog import SickDialogMixin
from python_pkg.screen_locker._ui_flows import UIFlowsMixin from python_pkg.screen_locker._ui_flows import UIFlowsMixin
from python_pkg.screen_locker._weekly_check import (
WEEKLY_WORKOUT_MINIMUM,
has_weekly_minimum,
is_relaxed_day,
)
from python_pkg.screen_locker._window_setup import WindowSetupMixin from python_pkg.screen_locker._window_setup import WindowSetupMixin
from python_pkg.wake_alarm._state import has_workout_skip_today from python_pkg.wake_alarm._state import has_workout_skip_today
@ -57,6 +62,7 @@ __all__ = [
"SCHEDULED_SKIPS_FILE", "SCHEDULED_SKIPS_FILE",
"SICK_LOCKOUT_SECONDS", "SICK_LOCKOUT_SECONDS",
"STRONGLIFTS_DB_REMOTE", "STRONGLIFTS_DB_REMOTE",
"WEEKLY_WORKOUT_MINIMUM",
"ScreenLocker", "ScreenLocker",
] ]
@ -100,6 +106,7 @@ class ScreenLocker(
self.log_file = script_dir / "workout_log.json" self.log_file = script_dir / "workout_log.json"
self.verify_only = verify_only self.verify_only = verify_only
self.workout_data: dict[str, str] = {} self.workout_data: dict[str, str] = {}
self._relaxed_day_mode: bool = False
self._check_early_exits(verify_only=verify_only) self._check_early_exits(verify_only=verify_only)
self.root = tk.Tk() self.root = tk.Tk()
title_suffix = ( title_suffix = (
@ -110,6 +117,8 @@ class ScreenLocker(
self.lockout_time = 10 if demo_mode else 1800 self.lockout_time = 10 if demo_mode else 1800
if verify_only: if verify_only:
self._setup_verify_window() self._setup_verify_window()
elif self._relaxed_day_mode:
self._setup_relaxed_day_window()
else: else:
self._setup_window() self._setup_window()
if demo_mode: if demo_mode:
@ -119,6 +128,8 @@ class ScreenLocker(
self._phone_future: Future[tuple[str, str]] | None = None self._phone_future: Future[tuple[str, str]] | None = None
if verify_only: if verify_only:
self._start_verify_workout_check() self._start_verify_workout_check()
elif self._relaxed_day_mode:
self._start_relaxed_day_flow()
else: else:
self._start_phone_check() self._start_phone_check()
self._grab_input() self._grab_input()
@ -149,39 +160,51 @@ class ScreenLocker(
return return
self._check_non_verify_exits() 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: def _check_non_verify_exits(self) -> None:
"""Check all normal (non-verify) startup early-exit conditions.""" """Check all normal (non-verify) startup early-exit conditions."""
if self._is_scheduled_skip_today(): if self._is_scheduled_skip_today():
_logger.info("Today is a scheduled skip day. Skipping screen lock.") _logger.info("Today is a scheduled skip day. Skipping screen lock.")
sys.exit(0) sys.exit(0)
if self._is_early_bird_log() and not self._is_early_bird_time(): return
if self._try_auto_upgrade_early_bird(): if self._check_today_state_exits():
_logger.info( return
"Auto-upgraded early_bird entry to phone_verified.", # Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
) if is_relaxed_day():
sys.exit(0) _logger.info("Relaxed day (Tue-Thu) - showing optional workout prompt.")
elif self._is_early_bird_log(): self._relaxed_day_mode = True
_logger.info("Early bird window still active — skipping lock.") return
sys.exit(0) # Fri-Mon: skip lock when weekly minimum is already met.
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day(): if has_weekly_minimum(self.log_file):
_logger.info( _logger.info(
"Auto-upgraded today's sick_day entry to phone_verified.", "Weekly minimum of %d workouts met. Skipping screen lock.",
) WEEKLY_WORKOUT_MINIMUM,
sys.exit(0)
elif self.has_logged_today():
_logger.info("Workout already logged today. Skipping screen lock.")
sys.exit(0)
elif has_workout_skip_today():
_logger.info(
"Wake alarm earned workout skip. Skipping screen lock.",
)
sys.exit(0)
elif self._is_early_bird_time():
self._save_early_bird_log()
_logger.info(
"Early bird time — skipping lock, will re-check at 08:30.",
) )
sys.exit(0) sys.exit(0)
return
def _try_auto_upgrade_sick_day(self) -> bool: def _try_auto_upgrade_sick_day(self) -> bool:
"""Silently upgrade today's sick_day entry if phone shows a workout.""" """Silently upgrade today's sick_day entry if phone shows a workout."""

View File

@ -106,6 +106,28 @@ def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
yield yield
@pytest.fixture(autouse=True)
def _mock_weekly_logic() -> Iterator[None]:
"""Default to Fri-Mon enforcement with weekly minimum not yet met.
Without this, tests that run on a Tue/Wed/Thu would hit the relaxed-day
branch instead of the full-lock path that existing tests expect.
Setting has_weekly_minimum=False ensures the full lock is shown
(weekly quota not reached enforce).
"""
with (
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=False,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
):
yield
@pytest.fixture @pytest.fixture
def mock_tk() -> Generator[MagicMock]: def mock_tk() -> Generator[MagicMock]:
"""Mock tkinter module for testing without display.""" """Mock tkinter module for testing without display."""
@ -172,6 +194,7 @@ def create_locker(
return_value=False, return_value=False,
), ),
patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"), patch.object(ScreenLocker, "_start_verify_workout_check"),
): ):
return ScreenLocker( return ScreenLocker(
@ -180,6 +203,38 @@ def create_locker(
) )
def create_locker_relaxed_day(
_mock_tk: MagicMock,
tmp_path: Path,
*,
demo_mode: bool = True,
has_logged: bool = False,
) -> ScreenLocker:
"""Create a ScreenLocker in relaxed-day mode (Tue/Wed/Thu).
``is_relaxed_day`` returns True so ``_relaxed_day_mode`` is set and
``_start_relaxed_day_flow`` is called instead of ``_start_phone_check``.
The autouse ``_mock_weekly_logic`` fixture is overridden here.
"""
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
patch("python_pkg.screen_locker.screen_lock.is_relaxed_day", return_value=True),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
return ScreenLocker(demo_mode=demo_mode)
def create_locker_early_bird( def create_locker_early_bird(
_mock_tk: MagicMock, _mock_tk: MagicMock,
tmp_path: Path, tmp_path: Path,
@ -212,6 +267,7 @@ def create_locker_early_bird(
), ),
patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False), patch.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"), patch.object(ScreenLocker, "_start_verify_workout_check"),
): ):
return ScreenLocker(demo_mode=demo_mode) return ScreenLocker(demo_mode=demo_mode)

View File

@ -0,0 +1,243 @@
"""Tests for _weekly_check: is_relaxed_day, count_weekly_workouts,
has_weekly_minimum."""
from __future__ import annotations
from datetime import datetime, timezone
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
from python_pkg.screen_locker._weekly_check import (
_RELAXED_WEEKDAYS,
WEEKLY_WORKOUT_MINIMUM,
count_weekly_workouts,
has_weekly_minimum,
is_relaxed_day,
)
if TYPE_CHECKING:
from pathlib import Path
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _dt(weekday: int, hour: int = 10) -> datetime:
"""Return a UTC-aware datetime for the given ISO weekday (0=Mon, 6=Sun)."""
# 2025-05-19 is a Monday (weekday 0)
base = datetime(2025, 5, 19, hour, 0, 0, tzinfo=timezone.utc)
from datetime import timedelta
return base + timedelta(days=weekday)
def _make_log(entries: dict[str, str], log_file: Path) -> Path:
"""Write a workout_log.json with given date→workout_type mapping."""
data: dict[str, Any] = {
date: {
"timestamp": f"{date}T10:00:00+00:00",
"workout_data": {"type": wtype},
}
for date, wtype in entries.items()
}
log_file.write_text(json.dumps(data))
return log_file
# ---------------------------------------------------------------------------
# is_relaxed_day
# ---------------------------------------------------------------------------
class TestIsRelaxedDay:
def test_monday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(0)) is False
def test_tuesday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(1)) is True
def test_wednesday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(2)) is True
def test_thursday_is_relaxed(self) -> None:
assert is_relaxed_day(today=_dt(3)) is True
def test_friday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(4)) is False
def test_saturday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(5)) is False
def test_sunday_is_enforced(self) -> None:
assert is_relaxed_day(today=_dt(6)) is False
def test_relaxed_weekdays_constant_correct(self) -> None:
assert frozenset({1, 2, 3}) == _RELAXED_WEEKDAYS
def test_uses_local_time_by_default(self) -> None:
result = is_relaxed_day()
assert isinstance(result, bool)
# ---------------------------------------------------------------------------
# count_weekly_workouts
# ---------------------------------------------------------------------------
class TestCountWeeklyWorkouts:
def test_no_log_file_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_corrupt_json_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
log.write_text("{not valid json}")
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_oserror_returns_zero(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
log.write_text("{}")
with patch("builtins.open", side_effect=OSError("no permission")):
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_phone_verified_in_current_week(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# Mon=2025-05-19, Tue=2025-05-20 both in same week; check on Fri=2025-05-23
_make_log({"2025-05-19": "phone_verified", "2025-05-20": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 2
def test_sick_day_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "sick_day"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_early_bird_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "early_bird"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_previous_week_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# 2025-05-12 is the Monday of the previous week
_make_log({"2025-05-12": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_future_date_not_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# 2025-05-24 is Saturday, checking on Friday 2025-05-23
_make_log({"2025-05-24": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_invalid_date_key_skipped(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {
"not-a-date": {
"timestamp": "x",
"workout_data": {"type": "phone_verified"},
},
"2025-05-19": {
"timestamp": "x",
"workout_data": {"type": "phone_verified"},
},
}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_non_dict_entry_skipped(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
data: dict[str, Any] = {"2025-05-19": "not-a-dict"}
log.write_text(json.dumps(data))
assert count_weekly_workouts(log, today=_dt(4)) == 0
def test_counts_up_to_four(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert count_weekly_workouts(log, today=_dt(4)) == 4
def test_today_counts_if_this_week(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
# today is Friday 2025-05-23
_make_log({"2025-05-23": "phone_verified"}, log)
assert count_weekly_workouts(log, today=_dt(4)) == 1
def test_monday_start_of_week_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log({"2025-05-19": "phone_verified"}, log)
# Checking on Monday itself (today=Mon)
assert count_weekly_workouts(log, today=_dt(0)) == 1
def test_mixed_types_only_verified_counted(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "sick_day",
"2025-05-21": "early_bird",
"2025-05-22": "phone_verified",
},
log,
)
assert count_weekly_workouts(log, today=_dt(4)) == 2
# ---------------------------------------------------------------------------
# has_weekly_minimum
# ---------------------------------------------------------------------------
class TestHasWeeklyMinimum:
def test_zero_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_three_workouts_is_false(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is False
def test_four_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_five_workouts_is_true(self, tmp_path: Path) -> None:
log = tmp_path / "workout_log.json"
_make_log(
{
"2025-05-19": "phone_verified",
"2025-05-20": "phone_verified",
"2025-05-21": "phone_verified",
"2025-05-22": "phone_verified",
"2025-05-23": "phone_verified",
},
log,
)
assert has_weekly_minimum(log, today=_dt(4)) is True
def test_weekly_workout_minimum_constant(self) -> None:
assert WEEKLY_WORKOUT_MINIMUM == 4

View File

@ -0,0 +1,598 @@
"""Tests for weekly workout enforcement and relaxed-day (Tue-Thu) logic."""
from __future__ import annotations
from pathlib import Path
from unittest.mock import MagicMock, patch
from python_pkg.screen_locker.screen_lock import ScreenLocker
from python_pkg.screen_locker.tests.conftest import (
create_locker,
create_locker_relaxed_day,
)
# ---------------------------------------------------------------------------
# _check_non_verify_exits: relaxed-day branch
# ---------------------------------------------------------------------------
class TestRelaxedDayBranch:
def test_relaxed_day_sets_flag_instead_of_exiting(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker_relaxed_day(mock_tk, tmp_path)
assert locker._relaxed_day_mode is True
mock_sys_exit.assert_not_called()
def test_relaxed_day_calls_start_relaxed_flow(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
),
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=True,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_start_phone_check") as mock_phone,
patch.object(ScreenLocker, "_start_relaxed_day_flow") as mock_relaxed,
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
ScreenLocker(demo_mode=True)
mock_relaxed.assert_called_once()
mock_phone.assert_not_called()
def test_relaxed_day_uses_small_window_not_fullscreen(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
),
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=True,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_setup_window") as mock_full,
patch.object(ScreenLocker, "_setup_relaxed_day_window") as mock_small,
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
ScreenLocker(demo_mode=True)
mock_small.assert_called_once()
mock_full.assert_not_called()
def test_relaxed_day_no_grab_input(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
),
patch(
"python_pkg.screen_locker.screen_lock.is_relaxed_day",
return_value=True,
),
patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
),
patch.object(ScreenLocker, "_grab_input") as mock_grab,
patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_relaxed_day_flow"),
patch.object(ScreenLocker, "_start_verify_workout_check"),
):
ScreenLocker(demo_mode=True)
mock_grab.assert_not_called()
def test_has_logged_today_exits_before_relaxed_check(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
create_locker_relaxed_day(mock_tk, tmp_path, has_logged=True)
mock_sys_exit.assert_called_once_with(0)
# ---------------------------------------------------------------------------
# _check_non_verify_exits: Fri-Mon weekly minimum branch
# ---------------------------------------------------------------------------
class TestWeeklyMinimumBranch:
def test_weekly_minimum_met_exits(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=True,
):
create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_called_once_with(0)
def test_weekly_minimum_not_met_shows_full_lock(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
# create_locker already stubs _start_phone_check; just verify no exit
# and _relaxed_day_mode stays False (full lock path taken).
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
return_value=False,
):
locker = create_locker(mock_tk, tmp_path, has_logged=False)
mock_sys_exit.assert_not_called()
assert locker._relaxed_day_mode is False
def test_weekly_minimum_not_checked_on_relaxed_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
) as mock_weekly:
create_locker_relaxed_day(mock_tk, tmp_path)
mock_weekly.assert_not_called()
def test_has_logged_exits_before_weekly_check(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
with patch(
"python_pkg.screen_locker.screen_lock.has_weekly_minimum",
) as mock_weekly:
create_locker(mock_tk, tmp_path, has_logged=True)
mock_weekly.assert_not_called()
# ---------------------------------------------------------------------------
# Relaxed-day UI flow methods
# ---------------------------------------------------------------------------
class TestStartRelaxedDayFlow:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_shows_weekly_count_in_text(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch(
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
return_value=2,
),
patch.object(locker, "_text") as mock_text,
patch.object(locker, "_label"),
patch.object(locker, "_button_row"),
patch.object(locker, "_button"),
patch.object(locker, "clear_container"),
):
locker._start_relaxed_day_flow()
all_text = " ".join(str(c) for c in mock_text.call_args_list)
assert "2" in all_text
assert "4" in all_text
def test_skip_button_wires_close(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch(
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
return_value=0,
),
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._start_relaxed_day_flow()
skip_cmds = [
c.kwargs["command"]
for c in mock_button.call_args_list
if "Skip" in str(c.args)
]
assert any(cmd == locker.close for cmd in skip_cmds)
def test_log_button_wires_relaxed_phone_check(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch(
"python_pkg.screen_locker._ui_flows.count_weekly_workouts",
return_value=1,
),
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._start_relaxed_day_flow()
log_cmds = [
c.kwargs["command"]
for c in mock_button.call_args_list
if "Log" in str(c.args)
]
assert any(cmd == locker._start_relaxed_phone_check for cmd in log_cmds)
class TestStartRelaxedPhoneCheck:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_submits_phone_verify_and_polls(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(
locker, "_verify_phone_workout", return_value=("verified", "ok")
):
locker._start_relaxed_phone_check()
assert locker._phone_future is not None
locker.root.after.assert_called()
def test_poll_routes_when_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = True
mock_future.result.return_value = ("verified", "ok")
locker._phone_future = mock_future
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check()
mock_handle.assert_called_once_with("verified", "ok")
def test_poll_waits_when_not_done(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
mock_future = MagicMock()
mock_future.done.return_value = False
locker._phone_future = mock_future
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check()
mock_handle.assert_not_called()
locker.root.after.assert_called_with(500, locker._poll_relaxed_phone_check)
def test_poll_with_none_future_waits(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
locker._phone_future = None
with patch.object(locker, "_handle_relaxed_phone_result") as mock_handle:
locker._poll_relaxed_phone_check()
mock_handle.assert_not_called()
class TestHandleRelaxedPhoneResult:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_verified_calls_unlock_screen(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "unlock_screen"):
locker._handle_relaxed_phone_result("verified", "StrongLifts sync OK")
assert locker.workout_data["type"] == "phone_verified"
assert locker.workout_data["source"] == "StrongLifts sync OK"
locker.root.after.assert_called()
def test_not_verified_shows_relaxed_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("not_verified", "no workout today")
mock_retry.assert_called_once_with("no workout today", "not_verified")
def test_too_short_shows_relaxed_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("too_short", "only 20 min")
mock_retry.assert_called_once_with("only 20 min", "too_short")
def test_no_phone_shows_relaxed_retry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with patch.object(locker, "_show_relaxed_retry") as mock_retry:
locker._handle_relaxed_phone_result("no_phone", "ADB not found")
mock_retry.assert_called_once_with("ADB not found", "no_phone")
class TestShowRelaxedRetry:
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_shows_try_again_and_close_buttons(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._show_relaxed_retry("msg", "not_verified")
button_texts = " ".join(str(c.args) for c in mock_button.call_args_list)
assert "TRY AGAIN" in button_texts
assert "Close" in button_texts
def test_no_sick_button(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_button") as mock_button,
patch.object(locker, "_label"),
patch.object(locker, "_text"),
patch.object(locker, "_button_row", return_value=MagicMock()),
patch.object(locker, "clear_container"),
):
locker._show_relaxed_retry("msg", "not_verified")
button_texts = " ".join(str(c.args) for c in mock_button.call_args_list)
assert "sick" not in button_texts.lower()
# ---------------------------------------------------------------------------
# _check_today_state_exits: return True/False branches
# ---------------------------------------------------------------------------
class TestCheckTodayStateExits:
"""Cover all return True/False paths in _check_today_state_exits.
sys.exit is mocked without side_effect so execution continues past it
and the 'return True' statements are reachable.
"""
def _make_locker(self, mock_tk: MagicMock, tmp_path: Path) -> ScreenLocker:
return create_locker(mock_tk, tmp_path)
def test_early_bird_upgrade_success_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True),
):
result = locker._check_today_state_exits()
assert result is True
def test_early_bird_upgrade_fail_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False),
):
result = locker._check_today_state_exits()
assert result is False
def test_early_bird_window_active_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=True),
):
result = locker._check_today_state_exits()
assert result is True
def test_sick_day_auto_upgrade_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=True),
patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True),
):
result = locker._check_today_state_exits()
assert result is True
def test_workout_skip_today_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=True,
),
):
result = locker._check_today_state_exits()
assert result is True
def test_early_bird_time_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=False,
),
patch.object(locker, "_is_early_bird_time", return_value=True),
patch.object(locker, "_save_early_bird_log"),
):
result = locker._check_today_state_exits()
assert result is True
def test_no_exit_conditions_returns_false(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"python_pkg.screen_locker.screen_lock.has_workout_skip_today",
return_value=False,
),
patch.object(locker, "_is_early_bird_time", return_value=False),
):
result = locker._check_today_state_exits()
assert result is False
class TestCheckNonVerifyExitsScheduledSkip:
"""Cover the return after scheduled-skip sys.exit in _check_non_verify_exits."""
def test_scheduled_skip_return_reached(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
locker = create_locker(mock_tk, tmp_path)
with patch.object(locker, "_is_scheduled_skip_today", return_value=True):
locker._check_non_verify_exits()
mock_sys_exit.assert_called_once_with(0)