From 4b4695e23ace999142f78745cc94e8b40a2dda9a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sat, 15 Aug 2026 19:01:18 +0200 Subject: [PATCH] Split screen_lock.py under the 250-line cap Co-Authored-By: Claude Opus 5 (1M context) --- screen_locker/_cli.py | 60 +++++ screen_locker/_startup_checks.py | 126 ++++++++++ screen_locker/_sync_mixin.py | 102 +++++++++ screen_locker/screen_lock.py | 215 +----------------- screen_locker/tests/_locker_factories.py | 4 +- screen_locker/tests/conftest.py | 14 +- screen_locker/tests/test_cli.py | 175 ++++++++++++++ .../tests/test_screen_lock_coverage_part1.py | 66 +++--- .../tests/test_screen_lock_coverage_part2.py | 12 +- screen_locker/tests/test_weekly_logic.py | 20 +- .../tests/test_weekly_logic_part2.py | 30 ++- 11 files changed, 554 insertions(+), 270 deletions(-) create mode 100644 screen_locker/_cli.py create mode 100644 screen_locker/_startup_checks.py create mode 100644 screen_locker/_sync_mixin.py create mode 100644 screen_locker/tests/test_cli.py diff --git a/screen_locker/_cli.py b/screen_locker/_cli.py new file mode 100644 index 0000000..1a83ce6 --- /dev/null +++ b/screen_locker/_cli.py @@ -0,0 +1,60 @@ +"""Command-line entry points for ``screen_lock.py``. + +Split out of ``screen_lock.py`` to keep every file under the 250-line cap. +The argument handling lives here; ``screen_lock.py`` keeps only the +``ScreenLocker`` class and a one-line ``__main__`` delegation, so the module +that every mixin imports stays small. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +import sys +from typing import TYPE_CHECKING + +from screen_locker._status import run_status + +if TYPE_CHECKING: + from screen_locker.screen_lock import ScreenLocker + +__all__ = ["main"] + +_LOG_FILE_NAME = "workout_log.json" + + +def _headless_locker(locker_cls: type[ScreenLocker]) -> ScreenLocker: + """A ScreenLocker with ``__init__`` bypassed, for the no-UI subcommands. + + ``--status`` and ``--sync-only`` touch only ``log_file`` and + ``workout_data``; constructing the real object would build a Tk UI and, + in the ``--production`` case, grab the screen. + """ + locker = object.__new__(locker_cls) + locker.log_file = Path(__file__).resolve().parent / _LOG_FILE_NAME + locker.workout_data = {} + return locker + + +def main(locker_cls: type[ScreenLocker], argv: list[str]) -> None: + """Dispatch on ``argv`` and run the requested mode.""" + if "--status" in argv: + run_status(_headless_locker(locker_cls)) + + if "--sync-only" in argv: + # Headless sync for the timer unit: pull other devices' workouts and + # apply their credit, with no Tk and no lock screen. Sync used to run + # ONLY at process start, so a workout finished after login was not seen + # until the next login — the timer closes that window. + logging.basicConfig( + level=logging.INFO, + format="%(levelname)s %(name)s: %(message)s", + ) + _headless_locker(locker_cls).sync_now() + sys.exit(0) + + locker = locker_cls( + demo_mode="--production" not in argv, + verify_only="--verify-workout" in argv, + ) + locker.run() diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py new file mode 100644 index 0000000..5030585 --- /dev/null +++ b/screen_locker/_startup_checks.py @@ -0,0 +1,126 @@ +"""Startup early-exit checks for the screen locker. + +Split out of ``screen_lock.py`` to keep every file under the 250-line cap. +These run before any lock window is built and decide whether the lock is +needed at all: scheduled skip, weekly minimum already met, relaxed day, or +genuine extreme heat. + +The entry point is ``_check_non_verify_exits``, which +``_auto_upgrade.AutoUpgradeMixin._check_early_exits`` calls. +""" + +from __future__ import annotations + +import logging +import sys + +from screen_locker._constants import ( + EXTRA_BENEFITS_FILE, + HEAT_SKIP_CITY, + HEAT_SKIP_TEMP_THRESHOLD, + SHUTDOWN_BASE_FILE, + SICK_DAY_STATE_FILE, +) +from screen_locker._extra_benefits import process_week_transition +from screen_locker._shutdown_base import reset_to_base_if_new_day +from screen_locker._sync_mixin import SyncMixin +from screen_locker._temperature import fetch_current_temp_with_status +from screen_locker._weekly_check import ( + WEEKLY_WORKOUT_MINIMUM, + has_weekly_minimum, + is_relaxed_day, +) + +__all__ = ["StartupChecksMixin"] + +_logger = logging.getLogger(__name__) + + +class StartupChecksMixin(SyncMixin): + """Decide, before building any UI, whether the lock should run today. + + Inherits ``SyncMixin`` rather than sitting beside it in ``ScreenLocker``'s + base list: the early-exit sequence calls ``_ingest_synced_manual_workouts`` + and ``_auto_fill_week_runnerup_bonus`` directly, so the dependency is real + and this keeps the god-class's ancestor count from growing. + """ + + 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 + # Award streak / shutdown-bonus / EB-extension rewards from last week + # before the daily reset, so a Monday transition's bonus is recorded + # in time for _apply_weekly_shutdown_bonus below to see it. + for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE): + _logger.info("Weekly reward: %s", reward_msg) + # Reset shutdown config to base (21:00) at the start of each new day, + # then layer this week's earned bonus back on top of the fresh base. + if reset_to_base_if_new_day( + SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE + ): + self._apply_weekly_shutdown_bonus() + # Ingest any manual workouts synced from the phone (or another device) + # before the early-exit checks, so a manual logged off-app still counts + # toward today's/this week's minimum. + self._ingest_synced_manual_workouts() + # 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.). + self._auto_fill_week_runnerup_bonus() + 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( + "Weekly minimum of %d workouts met. Skipping screen lock.", + WEEKLY_WORKOUT_MINIMUM, + ) + sys.exit(0) + return + # Only remaining same-day skip: genuine extreme heat. Sick days go + # through the justification flow instead; there is no banked + # "skip a workout" credit — that mechanic works against the goal of + # maximizing weekly workouts, so it was removed in favor of a + # shutdown-time-only reward (see _apply_weekly_shutdown_bonus). + self._check_heat_skip_exit() + + def _check_heat_skip_exit(self) -> None: + """Exit early if today qualifies for the extreme-heat skip dialog. + + Fail-closed by construction: a failed or timed-out temperature fetch + falls straight through to the lock, same as "not hot enough" — the + only difference is this logs *why* explicitly, so a fetch failure + is never silently indistinguishable from a normal day in the logs. + """ + check = fetch_current_temp_with_status(HEAT_SKIP_CITY) + if check.timed_out: + _logger.warning( + "Heat-skip temperature check timed out — defaulting to lock." + ) + return + if check.temp_celsius is None: + _logger.warning( + "Heat-skip temperature check failed (network/API error) — " + "defaulting to lock." + ) + return + if check.temp_celsius < HEAT_SKIP_TEMP_THRESHOLD: + return + _logger.info( + "Temperature %.0f°C exceeds threshold — showing heat-skip dialog.", + check.temp_celsius, + ) + if self._show_heat_skip_dialog(check.temp_celsius): + self._save_heat_skip_log(check.temp_celsius) + _logger.info( + "User skipped workout due to heat (%.0f°C).", check.temp_celsius + ) + sys.exit(0) diff --git a/screen_locker/_sync_mixin.py b/screen_locker/_sync_mixin.py new file mode 100644 index 0000000..b3fdaf6 --- /dev/null +++ b/screen_locker/_sync_mixin.py @@ -0,0 +1,102 @@ +"""Manual-workout sync and RunnerUp auto-fill for the screen locker. + +Split out of ``screen_lock.py`` to keep every file under the 250-line cap. +These methods form one cohesive unit: they publish this PC's workouts, ingest +records other devices produced, and apply the same shutdown/debt credit a +live-logged workout earns. +""" + +from __future__ import annotations + +import logging + +from screen_locker._constants import EXTRA_BENEFITS_FILE +from screen_locker._extra_benefits import weekly_shutdown_bonus_hours +from screen_locker._manual_push import push_pc_workouts +from screen_locker._manual_sync import ingest_manual_records +from screen_locker._weekly_check import ( + WEEKLY_WORKOUT_MINIMUM, + count_weekly_workouts, +) +from screen_locker._workout_sync import pull_all_manual_records + +__all__ = ["SyncMixin"] + +_logger = logging.getLogger(__name__) + + +class SyncMixin: + """Publish, ingest and credit manual workouts synced between devices.""" + + def sync_now(self) -> None: + """Run one sync pass: publish this PC's workouts, ingest everyone's. + + The public entry point behind ``--sync-only``, which the + ``workout-sync.timer`` unit calls. Sync used to happen only inside the + locker's startup path, so a workout finished after login stayed + invisible until the next login. + + Also re-runs the RunnerUp TCX backfill here, not just at login: the + login-time scan gets exactly one shot, and if the phone isn't + adb-visible at that instant (e.g. USB debugging not yet + authorized), a same-day run stays uncredited until the next login. + Repeating it every 15 minutes closes that gap. + """ + self._ingest_synced_manual_workouts() + self._auto_fill_week_runnerup_bonus() + + def _ingest_synced_manual_workouts(self) -> None: + """Sync manual workouts: publish this PC's, ingest everyone else's. + + Each newly-ingested record earns the identical shutdown/debt reward a + live-logged workout would (see + ``WorkoutCreditMixin._apply_credit_for_written_entry``), regardless of + whether it's dated today or back-dated — there's only one current + shutdown config, so a back-dated sync still pushes it. + """ + push_pc_workouts(self.log_file) + ingested = ingest_manual_records( + self.log_file, + pull_all_manual_records(), + on_ingested=self._credit_ingested_manual_workout, + ) + for record_id in ingested: + _logger.info("Ingested synced manual workout: %s", record_id) + self.workout_data = {} + + def _credit_ingested_manual_workout( + self, entry: dict[str, str], prior_entries: list[dict] + ) -> None: + """Apply the live-workout reward to a manual workout ingested via sync.""" + self.workout_data = entry + credit = self._apply_credit_for_written_entry(prior_entries) + if credit.shutdown_adjusted: + _logger.info( + "Synced manual workout pushed shutdown time +2h: %s", + entry.get("source", ""), + ) + elif credit.extra_bonus_delta: + _logger.info( + "Synced manual workout added +%dh shutdown time: %s", + credit.extra_bonus_delta, + entry.get("source", ""), + ) + + def _auto_fill_week_runnerup_bonus(self) -> None: + """Auto-fill missed RunnerUp workouts and award any earned bonus.""" + prev_count = count_weekly_workouts(self.log_file) + n_filled = self._scan_and_fill_week_runnerup(self.log_file) + if not n_filled: + return + 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) + + def _apply_weekly_shutdown_bonus(self) -> None: + """Layer this week's earned shutdown bonus back on top of the fresh base.""" + bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) + if bonus > 0 and self._adjust_shutdown_time_by(bonus): + _logger.info("Weekly bonus: +%dh shutdown time this week.", bonus) diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index e020f46..6ed68b9 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -23,49 +23,31 @@ from screen_locker._constants import ( EARLY_BIRD_END_HOUR, EARLY_BIRD_END_MINUTE, EARLY_BIRD_START_HOUR, - EXTRA_BENEFITS_FILE, - HEAT_SKIP_CITY, - HEAT_SKIP_TEMP_THRESHOLD, 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 ( - process_week_transition, - weekly_shutdown_bonus_hours, -) from screen_locker._heat_skip import HeatSkipMixin from screen_locker._log_mixin import LogMixin -from screen_locker._manual_push import push_pc_workouts -from screen_locker._manual_sync import ingest_manual_records from screen_locker._manual_workout_dialog import ManualWorkoutDialogMixin 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._startup_checks import StartupChecksMixin from screen_locker._surface_group import FrameGroup -from screen_locker._temperature import fetch_current_temp_with_status 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._unlock_view import UnlockViewMixin -from screen_locker._weekly_check import ( - WEEKLY_WORKOUT_MINIMUM, - count_weekly_workouts, - has_weekly_minimum, - is_relaxed_day, -) +from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM from screen_locker._window_setup import WindowSetupMixin from screen_locker._workout_credit import WorkoutCreditMixin -from screen_locker._workout_sync import pull_all_manual_records if TYPE_CHECKING: from collections.abc import Callable @@ -116,6 +98,7 @@ class ScreenLocker( PhoneVerificationMixin, RunnerUpVerificationMixin, SickDialogMixin, + StartupChecksMixin, UIFlowsMixin, UIFlowsRelaxedMixin, UIWidgetsMixin, @@ -180,159 +163,6 @@ class ScreenLocker( if self._lock is not None: # pragma: no branch self._lock.grab_input() - 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 - # Award streak / shutdown-bonus / EB-extension rewards from last week - # before the daily reset, so a Monday transition's bonus is recorded - # in time for _apply_weekly_shutdown_bonus below to see it. - for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE): - _logger.info("Weekly reward: %s", reward_msg) - # Reset shutdown config to base (21:00) at the start of each new day, - # then layer this week's earned bonus back on top of the fresh base. - if reset_to_base_if_new_day( - SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE - ): - self._apply_weekly_shutdown_bonus() - # Ingest any manual workouts synced from the phone (or another device) - # before the early-exit checks, so a manual logged off-app still counts - # toward today's/this week's minimum. - self._ingest_synced_manual_workouts() - # 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.). - self._auto_fill_week_runnerup_bonus() - 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( - "Weekly minimum of %d workouts met. Skipping screen lock.", - WEEKLY_WORKOUT_MINIMUM, - ) - sys.exit(0) - return - # Only remaining same-day skip: genuine extreme heat. Sick days go - # through the justification flow instead; there is no banked - # "skip a workout" credit — that mechanic works against the goal of - # maximizing weekly workouts, so it was removed in favor of a - # shutdown-time-only reward (see _apply_weekly_shutdown_bonus). - self._check_heat_skip_exit() - - def sync_now(self) -> None: - """Run one sync pass: publish this PC's workouts, ingest everyone's. - - The public entry point behind ``--sync-only``, which the - ``workout-sync.timer`` unit calls. Sync used to happen only inside the - locker's startup path, so a workout finished after login stayed - invisible until the next login. - - Also re-runs the RunnerUp TCX backfill here, not just at login: the - login-time scan gets exactly one shot, and if the phone isn't - adb-visible at that instant (e.g. USB debugging not yet - authorized), a same-day run stays uncredited until the next login. - Repeating it every 15 minutes closes that gap. - """ - self._ingest_synced_manual_workouts() - self._auto_fill_week_runnerup_bonus() - - def _ingest_synced_manual_workouts(self) -> None: - """Sync manual workouts: publish this PC's, ingest everyone else's. - - Each newly-ingested record earns the identical shutdown/debt reward a - live-logged workout would (see - ``WorkoutCreditMixin._apply_credit_for_written_entry``), regardless of - whether it's dated today or back-dated — there's only one current - shutdown config, so a back-dated sync still pushes it. - """ - push_pc_workouts(self.log_file) - ingested = ingest_manual_records( - self.log_file, - pull_all_manual_records(), - on_ingested=self._credit_ingested_manual_workout, - ) - for record_id in ingested: - _logger.info("Ingested synced manual workout: %s", record_id) - self.workout_data = {} - - def _credit_ingested_manual_workout( - self, entry: dict[str, str], prior_entries: list[dict] - ) -> None: - """Apply the live-workout reward to a manual workout ingested via sync.""" - self.workout_data = entry - credit = self._apply_credit_for_written_entry(prior_entries) - if credit.shutdown_adjusted: - _logger.info( - "Synced manual workout pushed shutdown time +2h: %s", - entry.get("source", ""), - ) - elif credit.extra_bonus_delta: - _logger.info( - "Synced manual workout added +%dh shutdown time: %s", - credit.extra_bonus_delta, - entry.get("source", ""), - ) - - def _auto_fill_week_runnerup_bonus(self) -> None: - """Auto-fill missed RunnerUp workouts and award any earned bonus.""" - prev_count = count_weekly_workouts(self.log_file) - n_filled = self._scan_and_fill_week_runnerup(self.log_file) - if not n_filled: - return - 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) - - def _check_heat_skip_exit(self) -> None: - """Exit early if today qualifies for the extreme-heat skip dialog. - - Fail-closed by construction: a failed or timed-out temperature fetch - falls straight through to the lock, same as "not hot enough" — the - only difference is this logs *why* explicitly, so a fetch failure - is never silently indistinguishable from a normal day in the logs. - """ - check = fetch_current_temp_with_status(HEAT_SKIP_CITY) - if check.timed_out: - _logger.warning( - "Heat-skip temperature check timed out — defaulting to lock." - ) - return - if check.temp_celsius is None: - _logger.warning( - "Heat-skip temperature check failed (network/API error) — " - "defaulting to lock." - ) - return - if check.temp_celsius < HEAT_SKIP_TEMP_THRESHOLD: - return - _logger.info( - "Temperature %.0f°C exceeds threshold — showing heat-skip dialog.", - check.temp_celsius, - ) - if self._show_heat_skip_dialog(check.temp_celsius): - self._save_heat_skip_log(check.temp_celsius) - _logger.info( - "User skipped workout due to heat (%.0f°C).", check.temp_celsius - ) - sys.exit(0) - - def _apply_weekly_shutdown_bonus(self) -> None: - """Layer this week's earned shutdown bonus back on top of the fresh base.""" - bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) - if bonus > 0 and self._adjust_shutdown_time_by(bonus): - _logger.info("Weekly bonus: +%dh shutdown time this week.", bonus) - def close(self) -> None: """Close the application and exit.""" if self._lock is not None: @@ -350,41 +180,6 @@ class ScreenLocker( if __name__ == "__main__": - if "--status" in sys.argv: - from screen_locker._status import run_status + from screen_locker._cli import main - # 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) - - if "--sync-only" in sys.argv: - # Headless sync for the timer unit: pull other devices' workouts and - # apply their credit, with no Tk and no lock screen. Sync used to run - # ONLY at process start, so a workout finished after login was not seen - # until the next login — the timer closes that window. - # - # Same __init__ bypass as --status: this path touches only log_file and - # workout_data, and constructing the real ScreenLocker would build a UI. - logging.basicConfig( - level=logging.INFO, - format="%(levelname)s %(name)s: %(message)s", - ) - _sl = object.__new__(ScreenLocker) - _sl.log_file = Path(__file__).resolve().parent / "workout_log.json" - _sl.workout_data = {} - _sl.sync_now() - sys.exit(0) - - demo_mode = True - verify_only = "--verify-workout" in sys.argv - - if "--production" in sys.argv: - demo_mode = False - - locker = ScreenLocker( - demo_mode=demo_mode, - verify_only=verify_only, - ) - locker.run() + main(ScreenLocker, sys.argv) diff --git a/screen_locker/tests/_locker_factories.py b/screen_locker/tests/_locker_factories.py index 29b6143..deaa446 100644 --- a/screen_locker/tests/_locker_factories.py +++ b/screen_locker/tests/_locker_factories.py @@ -93,9 +93,9 @@ def create_locker_relaxed_day( patch.object(ScreenLocker, "_is_early_bird_pending", 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("screen_locker.screen_lock.is_relaxed_day", return_value=True), + patch("screen_locker._startup_checks.is_relaxed_day", return_value=True), patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False, ), patch.object(ScreenLocker, "_start_phone_check"), diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 143608d..21a858b 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -107,6 +107,9 @@ def _block_real_tk_and_exit() -> Iterator[None]: ) ) stack.enter_context(patch("screen_locker.screen_lock.sys.exit")) + # The startup early-exit checks moved to _startup_checks; sys.exit is + # bound there now, so it needs blocking separately. + stack.enter_context(patch("screen_locker._startup_checks.sys.exit")) yield @@ -188,7 +191,8 @@ def _isolate_extra_benefits(tmp_path: Path) -> Iterator[None]: target = tmp_path / "extra_benefits_state.json" with ( patch("screen_locker._constants.EXTRA_BENEFITS_FILE", target), - patch("screen_locker.screen_lock.EXTRA_BENEFITS_FILE", target), + patch("screen_locker._startup_checks.EXTRA_BENEFITS_FILE", target), + patch("screen_locker._sync_mixin.EXTRA_BENEFITS_FILE", target), patch("screen_locker._early_bird.EXTRA_BENEFITS_FILE", target), patch("screen_locker._status.EXTRA_BENEFITS_FILE", target), ): @@ -213,7 +217,7 @@ def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]: ) with ( patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target), - patch("screen_locker.screen_lock.SHUTDOWN_BASE_FILE", target), + patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target), ): yield @@ -224,7 +228,7 @@ def _isolate_sick_day_state(tmp_path: Path) -> Iterator[None]: target = tmp_path / "sick_day_state.json" with ( patch("screen_locker._constants.SICK_DAY_STATE_FILE", target), - patch("screen_locker.screen_lock.SICK_DAY_STATE_FILE", target), + patch("screen_locker._startup_checks.SICK_DAY_STATE_FILE", target), ): yield @@ -251,11 +255,11 @@ def _mock_weekly_logic() -> Iterator[None]: """ with ( patch( - "screen_locker.screen_lock.is_relaxed_day", + "screen_locker._startup_checks.is_relaxed_day", return_value=False, ), patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False, ), ): diff --git a/screen_locker/tests/test_cli.py b/screen_locker/tests/test_cli.py new file mode 100644 index 0000000..a111990 --- /dev/null +++ b/screen_locker/tests/test_cli.py @@ -0,0 +1,175 @@ +"""Tests for screen_locker._cli, the command-line entry-point dispatcher. + +This logic used to live in ``screen_lock.py``'s ``if __name__ == "__main__":`` +block, which coverage excludes -- so it shipped untested. Splitting it into a +real module for the 250-line cap made it testable; these tests cover it. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, ClassVar # ClassVar is needed at runtime +from unittest.mock import patch + +import pytest + +from screen_locker import _cli + +if TYPE_CHECKING: + from collections.abc import Iterator + + +class _FakeLocker: + """Stand-in for ScreenLocker recording how it was constructed.""" + + instances: ClassVar[list[_FakeLocker]] = [] + + def __init__(self, *, demo_mode: bool = True, verify_only: bool = False) -> None: + self.demo_mode = demo_mode + self.verify_only = verify_only + self.ran = False + self.log_file: Path | None = None + self.workout_data: dict[str, str] | None = None + self.synced = False + _FakeLocker.instances.append(self) + + def run(self) -> None: + """Record that the main loop was entered.""" + self.ran = True + + def sync_now(self) -> None: + """Record that a sync pass was requested.""" + self.synced = True + + +@pytest.fixture(autouse=True) +def _reset_instances() -> Iterator[None]: + """Clear the recorded instances before each test.""" + _FakeLocker.instances = [] + yield + _FakeLocker.instances = [] + + +class TestHeadlessLocker: + """_headless_locker bypasses __init__ so no UI is ever built.""" + + def test_bypasses_init_and_seeds_only_the_two_needed_fields(self) -> None: + """Bypassing __init__ leaves only log_file and workout_data set.""" + locker = _cli._headless_locker(_FakeLocker) + + # __init__ never ran, so demo_mode/verify_only are absent entirely. + assert not hasattr(locker, "demo_mode") + assert not _FakeLocker.instances + assert not locker.workout_data + assert locker.log_file.name == "workout_log.json" + + def test_log_file_sits_beside_the_package(self) -> None: + """The workout log is resolved relative to the package directory.""" + locker = _cli._headless_locker(_FakeLocker) + + assert locker.log_file.parent == Path(_cli.__file__).resolve().parent + + +class TestStatusMode: + """--status runs the status report against a headless locker.""" + + def test_calls_run_status_without_constructing_a_ui(self) -> None: + """--status reports via run_status and builds no locker.""" + # The real run_status ends in sys.exit(0), which is why main() needs + # no `return` here; the fake reproduces that so the fall-through to + # the UI locker is exercised exactly as it is in production. + with ( + patch.object(_cli, "run_status", side_effect=SystemExit(0)) as run_status, + pytest.raises(SystemExit) as excinfo, + ): + _cli.main(_FakeLocker, ["screen_lock.py", "--status"]) + + assert not excinfo.value.code + run_status.assert_called_once() + passed = run_status.call_args[0][0] + assert not passed.workout_data + # No real locker was constructed for the status path. + assert not _FakeLocker.instances + + +class TestSyncOnlyMode: + """--sync-only runs one headless sync pass and exits 0. + + The suite-wide conftest fixture patches ``sys.exit`` to a no-op so a stray + exit cannot kill the test run, so these assert on the call rather than on + a raised SystemExit. + """ + + def test_syncs_then_exits_zero(self) -> None: + """--sync-only configures logging and exits 0.""" + with ( + patch.object(_cli.logging, "basicConfig") as basic_config, + patch.object(_cli.sys, "exit") as sys_exit, + ): + _cli.main(_FakeLocker, ["screen_lock.py", "--sync-only"]) + + sys_exit.assert_called_once_with(0) + basic_config.assert_called_once() + + def test_actually_calls_sync_now(self) -> None: + """--sync-only runs exactly one sync pass.""" + synced: list[bool] = [] + + class _Recorder(_FakeLocker): + def sync_now(self) -> None: + """Record the sync call.""" + synced.append(True) + + with ( + patch.object(_cli.logging, "basicConfig"), + patch.object(_cli.sys, "exit"), + ): + _cli.main(_Recorder, ["screen_lock.py", "--sync-only"]) + + assert synced == [True] + + +class TestLockMode: + """With no subcommand flag, the real locker is built and run.""" + + def test_defaults_to_demo_mode(self) -> None: + """With no flags the locker is built in demo mode and run.""" + _cli.main(_FakeLocker, ["screen_lock.py"]) + + locker = _FakeLocker.instances[-1] + assert locker.demo_mode is True + assert locker.verify_only is False + assert locker.ran is True + + def test_production_flag_disables_demo_mode(self) -> None: + """--production turns demo mode off.""" + _cli.main(_FakeLocker, ["screen_lock.py", "--production"]) + + locker = _FakeLocker.instances[-1] + assert locker.demo_mode is False + assert locker.verify_only is False + + def test_verify_workout_flag_sets_verify_only(self) -> None: + """--verify-workout sets verify_only.""" + _cli.main(_FakeLocker, ["screen_lock.py", "--verify-workout"]) + + locker = _FakeLocker.instances[-1] + assert locker.verify_only is True + assert locker.demo_mode is True + + def test_production_and_verify_combine(self) -> None: + """--production and --verify-workout apply together.""" + _cli.main(_FakeLocker, ["screen_lock.py", "--production", "--verify-workout"]) + + locker = _FakeLocker.instances[-1] + assert locker.demo_mode is False + assert locker.verify_only is True + + +class TestScreenLockDelegation: + """screen_lock.py's __main__ block delegates here rather than duplicating.""" + + def test_main_is_exported(self) -> None: + """main is the module's public entry point.""" + assert _cli.__all__ == ["main"] + assert callable(_cli.main) diff --git a/screen_locker/tests/test_screen_lock_coverage_part1.py b/screen_locker/tests/test_screen_lock_coverage_part1.py index afb6126..1542cb4 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part1.py +++ b/screen_locker/tests/test_screen_lock_coverage_part1.py @@ -41,16 +41,18 @@ class TestCheckNonVerifyExitsExtras: MagicMock(return_value=True), ) with ( - patch("screen_locker.screen_lock.reset_to_base_if_new_day"), + patch("screen_locker._startup_checks.reset_to_base_if_new_day"), patch( - "screen_locker.screen_lock.count_weekly_workouts", side_effect=[0, 6] + "screen_locker._sync_mixin.count_weekly_workouts", side_effect=[0, 6] ), patch( - "screen_locker.screen_lock.process_week_transition", + "screen_locker._startup_checks.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._startup_checks.is_relaxed_day", return_value=False), + patch( + "screen_locker._startup_checks.has_weekly_minimum", return_value=True + ), patch("screen_locker.screen_lock.sys.exit"), ): locker._check_non_verify_exits() @@ -75,17 +77,19 @@ class TestCheckNonVerifyExitsExtras: MagicMock(return_value=False), ) with ( - patch("screen_locker.screen_lock.reset_to_base_if_new_day"), + patch("screen_locker._startup_checks.reset_to_base_if_new_day"), # prev=2, new=3 → bonus=max(0,3-max(5,2))=0 → no bonus logger call patch( - "screen_locker.screen_lock.count_weekly_workouts", side_effect=[2, 3] + "screen_locker._sync_mixin.count_weekly_workouts", side_effect=[2, 3] ), patch( - "screen_locker.screen_lock.process_week_transition", + "screen_locker._startup_checks.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._startup_checks.is_relaxed_day", return_value=False), + patch( + "screen_locker._startup_checks.has_weekly_minimum", return_value=False + ), patch("screen_locker.screen_lock.sys.exit"), ): locker._check_non_verify_exits() @@ -104,13 +108,15 @@ class TestCheckNonVerifyExitsExtras: MagicMock(return_value=0), ) with ( - patch("screen_locker.screen_lock.reset_to_base_if_new_day"), + patch("screen_locker._startup_checks.reset_to_base_if_new_day"), patch( - "screen_locker.screen_lock.process_week_transition", + "screen_locker._startup_checks.process_week_transition", return_value=["🎉 +1h shutdown bonus 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._startup_checks.is_relaxed_day", return_value=False), + patch( + "screen_locker._startup_checks.has_weekly_minimum", return_value=True + ), patch("screen_locker.screen_lock.sys.exit"), ): locker._check_non_verify_exits() @@ -135,18 +141,21 @@ class TestCheckNonVerifyExitsExtras: ) with ( patch( - "screen_locker.screen_lock.reset_to_base_if_new_day", return_value=True + "screen_locker._startup_checks.reset_to_base_if_new_day", + return_value=True, ), patch( - "screen_locker.screen_lock.process_week_transition", + "screen_locker._startup_checks.process_week_transition", return_value=[], ), patch( - "screen_locker.screen_lock.weekly_shutdown_bonus_hours", + "screen_locker._sync_mixin.weekly_shutdown_bonus_hours", return_value=2, ), - 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._startup_checks.is_relaxed_day", return_value=False), + patch( + "screen_locker._startup_checks.has_weekly_minimum", return_value=True + ), patch("screen_locker.screen_lock.sys.exit"), ): locker._check_non_verify_exits() @@ -172,18 +181,21 @@ class TestCheckNonVerifyExitsExtras: ) with ( patch( - "screen_locker.screen_lock.reset_to_base_if_new_day", return_value=False + "screen_locker._startup_checks.reset_to_base_if_new_day", + return_value=False, ), patch( - "screen_locker.screen_lock.process_week_transition", + "screen_locker._startup_checks.process_week_transition", return_value=[], ), patch( - "screen_locker.screen_lock.weekly_shutdown_bonus_hours", + "screen_locker._sync_mixin.weekly_shutdown_bonus_hours", return_value=2, ), - 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._startup_checks.is_relaxed_day", return_value=False), + patch( + "screen_locker._startup_checks.has_weekly_minimum", return_value=True + ), patch("screen_locker.screen_lock.sys.exit"), ): locker._check_non_verify_exits() @@ -358,12 +370,12 @@ class TestSyncNow: locker = create_locker(mock_tk, tmp_path) with ( patch( - "screen_locker.screen_lock.pull_all_manual_records", + "screen_locker._sync_mixin.pull_all_manual_records", return_value=[("manual:x", {})], ), - patch("screen_locker.screen_lock.push_pc_workouts") as push, + patch("screen_locker._sync_mixin.push_pc_workouts") as push, patch( - "screen_locker.screen_lock.ingest_manual_records", + "screen_locker._sync_mixin.ingest_manual_records", return_value=["manual:x"], ) as ingest, ): diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index 29f868f..e89caf6 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -272,11 +272,11 @@ class TestIngestSyncedManualWorkouts: locker = create_locker(mock_tk, tmp_path) with ( patch( - "screen_locker.screen_lock.pull_all_manual_records", + "screen_locker._sync_mixin.pull_all_manual_records", return_value=[("manual:x", {})], ), patch( - "screen_locker.screen_lock.ingest_manual_records", + "screen_locker._sync_mixin.ingest_manual_records", return_value=["manual:x"], ) as ingest, ): @@ -289,11 +289,11 @@ class TestIngestSyncedManualWorkouts: locker = create_locker(mock_tk, tmp_path) with ( patch( - "screen_locker.screen_lock.pull_all_manual_records", + "screen_locker._sync_mixin.pull_all_manual_records", return_value=[], ), patch( - "screen_locker.screen_lock.ingest_manual_records", + "screen_locker._sync_mixin.ingest_manual_records", return_value=[], ), ): @@ -310,11 +310,11 @@ class TestIngestSyncedManualWorkouts: locker.workout_data = {"stale": "state"} with ( patch( - "screen_locker.screen_lock.pull_all_manual_records", + "screen_locker._sync_mixin.pull_all_manual_records", return_value=[], ), patch( - "screen_locker.screen_lock.ingest_manual_records", + "screen_locker._sync_mixin.ingest_manual_records", return_value=[], ) as ingest, ): diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index 15ab412..e98f426 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -43,11 +43,11 @@ class TestRelaxedDayBranch: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ), patch( - "screen_locker.screen_lock.is_relaxed_day", + "screen_locker._startup_checks.is_relaxed_day", return_value=True, ), patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False, ), patch.object(ScreenLocker, "_start_phone_check") as mock_phone, @@ -75,11 +75,11 @@ class TestRelaxedDayBranch: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ), patch( - "screen_locker.screen_lock.is_relaxed_day", + "screen_locker._startup_checks.is_relaxed_day", return_value=True, ), patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False, ), patch("screen_locker.screen_lock.LockWindow") as mock_lock_window, @@ -109,11 +109,11 @@ class TestRelaxedDayBranch: ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ), patch( - "screen_locker.screen_lock.is_relaxed_day", + "screen_locker._startup_checks.is_relaxed_day", return_value=True, ), patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False, ), patch("screen_locker.screen_lock.LockWindow") as mock_lock_window, @@ -148,7 +148,7 @@ class TestWeeklyMinimumBranch: tmp_path: Path, ) -> None: with patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=True, ): create_locker(mock_tk, tmp_path, has_logged=False) @@ -164,7 +164,7 @@ class TestWeeklyMinimumBranch: # create_locker already stubs _start_phone_check; just verify no exit # and _relaxed_day_mode stays False (full lock path taken). with patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False, ): locker = create_locker(mock_tk, tmp_path, has_logged=False) @@ -179,7 +179,7 @@ class TestWeeklyMinimumBranch: tmp_path: Path, ) -> None: with patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", ) as mock_weekly: create_locker_relaxed_day(mock_tk, tmp_path) @@ -192,7 +192,7 @@ class TestWeeklyMinimumBranch: tmp_path: Path, ) -> None: with patch( - "screen_locker.screen_lock.has_weekly_minimum", + "screen_locker._startup_checks.has_weekly_minimum", ) as mock_weekly: create_locker(mock_tk, tmp_path, has_logged=True) diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py index 95992a1..d70cb4a 100644 --- a/screen_locker/tests/test_weekly_logic_part2.py +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -215,9 +215,11 @@ class TestHeatSkipBranch: tmp_path: Path, ) -> None: with ( - patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch( - "screen_locker.screen_lock.fetch_current_temp_with_status", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False + ), + patch( + "screen_locker._startup_checks.fetch_current_temp_with_status", return_value=TemperatureCheck(temp_celsius=20.0, timed_out=False), ) as mock_hot, patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog, @@ -235,9 +237,11 @@ class TestHeatSkipBranch: tmp_path: Path, ) -> None: with ( - patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch( - "screen_locker.screen_lock.fetch_current_temp_with_status", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False + ), + patch( + "screen_locker._startup_checks.fetch_current_temp_with_status", return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False), ), patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=True), @@ -255,9 +259,11 @@ class TestHeatSkipBranch: tmp_path: Path, ) -> None: with ( - patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch( - "screen_locker.screen_lock.fetch_current_temp_with_status", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False + ), + patch( + "screen_locker._startup_checks.fetch_current_temp_with_status", return_value=TemperatureCheck(temp_celsius=35.0, timed_out=False), ), patch.object(ScreenLocker, "_show_heat_skip_dialog", return_value=False), @@ -276,9 +282,11 @@ class TestHeatSkipBranch: ) -> None: """A timed-out temperature check must fail closed, not skip the lock.""" with ( - patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch( - "screen_locker.screen_lock.fetch_current_temp_with_status", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False + ), + patch( + "screen_locker._startup_checks.fetch_current_temp_with_status", return_value=TemperatureCheck(temp_celsius=None, timed_out=True), ), patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog, @@ -296,9 +304,11 @@ class TestHeatSkipBranch: ) -> None: """A failed (non-timeout) temperature check must also fail closed.""" with ( - patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch( - "screen_locker.screen_lock.fetch_current_temp_with_status", + "screen_locker._startup_checks.has_weekly_minimum", return_value=False + ), + patch( + "screen_locker._startup_checks.fetch_current_temp_with_status", return_value=TemperatureCheck(temp_celsius=None, timed_out=False), ), patch.object(ScreenLocker, "_show_heat_skip_dialog") as mock_dialog,