diff --git a/docs/superpowers/contracts/screen-locker-weekly-check-2026-05-28.json b/docs/superpowers/contracts/screen-locker-weekly-check-2026-05-28.json new file mode 100644 index 0000000..ed5382d --- /dev/null +++ b/docs/superpowers/contracts/screen-locker-weekly-check-2026-05-28.json @@ -0,0 +1,16 @@ +{ + "title": "screen_locker: weekly check feature with UI integration", + "objective": "Add a weekly check module to screen_locker that runs at lock time and integrates with the existing UI flow and window setup. The feature should be covered at 100% branch coverage and not regress any existing lock/unlock behavior.", + "acceptance_criteria": [ + "New _weekly_check.py module implements weekly check logic", + "_ui_flows.py and _window_setup.py integrate the weekly check into the lock UI", + "screen_lock.py main flow invokes weekly check at appropriate point", + "All new code covered by test_weekly_check.py and test_weekly_logic.py", + "100% branch coverage maintained across screen_locker package" + ], + "out_of_scope": [ + "Changes to other python_pkg subpackages", + "Wake alarm or morning routine integration" + ], + "verifier": "pre-commit run --files " +} diff --git a/docs/superpowers/evidence/screen-locker-weekly-check-2026-05-28.json b/docs/superpowers/evidence/screen-locker-weekly-check-2026-05-28.json new file mode 100644 index 0000000..68a4587 --- /dev/null +++ b/docs/superpowers/evidence/screen-locker-weekly-check-2026-05-28.json @@ -0,0 +1,30 @@ +{ + "intent": "Add weekly check feature to screen_locker with UI flows and window setup integration.", + "scope": [ + "python_pkg/screen_locker/_weekly_check.py", + "python_pkg/screen_locker/_ui_flows.py", + "python_pkg/screen_locker/_window_setup.py", + "python_pkg/screen_locker/screen_lock.py", + "python_pkg/screen_locker/tests/" + ], + "changes": [ + "Add _weekly_check.py with weekly check logic", + "Update _ui_flows.py and _window_setup.py to integrate weekly check into lock UI", + "Update screen_lock.py main flow to invoke weekly check", + "Add conftest.py fixtures and test_weekly_check/test_weekly_logic test files" + ], + "verification": [ + { + "command": "pre-commit run --files ", + "result": "pass", + "evidence": "All hooks passed including ruff, mypy, pylint, bandit, pytest-coverage (100% branch coverage)" + } + ], + "risks": [ + "Weekly check integrates into critical lock flow — regression risk if weekly check raises unexpectedly" + ], + "rollback": [ + "Revert commits to restore prior screen_lock.py and UI files", + "Validate by running screen locker and confirming lock/unlock cycle works" + ] +} diff --git a/python_pkg/screen_locker/_ui_flows.py b/python_pkg/screen_locker/_ui_flows.py index 6d13432..edd8aca 100644 --- a/python_pkg/screen_locker/_ui_flows.py +++ b/python_pkg/screen_locker/_ui_flows.py @@ -11,6 +11,10 @@ from python_pkg.screen_locker._constants import ( PHONE_PENALTY_DELAY_DEMO, PHONE_PENALTY_DELAY_PRODUCTION, ) +from python_pkg.screen_locker._weekly_check import ( + WEEKLY_WORKOUT_MINIMUM, + count_weekly_workouts, +) if TYPE_CHECKING: from collections.abc import Callable @@ -72,33 +76,31 @@ class UIFlowsMixin: self.workout_data["type"] = "phone_verified" self.workout_data["source"] = message self.clear_container() - self._label( - "\u2713 Workout Verified!", font_size=42, color="#00cc44", pady=30 - ) + self._label("✓ Workout Verified!", font_size=42, color="#00cc44", pady=30) self._text(message, font_size=20, color="#aaffaa") self._text("Unlocking...", font_size=18, color="#888888") unlock_delay = 1500 if self.demo_mode else 2000 self.root.after(unlock_delay, self.unlock_screen) elif status == "too_short": self._show_retry_and_sick( - f"\u274c {message}\n\n" + f"❌ {message}\n\n" "Your workout was too short!\n" "Actually do the full workout, don't just\n" "spam through the exercises.", ) elif status in ("stale", "no_exercises"): self._show_retry_and_sick( - f"\u274c {message}\n\nReason: {status}", + f"❌ {message}\n\nReason: {status}", ) elif status == "clock_tampered": self._show_retry_and_sick( - f"\u274c {message}\n\n" + f"❌ {message}\n\n" "System clock appears to be manipulated.\n" "Fix your system time and try again.", ) elif status == "not_verified": self._show_retry_and_sick( - f"\u274c {message}\n\n" + f"❌ {message}\n\n" "StrongLifts shows no workout today.\n" "Go do your workout first!", ) @@ -116,7 +118,7 @@ class UIFlowsMixin: return "Shutdown time already adjusted today", "#ffaa00" if self._adjust_shutdown_time_earlier(): 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" return "Could not adjust shutdown time (check permissions)", "#ff4444" @@ -313,7 +315,7 @@ class UIFlowsMixin: self.save_workout_log() self.clear_container() self._label( - "\u2713 Workout Verified!", + "✓ Workout Verified!", font_size=42, color="#00cc44", pady=30, @@ -354,3 +356,97 @@ class UIFlowsMixin: command=self.close, width=12, ).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) diff --git a/python_pkg/screen_locker/_weekly_check.py b/python_pkg/screen_locker/_weekly_check.py new file mode 100644 index 0000000..0c99a37 --- /dev/null +++ b/python_pkg/screen_locker/_weekly_check.py @@ -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 diff --git a/python_pkg/screen_locker/_window_setup.py b/python_pkg/screen_locker/_window_setup.py index 3da88ce..e1f0fcd 100644 --- a/python_pkg/screen_locker/_window_setup.py +++ b/python_pkg/screen_locker/_window_setup.py @@ -64,6 +64,12 @@ class WindowSetupMixin: ) 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: """Force input focus to the locker window.""" self.root.update_idletasks() diff --git a/python_pkg/screen_locker/screen_lock.py b/python_pkg/screen_locker/screen_lock.py index e9af2e4..fd0eb86 100755 --- a/python_pkg/screen_locker/screen_lock.py +++ b/python_pkg/screen_locker/screen_lock.py @@ -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._sick_dialog import SickDialogMixin 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.wake_alarm._state import has_workout_skip_today @@ -57,6 +62,7 @@ __all__ = [ "SCHEDULED_SKIPS_FILE", "SICK_LOCKOUT_SECONDS", "STRONGLIFTS_DB_REMOTE", + "WEEKLY_WORKOUT_MINIMUM", "ScreenLocker", ] @@ -100,6 +106,7 @@ class ScreenLocker( self.log_file = script_dir / "workout_log.json" self.verify_only = verify_only self.workout_data: dict[str, str] = {} + self._relaxed_day_mode: bool = False self._check_early_exits(verify_only=verify_only) self.root = tk.Tk() title_suffix = ( @@ -110,6 +117,8 @@ class ScreenLocker( self.lockout_time = 10 if demo_mode else 1800 if verify_only: self._setup_verify_window() + elif self._relaxed_day_mode: + self._setup_relaxed_day_window() else: self._setup_window() if demo_mode: @@ -119,6 +128,8 @@ class ScreenLocker( self._phone_future: Future[tuple[str, str]] | None = None if verify_only: self._start_verify_workout_check() + elif self._relaxed_day_mode: + self._start_relaxed_day_flow() else: self._start_phone_check() self._grab_input() @@ -149,39 +160,51 @@ class ScreenLocker( 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) - 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) - elif self._is_early_bird_log(): - _logger.info("Early bird window still active — skipping lock.") - sys.exit(0) - elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day(): + return + if self._check_today_state_exits(): + return + # Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced. + if is_relaxed_day(): + _logger.info("Relaxed day (Tue-Thu) - showing optional workout prompt.") + self._relaxed_day_mode = True + return + # Fri-Mon: skip lock when weekly minimum is already met. + if has_weekly_minimum(self.log_file): _logger.info( - "Auto-upgraded today's sick_day entry to phone_verified.", - ) - 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.", + "Weekly minimum of %d workouts met. Skipping screen lock.", + WEEKLY_WORKOUT_MINIMUM, ) sys.exit(0) + return def _try_auto_upgrade_sick_day(self) -> bool: """Silently upgrade today's sick_day entry if phone shows a workout.""" diff --git a/python_pkg/screen_locker/tests/conftest.py b/python_pkg/screen_locker/tests/conftest.py index 32e1380..1164cd6 100644 --- a/python_pkg/screen_locker/tests/conftest.py +++ b/python_pkg/screen_locker/tests/conftest.py @@ -106,6 +106,28 @@ def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]: 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 def mock_tk() -> Generator[MagicMock]: """Mock tkinter module for testing without display.""" @@ -172,6 +194,7 @@ def create_locker( 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( @@ -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( _mock_tk: MagicMock, 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, "_start_phone_check"), + patch.object(ScreenLocker, "_start_relaxed_day_flow"), patch.object(ScreenLocker, "_start_verify_workout_check"), ): return ScreenLocker(demo_mode=demo_mode) diff --git a/python_pkg/screen_locker/tests/test_weekly_check.py b/python_pkg/screen_locker/tests/test_weekly_check.py new file mode 100644 index 0000000..5ff4f50 --- /dev/null +++ b/python_pkg/screen_locker/tests/test_weekly_check.py @@ -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 diff --git a/python_pkg/screen_locker/tests/test_weekly_logic.py b/python_pkg/screen_locker/tests/test_weekly_logic.py new file mode 100644 index 0000000..13f4442 --- /dev/null +++ b/python_pkg/screen_locker/tests/test_weekly_logic.py @@ -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)