Split screen_lock.py under the 250-line cap
Some checks are pending
pre-commit / pre-commit (push) Waiting to run
Tests / test (3.10) (push) Waiting to run
Tests / test (3.11) (push) Waiting to run
Tests / test (3.12) (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
release-apk / release (APK) (push) Waiting to run

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-08-15 19:01:18 +02:00
parent c63a02f9eb
commit 4b4695e23a
11 changed files with 554 additions and 270 deletions

60
screen_locker/_cli.py Normal file
View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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)

View File

@ -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"),

View File

@ -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,
),
):

View File

@ -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)

View File

@ -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,
):

View File

@ -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,
):

View File

@ -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)

View File

@ -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,