Fix silent skip-credit bypass; replace with weekly shutdown-time bonus

The screen locker skipped enforcement on 2026-07-03 without ever showing
a lock: a banked skip credit (earned from a prior 5+/week streak) was
consumed automatically with no confirmation and no visible log. Reworked
the whole reward mechanic instead of just gating it, since banking a
"skip a future workout" credit works against maximizing weekly workouts:

- Removed skip credits entirely (has_skip_credit/consume_skip_credit and
  the confirmation dialog built to gate them). The only same-day skip
  paths left are heat_skip and sick_day, both requiring a genuine reason.
- Extra workouts (5+/week) now bank shutdown-time-later hours for the
  following week instead — comfort, not reduced enforcement. Reuses the
  existing _adjust_shutdown_time_by and reset_to_base_if_new_day's
  previously-discarded return value as the once-per-day gate.
- early_bird and sick_day no longer pollute workout_log.json. early_bird
  is a same-day pending marker now stored in its own self-expiring,
  HMAC-signed file; sick_day is sourced entirely from sick_history.json
  (already the real source of truth). Fixes an accidental-safety gap
  where "already took a sick day today" only halted startup by luck.
- Cleaned up 3 stale non-workout entries already in workout_log.json.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QdTccgbK7624kfoaV6CtXS
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-07-03 15:27:08 +02:00
parent 26f23911f0
commit e25d806742
21 changed files with 729 additions and 536 deletions

View File

@ -1,12 +1,17 @@
"""Mixin: auto-upgrade early_bird/sick_day log entries via phone or RunnerUp.""" """Mixin: auto-upgrade early_bird/sick_day pending states via phone or RunnerUp.
Neither early_bird (a same-day pending marker, see ``_early_bird.py``) nor
sick_day (tracked in ``sick_history.json`` via ``_sick_tracker.py``) live in
workout_log.json this module only checks their pending state and, on
success, writes the *real* outcome (phone_verified/runnerup_verified) there.
"""
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timezone
import json
import logging import logging
import sys import sys
from screen_locker import _sick_tracker
from screen_locker._wake_state import has_workout_skip_today from screen_locker._wake_state import has_workout_skip_today
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -19,25 +24,14 @@ class AutoUpgradeMixin:
RunnerUpVerificationMixin, LogMixin, and ShutdownMixin via MRO. RunnerUpVerificationMixin, LogMixin, and ShutdownMixin via MRO.
""" """
def _is_sick_day_log(self) -> bool: def _is_sick_day_today(self) -> bool:
"""Check if today's workout log is a sick day (not yet verified).""" """Check if today is marked as a sick day in sick_history.json."""
if not self.log_file.exists(): # type: ignore[attr-defined] return _sick_tracker.is_sick_day(_sick_tracker.load_history())
return False
try:
with self.log_file.open() as f: # type: ignore[attr-defined]
logs = json.load(f)
except (OSError, json.JSONDecodeError):
return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
entry = logs.get(today)
if entry is None:
return False
return entry.get("workout_data", {}).get("type") == "sick_day"
def _check_early_exits(self, *, verify_only: bool) -> None: def _check_early_exits(self, *, verify_only: bool) -> None:
"""Check startup conditions and exit early when appropriate.""" """Check startup conditions and exit early when appropriate."""
if verify_only: if verify_only:
if not self._is_sick_day_log(): if not self._is_sick_day_today():
_logger.info("No sick day logged today. Nothing to verify.") _logger.info("No sick day logged today. Nothing to verify.")
sys.exit(0) sys.exit(0)
return return
@ -46,7 +40,7 @@ class AutoUpgradeMixin:
def _check_today_state_exits(self) -> bool: def _check_today_state_exits(self) -> bool:
"""Handle early-bird and today's log states. Return True to stop startup.""" """Handle early-bird and today's log states. Return True to stop startup."""
if ( if (
self._is_early_bird_log() # type: ignore[attr-defined] self._is_early_bird_pending() # type: ignore[attr-defined]
and not self._is_early_bird_time() # type: ignore[attr-defined] and not self._is_early_bird_time() # type: ignore[attr-defined]
): ):
if self._try_auto_upgrade_early_bird(): if self._try_auto_upgrade_early_bird():
@ -54,16 +48,19 @@ class AutoUpgradeMixin:
sys.exit(0) sys.exit(0)
return True return True
return False # Expired early bird, upgrade unavailable — full lock. return False # Expired early bird, upgrade unavailable — full lock.
if self._is_early_bird_log(): # type: ignore[attr-defined] if self._is_early_bird_pending(): # type: ignore[attr-defined]
_logger.info("Early bird window still active — skipping lock.") _logger.info("Early bird window still active — skipping lock.")
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day(): elif self._is_sick_day_today():
_logger.info("Auto-upgraded today's sick_day entry to phone_verified.") if self._try_auto_upgrade_sick_day():
_logger.info("Auto-upgraded today's sick_day entry to phone_verified.")
else:
_logger.info("Sick day already logged today.")
elif self.has_logged_today(): # type: ignore[attr-defined] elif self.has_logged_today(): # type: ignore[attr-defined]
_logger.info("Workout already logged today. Skipping screen lock.") _logger.info("Workout already logged today. Skipping screen lock.")
elif has_workout_skip_today(): elif has_workout_skip_today():
_logger.info("Wake alarm earned workout skip. Skipping screen lock.") _logger.info("Wake alarm earned workout skip. Skipping screen lock.")
elif self._is_early_bird_time(): # type: ignore[attr-defined] elif self._is_early_bird_time(): # type: ignore[attr-defined]
self._save_early_bird_log() # type: ignore[attr-defined] self._save_early_bird_pending() # type: ignore[attr-defined]
_logger.info("Early bird time — skipping lock, will re-check at 08:30.") _logger.info("Early bird time — skipping lock, will re-check at 08:30.")
else: else:
return False return False

View File

@ -69,6 +69,10 @@ SCHEDULED_SKIPS_FILE = Path(__file__).resolve().parent / "scheduled_skips.json"
EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json" EXTRA_BENEFITS_FILE = Path(__file__).resolve().parent / "extra_benefits_state.json"
# State file storing the base (pre-bonus) shutdown hours and last reset date. # State file storing the base (pre-bonus) shutdown hours and last reset date.
SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json" SHUTDOWN_BASE_FILE = Path(__file__).resolve().parent / "shutdown_base.json"
# Self-expiring marker: "logged in during today's early-bird window, still
# waiting to see if a real workout shows up." Not a workout_log.json entry —
# it's a same-day pending flag, checked against its own "date" field.
EARLY_BIRD_PENDING_FILE = Path(__file__).resolve().parent / "early_bird_pending.json"
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Wake-alarm integration (originally from wake_alarm._constants / _state). # Wake-alarm integration (originally from wake_alarm._constants / _state).

View File

@ -1,4 +1,11 @@
"""Early bird window detection and log helpers for ScreenLocker.""" """Early bird window detection and pending-state helpers for ScreenLocker.
The early-bird "still waiting to see if a real workout shows up" flag is a
same-day pending marker, not a workout it is intentionally kept out of
workout_log.json (which is reserved for real outcomes) and instead lives in
its own self-expiring, HMAC-signed state file, mirroring the pattern used by
``_wake_state.py`` for the companion wake_alarm service.
"""
from __future__ import annotations from __future__ import annotations
@ -6,9 +13,12 @@ from datetime import datetime, timezone
import json import json
import logging import logging
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._constants import ( from screen_locker._constants import (
EARLY_BIRD_END_HOUR, EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE, EARLY_BIRD_END_MINUTE,
EARLY_BIRD_PENDING_FILE,
EARLY_BIRD_START_HOUR, EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE, EXTRA_BENEFITS_FILE,
) )
@ -17,8 +27,13 @@ from screen_locker._extra_benefits import has_extended_early_bird
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
def _today_str() -> str:
"""Return today's date as YYYY-MM-DD in UTC."""
return datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
class EarlyBirdMixin: class EarlyBirdMixin:
"""Mixin providing early-bird time window checks and log helpers.""" """Mixin providing early-bird time window checks and pending-state helpers."""
def _get_local_time_minutes(self) -> int: def _get_local_time_minutes(self) -> int:
"""Return current local time as minutes from midnight.""" """Return current local time as minutes from midnight."""
@ -40,22 +55,35 @@ class EarlyBirdMixin:
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= minutes < end return start <= minutes < end
def _is_early_bird_log(self) -> bool: def _is_early_bird_pending(self) -> bool:
"""Check if today's workout log entry is an early_bird provisional entry.""" """Check if today has an unresolved early-bird pending marker."""
if not self.log_file.exists(): if not EARLY_BIRD_PENDING_FILE.exists():
return False return False
try: try:
with self.log_file.open() as f: with EARLY_BIRD_PENDING_FILE.open() as f:
logs = json.load(f) state = json.load(f)
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
return False return False
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") if not isinstance(state, dict) or state.get("date") != _today_str():
entry = logs.get(today)
if entry is None:
return False return False
return entry.get("workout_data", {}).get("type") == "early_bird" if verify_entry_hmac(state):
return True
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state:
_logger.info("HMAC key unavailable — accepting unsigned pending marker")
return True
_logger.warning("HMAC verification failed for early-bird pending marker")
return False
def _save_early_bird_log(self) -> None: def _save_early_bird_pending(self) -> None:
"""Save an early_bird provisional entry to the workout log.""" """Save today's early-bird pending marker (self-expires tomorrow)."""
self.workout_data = {"type": "early_bird"} state: dict[str, object] = {"date": _today_str()}
self.save_workout_log() signature = compute_entry_hmac(state)
if signature is not None:
state["hmac"] = signature
else:
_logger.warning("HMAC key unavailable — saving unsigned pending marker")
try:
with EARLY_BIRD_PENDING_FILE.open("w") as f:
json.dump(state, f, indent=2)
except OSError as exc:
_logger.warning("Could not save early-bird pending marker: %s", exc)

View File

@ -2,7 +2,10 @@
Tracks: Tracks:
- Consecutive weeks with 5+ workouts (streak counter). - Consecutive weeks with 5+ workouts (streak counter).
- Banked skip credits earned from extra workouts. - Banked shutdown-time bonus hours earned from extra workouts, applied
every day of the following week. This never reduces enforcement (unlike
a banked "skip a workout" credit would) it only grants extra comfort
time on top of a floor you still have to earn each day.
- ISO weeks in which the early-bird window is extended to 09:00. - ISO weeks in which the early-bird window is extended to 09:00.
State is persisted in ``extra_benefits_state.json`` next to this file. State is persisted in ``extra_benefits_state.json`` next to this file.
@ -22,7 +25,7 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
_MILESTONE_INTERVAL = 4 # every 4-week streak → +1 bonus skip credit _MILESTONE_INTERVAL = 4 # every 4-week streak → +1h extra shutdown bonus
_BONUS_THRESHOLD = 5 # workouts/week required to earn extra rewards _BONUS_THRESHOLD = 5 # workouts/week required to earn extra rewards
@ -46,20 +49,26 @@ def _save_state(state_file: Path, state: dict[str, Any]) -> None:
_logger.warning("Failed to save extra benefits state: %s", exc) _logger.warning("Failed to save extra benefits state: %s", exc)
def _current_iso_week(now: datetime) -> str:
"""Return *now*'s ISO week as ``YYYY-Www``."""
year, week, _ = now.isocalendar()
return f"{year}-W{week:02d}"
def process_week_transition(log_file: Path, state_file: Path) -> list[str]: def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
"""Process last week's results if we've entered a new ISO week. """Process last week's results if we've entered a new ISO week.
Counts workouts from the previous ISO week. If count >= 5: Counts workouts from the previous ISO week. If count >= 5:
- Increments the consecutive-streak counter. - Increments the consecutive-streak counter.
- Awards (count - 4) skip credits. - Banks (count - 4) hours of shutdown-time bonus for the current week,
applied once per day on top of the daily base reset.
- Marks the *current* ISO week as having extended early-bird (09:00). - Marks the *current* ISO week as having extended early-bird (09:00).
- Awards a bonus skip credit every ``_MILESTONE_INTERVAL`` streak weeks. - Adds +1h more bonus every ``_MILESTONE_INTERVAL`` streak weeks.
Returns a list of human-readable reward strings (empty if no transition). Returns a list of human-readable reward strings (empty if no transition).
""" """
now = datetime.now(tz=timezone.utc).astimezone() now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar() current_week_str = _current_iso_week(now)
current_week_str = f"{year}-W{week:02d}"
state = _load_state(state_file) state = _load_state(state_file)
if state.get("last_processed_iso_week") == current_week_str: if state.get("last_processed_iso_week") == current_week_str:
@ -80,7 +89,9 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt) prev_week_count = count_weekly_workouts(log_file, today=prev_week_dt)
streak = int(state.get("consecutive_5plus_weeks", 0)) streak = int(state.get("consecutive_5plus_weeks", 0))
skip_credits = int(state.get("skip_credits", 0)) weekly_bonus_hours: dict[str, int] = dict(
state.get("weekly_shutdown_bonus_hours", {})
)
eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", [])) eb_weeks: list[str] = list(state.get("extended_early_bird_iso_weeks", []))
rewards: list[str] = [] rewards: list[str] = []
@ -90,16 +101,17 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
if prev_week_count >= _BONUS_THRESHOLD: if prev_week_count >= _BONUS_THRESHOLD:
extra = prev_week_count - 4 extra = prev_week_count - 4
streak += 1 streak += 1
skip_credits += extra bonus_hours = extra
if current_week_str not in eb_weeks: if current_week_str not in eb_weeks:
eb_weeks.append(current_week_str) eb_weeks.append(current_week_str)
rewards.append( rewards.append(
f"{prev_week_count} workouts in {prev_week_str}! " f"{prev_week_count} workouts in {prev_week_str}! "
f"+{extra} skip credit(s), early-bird extended to 09:00 this week" f"+{extra}h shutdown bonus this week, early-bird extended to 09:00"
) )
if streak % _MILESTONE_INTERVAL == 0: if streak % _MILESTONE_INTERVAL == 0:
skip_credits += 1 bonus_hours += 1
rewards.append(f"{streak}-week streak milestone! +1 bonus skip credit") rewards.append(f"{streak}-week streak milestone! +1h extra shutdown bonus")
weekly_bonus_hours[current_week_str] = bonus_hours
else: else:
if streak > 0: if streak > 0:
rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)") rewards.append(f"Streak reset (was {streak} weeks of 5+ workouts)")
@ -110,7 +122,7 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
{ {
"consecutive_5plus_weeks": streak, "consecutive_5plus_weeks": streak,
"last_processed_iso_week": current_week_str, "last_processed_iso_week": current_week_str,
"skip_credits": skip_credits, "weekly_shutdown_bonus_hours": weekly_bonus_hours,
"extended_early_bird_iso_weeks": eb_weeks, "extended_early_bird_iso_weeks": eb_weeks,
}, },
) )
@ -122,25 +134,22 @@ def current_streak(state_file: Path) -> int:
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0)) return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
def has_skip_credit(state_file: Path) -> bool: def weekly_shutdown_bonus_hours(
"""Return True if at least one banked skip credit is available.""" state_file: Path, *, today: datetime | None = None
return int(_load_state(state_file).get("skip_credits", 0)) > 0 ) -> int:
"""Return the banked shutdown-time bonus (hours) for the current ISO week."""
now = today if today is not None else datetime.now(tz=timezone.utc).astimezone()
def consume_skip_credit(state_file: Path) -> None: current_week_str = _current_iso_week(now)
"""Deduct one skip credit from the bank.""" bonus_hours: dict[str, int] = _load_state(state_file).get(
state = _load_state(state_file) "weekly_shutdown_bonus_hours", {}
credit_count = int(state.get("skip_credits", 0)) )
if credit_count > 0: return int(bonus_hours.get(current_week_str, 0))
state["skip_credits"] = credit_count - 1
_save_state(state_file, state)
def has_extended_early_bird(state_file: Path) -> bool: def has_extended_early_bird(state_file: Path) -> bool:
"""Return True if the current ISO week has an extended early-bird window (09:00).""" """Return True if the current ISO week has an extended early-bird window (09:00)."""
now = datetime.now(tz=timezone.utc).astimezone() now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar() current_week_str = _current_iso_week(now)
current_week_str = f"{year}-W{week:02d}"
eb_weeks: list[str] = _load_state(state_file).get( eb_weeks: list[str] = _load_state(state_file).get(
"extended_early_bird_iso_weeks", [] "extended_early_bird_iso_weeks", []
) )

View File

@ -31,10 +31,10 @@ class LogMixin:
if entry is None: if entry is None:
return False return False
if verify_entry_hmac(entry): if verify_entry_hmac(entry):
return entry.get("workout_data", {}).get("type") != "early_bird" return True
if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry: if compute_entry_hmac({"_probe": True}) is None and "hmac" not in entry:
_logger.info("HMAC key unavailable — accepting unsigned entry") _logger.info("HMAC key unavailable — accepting unsigned entry")
return entry.get("workout_data", {}).get("type") != "early_bird" return True
_logger.warning("HMAC verification failed for today's log entry") _logger.warning("HMAC verification failed for today's log entry")
return False return False

View File

@ -93,6 +93,12 @@ def save_history(history: SickHistory) -> bool:
return True return True
def is_sick_day(history: SickHistory, *, today: str | None = None) -> bool:
"""Return True if today is recorded as a sick day."""
today_str = today or _today_iso()
return today_str in history.sick_days
def count_in_window( def count_in_window(
history: SickHistory, history: SickHistory,
days: int, days: int,

View File

@ -2,7 +2,7 @@
from __future__ import annotations from __future__ import annotations
from datetime import datetime, timedelta, timezone from datetime import date, datetime, timedelta, timezone
import json import json
import sys import sys
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
@ -11,7 +11,9 @@ from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._extra_benefits import ( from screen_locker._extra_benefits import (
current_streak, current_streak,
has_extended_early_bird, has_extended_early_bird,
weekly_shutdown_bonus_hours,
) )
from screen_locker._sick_tracker import load_history
from screen_locker._weekly_check import ( from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES, COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM, WEEKLY_WORKOUT_MINIMUM,
@ -45,12 +47,31 @@ def _load_extra_benefits() -> dict:
return {} return {}
def _print_day_line(d: date, entry: dict | None, sick_days: set[str]) -> bool:
"""Print one day's status line. Returns True if it counted as a workout."""
label = d.strftime("%a %b %d")
if entry is None:
if d.isoformat() in sick_days:
print(f" {label} ✗ sick_day")
else:
print(f" {label} — no entry")
return False
wtype = entry.get("workout_data", {}).get("type", "?")
src = entry.get("workout_data", {}).get("source", "")
counted = wtype in COUNTED_WORKOUT_TYPES
src_str = f" ({src[:45]})" if src else ""
mark = "" if counted else ""
print(f" {label} {mark} {wtype}{src_str}")
return counted
def run_status(locker: ScreenLocker) -> None: def run_status(locker: ScreenLocker) -> None:
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit.""" """Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
today = datetime.now(tz=timezone.utc).astimezone().date() today = datetime.now(tz=timezone.utc).astimezone().date()
monday = today - timedelta(days=today.weekday()) monday = today - timedelta(days=today.weekday())
log_file: Path = locker.log_file # type: ignore[attr-defined] log_file: Path = locker.log_file # type: ignore[attr-defined]
log_data = _load_log(log_file) log_data = _load_log(log_file)
sick_days = set(load_history().sick_days)
print("=== Weekly Workout Status ===") print("=== Weekly Workout Status ===")
@ -60,19 +81,8 @@ def run_status(locker: ScreenLocker) -> None:
d = monday + timedelta(days=i) d = monday + timedelta(days=i)
if d > today: if d > today:
break break
dstr = d.isoformat() if _print_day_line(d, log_data.get(d.isoformat()), sick_days):
entry = log_data.get(dstr) before_count += 1
if entry is None:
print(f" {d.strftime('%a %b %d')} — no entry")
else:
wtype = entry.get("workout_data", {}).get("type", "?")
src = entry.get("workout_data", {}).get("source", "")
counted = wtype in COUNTED_WORKOUT_TYPES
src_str = f" ({src[:45]})" if src else ""
mark = "" if counted else ""
print(f" {d.strftime('%a %b %d')} {mark} {wtype}{src_str}")
if counted:
before_count += 1
print() print()
@ -95,16 +105,13 @@ def run_status(locker: ScreenLocker) -> None:
print() print()
# Extra benefits summary # Extra benefits summary
state = _load_extra_benefits() bonus_hours = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
credits = state.get("skip_credits", 0)
streak = current_streak(EXTRA_BENEFITS_FILE) streak = current_streak(EXTRA_BENEFITS_FILE)
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE) eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No" eb_str = "Yes — until 09:00" if eb_ext else "No"
# Heat skips this month # Heat skips this month
from datetime import date this_month = datetime.now(tz=timezone.utc).astimezone().date().strftime("%Y-%m")
this_month = date.today().strftime("%Y-%m")
heat_entries = [ heat_entries = [
(d, e) (d, e)
for d, e in log_data.items() for d, e in log_data.items()
@ -118,7 +125,7 @@ def run_status(locker: ScreenLocker) -> None:
else: else:
heat_str = "0" heat_str = "0"
print(f" Skip credits banked : {credits}") print(f" Shutdown bonus (this wk): {bonus_hours}h")
print(f" Streak (5+ wks) : {streak}") print(f" Streak (5+ wks) : {streak}")
print(f" Early-bird extended : {eb_str}") print(f" Early-bird extended : {eb_str}")
print(f" Heat skips (month) : {heat_str}") print(f" Heat skips (month) : {heat_str}")

View File

@ -1,7 +1,7 @@
{ {
"consecutive_5plus_weeks": 1, "consecutive_5plus_weeks": 1,
"last_processed_iso_week": "2026-W27", "last_processed_iso_week": "2026-W27",
"skip_credits": 1, "weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [ "extended_early_bird_iso_weeks": [
"2026-W27" "2026-W27"
] ]

View File

@ -35,10 +35,9 @@ from screen_locker._constants import (
) )
from screen_locker._early_bird import EarlyBirdMixin from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._extra_benefits import ( from screen_locker._extra_benefits import (
consume_skip_credit,
current_streak, current_streak,
has_skip_credit,
process_week_transition, process_week_transition,
weekly_shutdown_bonus_hours,
) )
from screen_locker._heat_skip import HeatSkipMixin from screen_locker._heat_skip import HeatSkipMixin
from screen_locker._log_mixin import LogMixin from screen_locker._log_mixin import LogMixin
@ -172,11 +171,17 @@ class ScreenLocker(
_logger.info("Today is a scheduled skip day. Skipping screen lock.") _logger.info("Today is a scheduled skip day. Skipping screen lock.")
sys.exit(0) sys.exit(0)
return return
# Reset shutdown config to base (21:00) at the start of each new day # Award streak / shutdown-bonus / EB-extension rewards from last week
# so workout bonuses always layer on top of a known floor. # before the daily reset, so a Monday transition's bonus is recorded
reset_to_base_if_new_day( # 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 SHUTDOWN_BASE_FILE, self, sick_day_state_file=SICK_DAY_STATE_FILE
) ):
self._apply_weekly_shutdown_bonus()
# Auto-fill any RunnerUp workouts from earlier in the current ISO week # 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 # before any early-exit check, so gaps are closed regardless of today's
# logged state (early_bird, sick_day, etc.). # logged state (early_bird, sick_day, etc.).
@ -191,9 +196,6 @@ class ScreenLocker(
bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count)) bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count))
if bonus > 0 and self._adjust_shutdown_time_by(bonus): if bonus > 0 and self._adjust_shutdown_time_by(bonus):
_logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus) _logger.info("Auto-fill extra bonus: +%dh shutdown time.", bonus)
# Award streak / skip-credit / EB-extension rewards from last week.
for reward_msg in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
_logger.info("Weekly reward: %s", reward_msg)
if self._check_today_state_exits(): if self._check_today_state_exits():
return return
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced. # Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
@ -209,8 +211,11 @@ class ScreenLocker(
) )
sys.exit(0) sys.exit(0)
return return
# Offer heat skip before consuming a banked credit — credit is preserved # Only remaining same-day skip: genuine extreme heat. Sick days go
# for another day if the user chooses to skip due to temperature. # 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).
hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD) hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD)
if hot_temp is not None: if hot_temp is not None:
_logger.info( _logger.info(
@ -222,12 +227,12 @@ class ScreenLocker(
_logger.info("User skipped workout due to heat (%.0f°C).", hot_temp) _logger.info("User skipped workout due to heat (%.0f°C).", hot_temp)
sys.exit(0) sys.exit(0)
return return
# Spend a banked skip credit if the minimum hasn't been reached yet.
if has_skip_credit(EXTRA_BENEFITS_FILE): def _apply_weekly_shutdown_bonus(self) -> None:
consume_skip_credit(EXTRA_BENEFITS_FILE) """Layer this week's earned shutdown bonus back on top of the fresh base."""
_logger.info("Used a banked skip credit — no lock today.") bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
sys.exit(0) if bonus > 0 and self._adjust_shutdown_time_by(bonus):
return _logger.info("Weekly bonus: +%dh shutdown time this week.", bonus)
def _try_adjust_shutdown_for_workout(self) -> bool: def _try_adjust_shutdown_for_workout(self) -> bool:
"""Try to adjust shutdown time later for actual workouts.""" """Try to adjust shutdown time later for actual workouts."""
@ -256,7 +261,10 @@ class ScreenLocker(
def unlock_screen(self) -> None: def unlock_screen(self) -> None:
"""Save workout log and display success message.""" """Save workout log and display success message."""
self.save_workout_log() # sick_day is already persisted to sick_history.json by
# _finalize_sick_day — workout_log.json is reserved for real outcomes.
if self.workout_data.get("type") != "sick_day":
self.save_workout_log()
shutdown_adjusted = self._try_adjust_shutdown_for_workout() shutdown_adjusted = self._try_adjust_shutdown_for_workout()
new_debt = self._clear_debt_on_verified_workout() new_debt = self._clear_debt_on_verified_workout()

View File

@ -1,5 +1,5 @@
{ {
"base_mon_wed_hour": 21, "base_mon_wed_hour": 21,
"base_thu_sun_hour": 21, "base_thu_sun_hour": 21,
"last_reset_date": "2026-06-29" "last_reset_date": "2026-07-03"
} }

View File

@ -12,8 +12,11 @@ Safety:
from __future__ import annotations from __future__ import annotations
from contextlib import ExitStack from contextlib import ExitStack
from datetime import datetime, timezone
import json
from pathlib import Path from pathlib import Path
import tkinter as tk import tkinter as tk
from types import SimpleNamespace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import MagicMock, patch
@ -130,6 +133,74 @@ def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
yield yield
@pytest.fixture(autouse=True)
def _isolate_early_bird_pending(tmp_path: Path) -> Iterator[None]:
"""Redirect EARLY_BIRD_PENDING_FILE to tmp_path so tests use a clean file."""
target = tmp_path / "early_bird_pending.json"
with (
patch(
"screen_locker._early_bird.EARLY_BIRD_PENDING_FILE",
target,
),
patch(
"screen_locker._constants.EARLY_BIRD_PENDING_FILE",
target,
),
):
yield
@pytest.fixture(autouse=True)
def _isolate_extra_benefits(tmp_path: Path) -> Iterator[None]:
"""Redirect EXTRA_BENEFITS_FILE to tmp_path so tests cannot touch real state.
Bound by value into several modules at import time, so every bound name
needs patching individually not just the ``_constants`` source.
"""
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._early_bird.EXTRA_BENEFITS_FILE", target),
patch("screen_locker._status.EXTRA_BENEFITS_FILE", target),
):
yield
@pytest.fixture(autouse=True)
def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]:
"""Redirect SHUTDOWN_BASE_FILE to tmp_path so tests cannot touch real state.
Pre-seeded with today's date so reset_to_base_if_new_day() is a no-op by
default (matching the real file's steady state) -- tests that want to
exercise the actual reset path patch reset_to_base_if_new_day directly,
same as the rest of the suite already does.
"""
target = tmp_path / "shutdown_base.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
target.write_text(
json.dumps(
{"base_mon_wed_hour": 21, "base_thu_sun_hour": 21, "last_reset_date": today}
)
)
with (
patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target),
patch("screen_locker.screen_lock.SHUTDOWN_BASE_FILE", target),
):
yield
@pytest.fixture(autouse=True)
def _isolate_sick_day_state(tmp_path: Path) -> Iterator[None]:
"""Redirect SICK_DAY_STATE_FILE to tmp_path so tests cannot touch real state."""
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),
):
yield
@pytest.fixture(autouse=True) @pytest.fixture(autouse=True)
def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]: def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
"""Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file.""" """Redirect SCHEDULED_SKIPS_FILE to tmp_path so tests use a clean file."""
@ -203,6 +274,24 @@ def temp_log_file(tmp_path: Path) -> Path:
return tmp_path / "workout_log.json" return tmp_path / "workout_log.json"
def _make_locker(
log_file: Path,
*,
n_filled: int = 0,
bonus_applied: bool = False,
cfg: tuple | None = (22, 22, 5),
):
"""Build a minimal locker-like namespace for _status.run_status()."""
locker = SimpleNamespace(
log_file=log_file,
workout_data={},
)
locker._scan_and_fill_week_runnerup = MagicMock(return_value=n_filled)
locker._adjust_shutdown_time_by = MagicMock(return_value=bonus_applied)
locker._read_shutdown_config = MagicMock(return_value=cfg)
return locker
def create_locker( def create_locker(
_mock_tk: MagicMock, _mock_tk: MagicMock,
tmp_path: Path, tmp_path: Path,
@ -218,10 +307,10 @@ def create_locker(
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object( patch.object(
ScreenLocker, ScreenLocker,
"_is_sick_day_log", "_is_sick_day_today",
return_value=is_sick_day_log, return_value=is_sick_day_log,
), ),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object( patch.object(
ScreenLocker, ScreenLocker,
@ -255,8 +344,8 @@ def create_locker_relaxed_day(
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", 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.object(ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=True), patch("screen_locker.screen_lock.is_relaxed_day", return_value=True),
@ -294,9 +383,9 @@ def create_locker_early_bird(
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged), patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object( patch.object(
ScreenLocker, "_is_early_bird_log", return_value=is_early_bird_log ScreenLocker, "_is_early_bird_pending", return_value=is_early_bird_log
), ),
patch.object( patch.object(
ScreenLocker, "_is_early_bird_time", return_value=is_early_bird_time ScreenLocker, "_is_early_bird_time", return_value=is_early_bird_time

View File

@ -149,19 +149,23 @@ class TestIsEarlyBirdTime:
assert locker._is_early_bird_time() is False assert locker._is_early_bird_time() is False
class TestIsEarlyBirdLog: class TestIsEarlyBirdPending:
"""Tests for _is_early_bird_log method.""" """Tests for _is_early_bird_pending method.
def test_no_log_file( early_bird is a same-day pending marker stored in its own HMAC-signed
file (EARLY_BIRD_PENDING_FILE), not in workout_log.json see
_early_bird.py's module docstring for why.
"""
def test_no_pending_file(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when log file does not exist.""" """Return False when the pending file does not exist."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json" assert locker._is_early_bird_pending() is False
assert locker._is_early_bird_log() is False
def test_invalid_json( def test_invalid_json(
self, self,
@ -169,12 +173,12 @@ class TestIsEarlyBirdLog:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when log file contains invalid JSON.""" """Return False when the pending file contains invalid JSON."""
log_file = tmp_path / "workout_log.json"
log_file.write_text("{bad json}")
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file pending_file = tmp_path / "early_bird_pending.json"
assert locker._is_early_bird_log() is False pending_file.write_text("{bad json}")
with patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file):
assert locker._is_early_bird_pending() is False
def test_os_error_on_open( def test_os_error_on_open(
self, self,
@ -182,81 +186,136 @@ class TestIsEarlyBirdLog:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when opening the log file raises OSError.""" """Return False when opening the pending file raises OSError."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock() mock_file = MagicMock()
mock_file.exists.return_value = True mock_file.exists.return_value = True
mock_file.open.side_effect = OSError("permission denied") mock_file.open.side_effect = OSError("permission denied")
locker.log_file = mock_file with patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", mock_file):
assert locker._is_early_bird_log() is False assert locker._is_early_bird_pending() is False
def test_no_entry_today( def test_stale_date(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when no entry exists for today.""" """Return False when the marker is from a previous day."""
log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps({"2020-01-01": {}}))
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file pending_file = tmp_path / "early_bird_pending.json"
assert locker._is_early_bird_log() is False pending_file.write_text(json.dumps({"date": "2000-01-01", "hmac": "sig"}))
with patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file):
assert locker._is_early_bird_pending() is False
def test_today_is_phone_verified( def test_hmac_invalid(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when today's entry is phone_verified.""" """Return False when HMAC verification fails."""
log_file = tmp_path / "workout_log.json" locker = create_locker(mock_tk, tmp_path)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text( pending_file = tmp_path / "early_bird_pending.json"
json.dumps({today: {"workout_data": {"type": "phone_verified"}}}) pending_file.write_text(json.dumps({"date": today, "hmac": "bad"}))
) with (
locker = create_locker(mock_tk, tmp_path) patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file),
locker.log_file = log_file patch("screen_locker._early_bird.verify_entry_hmac", return_value=False),
assert locker._is_early_bird_log() is False patch("screen_locker._early_bird.compute_entry_hmac", return_value="sig"),
def test_today_is_early_bird(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return True when today's entry type is early_bird."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps({today: {"workout_data": {"type": "early_bird"}}})
)
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_early_bird_log() is True
class TestSaveEarlyBirdLog:
"""Tests for _save_early_bird_log method."""
def test_saves_early_bird_entry(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Saves an entry with type early_bird to the log file."""
log_file = tmp_path / "workout_log.json"
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with patch(
"screen_locker._log_mixin.compute_entry_hmac",
return_value=None,
): ):
locker._save_early_bird_log() assert locker._is_early_bird_pending() is False
assert log_file.exists() def test_today_valid_marker(
with log_file.open() as f: self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return True when today's marker is present and HMAC-valid."""
locker = create_locker(mock_tk, tmp_path)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
pending_file = tmp_path / "early_bird_pending.json"
pending_file.write_text(json.dumps({"date": today, "hmac": "sig"}))
with (
patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file),
patch("screen_locker._early_bird.verify_entry_hmac", return_value=True),
):
assert locker._is_early_bird_pending() is True
def test_unsigned_accepted_when_key_unavailable(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Unsigned marker is accepted when no HMAC key is configured."""
locker = create_locker(mock_tk, tmp_path)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
pending_file = tmp_path / "early_bird_pending.json"
pending_file.write_text(json.dumps({"date": today}))
with (
patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file),
patch("screen_locker._early_bird.verify_entry_hmac", return_value=False),
patch("screen_locker._early_bird.compute_entry_hmac", return_value=None),
):
assert locker._is_early_bird_pending() is True
class TestSaveEarlyBirdPending:
"""Tests for _save_early_bird_pending method."""
def test_saves_pending_marker(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Saves a date-stamped marker to the pending file, not workout_log.json."""
locker = create_locker(mock_tk, tmp_path)
pending_file = tmp_path / "early_bird_pending.json"
with (
patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file),
patch("screen_locker._early_bird.compute_entry_hmac", return_value=None),
):
locker._save_early_bird_pending()
assert pending_file.exists()
with pending_file.open() as f:
data: dict[str, Any] = json.load(f) data: dict[str, Any] = json.load(f)
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
assert data[today]["workout_data"]["type"] == "early_bird" assert data["date"] == today
assert not locker.log_file.exists()
def test_signs_when_hmac_key_available(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Includes an hmac field when a signature is computed."""
locker = create_locker(mock_tk, tmp_path)
pending_file = tmp_path / "early_bird_pending.json"
with (
patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", pending_file),
patch("screen_locker._early_bird.compute_entry_hmac", return_value="sig"),
):
locker._save_early_bird_pending()
data: dict[str, Any] = json.loads(pending_file.read_text())
assert data["hmac"] == "sig"
def test_os_error_on_save(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Warns and does not raise when writing the pending file fails."""
locker = create_locker(mock_tk, tmp_path)
mock_file = MagicMock()
mock_file.open.side_effect = OSError("disk full")
with (
patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", mock_file),
patch("screen_locker._early_bird.compute_entry_hmac", return_value=None),
):
locker._save_early_bird_pending()

View File

@ -99,30 +99,6 @@ class TestTryAutoUpgradeEarlyBird:
assert locker._try_auto_upgrade_early_bird() is False assert locker._try_auto_upgrade_early_bird() is False
class TestHasLoggedTodayEarlyBird:
"""Tests that has_logged_today returns False for early_bird entries."""
def test_early_bird_entry_not_counted_as_logged(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""early_bird entries must not satisfy has_logged_today."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps({today: {"workout_data": {"type": "early_bird"}}})
)
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
with patch(
"screen_locker._log_mixin.verify_entry_hmac",
return_value=True,
):
assert locker.has_logged_today() is False
class TestInitEarlyBirdFlow: class TestInitEarlyBirdFlow:
"""Integration tests for early bird branches in __init__.""" """Integration tests for early bird branches in __init__."""
@ -137,15 +113,15 @@ class TestInitEarlyBirdFlow:
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False), patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=True), patch.object(ScreenLocker, "_is_early_bird_time", return_value=True),
patch.object( patch.object(
ScreenLocker, ScreenLocker,
"_try_auto_upgrade_early_bird", "_try_auto_upgrade_early_bird",
return_value=False, return_value=False,
), ),
patch.object(ScreenLocker, "_save_early_bird_log") as mock_save, patch.object(ScreenLocker, "_save_early_bird_pending") as mock_save,
patch.object(ScreenLocker, "_start_phone_check"), patch.object(ScreenLocker, "_start_phone_check"),
patch.object(ScreenLocker, "_start_verify_workout_check"), patch.object(ScreenLocker, "_start_verify_workout_check"),
patch( patch(
@ -184,8 +160,8 @@ class TestInitEarlyBirdFlow:
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False), patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=True), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=True),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object( patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True ScreenLocker, "_try_auto_upgrade_early_bird", return_value=True

View File

@ -1,4 +1,4 @@
"""Tests for _extra_benefits module (streak, skip credits, EB extension).""" """Tests for _extra_benefits module (streak, shutdown bonus, EB extension)."""
from __future__ import annotations from __future__ import annotations
@ -10,11 +10,10 @@ from unittest.mock import MagicMock, patch
from screen_locker._extra_benefits import ( from screen_locker._extra_benefits import (
_load_state, _load_state,
_save_state, _save_state,
consume_skip_credit,
current_streak, current_streak,
has_extended_early_bird, has_extended_early_bird,
has_skip_credit,
process_week_transition, process_week_transition,
weekly_shutdown_bonus_hours,
) )
if TYPE_CHECKING: if TYPE_CHECKING:
@ -32,8 +31,8 @@ class TestLoadState:
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None: def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
"""Valid JSON file returns the parsed dict.""" """Valid JSON file returns the parsed dict."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3})) f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2026-W01": 3}}))
assert _load_state(f) == {"skip_credits": 3} assert _load_state(f) == {"weekly_shutdown_bonus_hours": {"2026-W01": 3}}
def test_returns_empty_on_oserror(self) -> None: def test_returns_empty_on_oserror(self) -> None:
"""OSError during read is caught and returns empty dict (lines 33-34).""" """OSError during read is caught and returns empty dict (lines 33-34)."""
@ -55,8 +54,10 @@ class TestSaveState:
def test_saves_state_to_file(self, tmp_path: Path) -> None: def test_saves_state_to_file(self, tmp_path: Path) -> None:
"""Valid path writes JSON content (lines 39-41).""" """Valid path writes JSON content (lines 39-41)."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
_save_state(f, {"skip_credits": 2}) _save_state(f, {"weekly_shutdown_bonus_hours": {"2026-W01": 2}})
assert json.loads(f.read_text())["skip_credits"] == 2 assert json.loads(f.read_text())["weekly_shutdown_bonus_hours"] == {
"2026-W01": 2
}
def test_logs_warning_on_oserror(self) -> None: def test_logs_warning_on_oserror(self) -> None:
"""OSError during write is caught as warning, does not raise (lines 42-43).""" """OSError during write is caught as warning, does not raise (lines 42-43)."""
@ -80,15 +81,21 @@ class TestProcessWeekTransition:
f.write_text(json.dumps({"last_processed_iso_week": f"{year}-W{week:02d}"})) f.write_text(json.dumps({"last_processed_iso_week": f"{year}-W{week:02d}"}))
assert process_week_transition(tmp_path / "log.json", f) == [] assert process_week_transition(tmp_path / "log.json", f) == []
def test_awards_credits_for_5plus_workouts(self, tmp_path: Path) -> None: @staticmethod
"""5+ workouts in previous week: streak += 1, skip_credits += extra (lines 87-96).""" def _current_week_str() -> str:
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
return f"{year}-W{week:02d}"
def test_awards_bonus_hours_for_5plus_workouts(self, tmp_path: Path) -> None:
"""5+ workouts in previous week: streak += 1, bonus hours += extra."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text( f.write_text(
json.dumps( json.dumps(
{ {
"last_processed_iso_week": self._PAST_WEEK, "last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0, "consecutive_5plus_weeks": 0,
"skip_credits": 0, "weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [], "extended_early_bird_iso_weeks": [],
} }
) )
@ -99,20 +106,20 @@ class TestProcessWeekTransition:
rewards = process_week_transition(tmp_path / "log.json", f) rewards = process_week_transition(tmp_path / "log.json", f)
assert len(rewards) >= 1 assert len(rewards) >= 1
assert "+2 skip credit" in rewards[0] assert "+2h shutdown bonus" in rewards[0]
state = json.loads(f.read_text()) state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 1 assert state["consecutive_5plus_weeks"] == 1
assert state["skip_credits"] == 2 # 6 4 assert state["weekly_shutdown_bonus_hours"][self._current_week_str()] == 2
def test_awards_milestone_bonus_at_4_week_streak(self, tmp_path: Path) -> None: def test_awards_milestone_bonus_at_4_week_streak(self, tmp_path: Path) -> None:
"""Streak reaches multiple of 4: +1 bonus skip credit (lines 97-99).""" """Streak reaches multiple of 4: +1h extra shutdown bonus."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text( f.write_text(
json.dumps( json.dumps(
{ {
"last_processed_iso_week": self._PAST_WEEK, "last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 3, "consecutive_5plus_weeks": 3,
"skip_credits": 0, "weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [], "extended_early_bird_iso_weeks": [],
} }
) )
@ -125,7 +132,7 @@ class TestProcessWeekTransition:
assert any("milestone" in r for r in rewards) assert any("milestone" in r for r in rewards)
state = json.loads(f.read_text()) state = json.loads(f.read_text())
assert state["consecutive_5plus_weeks"] == 4 assert state["consecutive_5plus_weeks"] == 4
assert state["skip_credits"] == 2 # 1 extra + 1 milestone assert state["weekly_shutdown_bonus_hours"][self._current_week_str()] == 2
def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None: def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None:
"""5+ workouts mark current ISO week as extended EB (line 91-92).""" """5+ workouts mark current ISO week as extended EB (line 91-92)."""
@ -156,7 +163,6 @@ class TestProcessWeekTransition:
{ {
"last_processed_iso_week": self._PAST_WEEK, "last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 2, "consecutive_5plus_weeks": 2,
"skip_credits": 3,
} }
) )
) )
@ -233,38 +239,28 @@ class TestCurrentStreak:
assert current_streak(f) == 5 assert current_streak(f) == 5
class TestHasSkipCredit: class TestWeeklyShutdownBonusHours:
"""Tests for has_skip_credit.""" """Tests for weekly_shutdown_bonus_hours."""
def test_returns_false_when_no_credits(self, tmp_path: Path) -> None: def test_returns_zero_when_missing(self, tmp_path: Path) -> None:
"""Zero credits → False.""" """No state file → 0."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0})) assert weekly_shutdown_bonus_hours(f) == 0
assert has_skip_credit(f) is False
def test_returns_true_when_credits_available(self, tmp_path: Path) -> None: def test_returns_current_week_bonus(self, tmp_path: Path) -> None:
"""Non-zero credits → True.""" """Returns the banked bonus for the current ISO week."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week = f"{year}-W{week:02d}"
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 2})) f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}}))
assert has_skip_credit(f) is True assert weekly_shutdown_bonus_hours(f) == 3
def test_ignores_other_weeks(self, tmp_path: Path) -> None:
class TestConsumeSkipCredit: """A bonus banked for a different ISO week is not returned."""
"""Tests for consume_skip_credit."""
def test_decrements_credit_count(self, tmp_path: Path) -> None:
"""Credits > 0: decrement by 1 (lines 129-133)."""
f = tmp_path / "state.json" f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3})) f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}}))
consume_skip_credit(f) assert weekly_shutdown_bonus_hours(f) == 0
assert json.loads(f.read_text())["skip_credits"] == 2
def test_does_nothing_when_no_credits(self, tmp_path: Path) -> None:
"""Credits == 0: no decrement (line 131 branch False)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0}))
consume_skip_credit(f)
assert json.loads(f.read_text())["skip_credits"] == 0
class TestHasExtendedEarlyBird: class TestHasExtendedEarlyBird:

View File

@ -86,7 +86,6 @@ class TestCheckNonVerifyExitsExtras:
), ),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=False), patch("screen_locker.screen_lock.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False),
patch("screen_locker.screen_lock.has_skip_credit", return_value=False),
patch("screen_locker.screen_lock.sys.exit"), patch("screen_locker.screen_lock.sys.exit"),
): ):
locker._check_non_verify_exits() locker._check_non_verify_exits()
@ -108,7 +107,7 @@ class TestCheckNonVerifyExitsExtras:
patch("screen_locker.screen_lock.reset_to_base_if_new_day"), patch("screen_locker.screen_lock.reset_to_base_if_new_day"),
patch( patch(
"screen_locker.screen_lock.process_week_transition", "screen_locker.screen_lock.process_week_transition",
return_value=["🎉 +1 skip credit for 5-workout week!"], 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.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=True), patch("screen_locker.screen_lock.has_weekly_minimum", return_value=True),
@ -116,40 +115,79 @@ class TestCheckNonVerifyExitsExtras:
): ):
locker._check_non_verify_exits() locker._check_non_verify_exits()
def test_uses_skip_credit_when_minimum_not_met( def test_applies_weekly_bonus_on_fresh_day_reset(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""has_skip_credit True + weekly min not met → consume credit and exit (251-254).""" """reset_to_base_if_new_day True → weekly shutdown bonus is applied once."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
object.__setattr__( object.__setattr__(
locker, locker,
"_scan_and_fill_week_runnerup", "_scan_and_fill_week_runnerup",
MagicMock(return_value=0), MagicMock(return_value=0),
) )
# Prevent time-dependent early-exit that would skip the skip-credit branch.
object.__setattr__( object.__setattr__(
locker, locker,
"_check_today_state_exits", "_adjust_shutdown_time_by",
MagicMock(return_value=False), MagicMock(return_value=True),
) )
mock_exit = MagicMock()
with ( with (
patch("screen_locker.screen_lock.reset_to_base_if_new_day"), patch(
"screen_locker.screen_lock.reset_to_base_if_new_day", return_value=True
),
patch( patch(
"screen_locker.screen_lock.process_week_transition", "screen_locker.screen_lock.process_week_transition",
return_value=[], return_value=[],
), ),
patch(
"screen_locker.screen_lock.weekly_shutdown_bonus_hours",
return_value=2,
),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=False), patch("screen_locker.screen_lock.is_relaxed_day", return_value=False),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=False), patch("screen_locker.screen_lock.has_weekly_minimum", return_value=True),
patch("screen_locker.screen_lock.has_skip_credit", return_value=True), patch("screen_locker.screen_lock.sys.exit"),
patch("screen_locker.screen_lock.consume_skip_credit"),
patch("screen_locker.screen_lock.sys.exit", mock_exit),
): ):
locker._check_non_verify_exits() locker._check_non_verify_exits()
mock_exit.assert_called_once_with(0) locker._adjust_shutdown_time_by.assert_called_once_with(2)
def test_no_weekly_bonus_applied_when_not_a_fresh_day(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""reset_to_base_if_new_day False (same-day restart) → bonus not re-applied."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=0),
)
object.__setattr__(
locker,
"_adjust_shutdown_time_by",
MagicMock(return_value=True),
)
with (
patch(
"screen_locker.screen_lock.reset_to_base_if_new_day", return_value=False
),
patch(
"screen_locker.screen_lock.process_week_transition",
return_value=[],
),
patch(
"screen_locker.screen_lock.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.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
locker._adjust_shutdown_time_by.assert_not_called()
class TestTryAutoUpgradeSickDayRunnerUp: class TestTryAutoUpgradeSickDayRunnerUp:

View File

@ -4,11 +4,11 @@ from __future__ import annotations
import json import json
from pathlib import Path from pathlib import Path
from types import SimpleNamespace
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch from unittest.mock import patch
from screen_locker._status import _load_extra_benefits, _load_log, run_status from screen_locker._status import _load_extra_benefits, _load_log, run_status
from screen_locker.tests.conftest import _make_locker
if TYPE_CHECKING: if TYPE_CHECKING:
import pytest import pytest
@ -88,24 +88,6 @@ class TestLoadExtraBenefits:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _make_locker(
log_file: Path,
*,
n_filled: int = 0,
bonus_applied: bool = False,
cfg: tuple | None = (22, 22, 5),
) -> SimpleNamespace:
"""Build a minimal locker-like namespace for run_status."""
locker = SimpleNamespace(
log_file=log_file,
workout_data={},
)
locker._scan_and_fill_week_runnerup = MagicMock(return_value=n_filled)
locker._adjust_shutdown_time_by = MagicMock(return_value=bonus_applied)
locker._read_shutdown_config = MagicMock(return_value=cfg)
return locker
class TestRunStatusNormal: class TestRunStatusNormal:
"""Tests for run_status display paths (no workouts in log).""" """Tests for run_status display paths (no workouts in log)."""
@ -128,6 +110,29 @@ class TestRunStatusNormal:
assert "No new workouts found" in out assert "No new workouts found" in out
assert "Need" in out assert "Need" in out
def test_sick_day_shown_when_no_log_entry(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""A date with no workout_log entry but in sick_history → shown as sick_day."""
from datetime import datetime, timezone
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
eb_file = tmp_path / "eb.json"
log_file = tmp_path / "log.json"
history_file = tmp_path / "sick_history.json"
history_file.write_text(json.dumps({"sick_days": [today]}))
locker = _make_locker(log_file, n_filled=0)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "sick_day" in out
def test_shutdown_config_printed( def test_shutdown_config_printed(
self, tmp_path: Path, capsys: pytest.CaptureFixture self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None: ) -> None:
@ -163,15 +168,15 @@ class TestRunStatusNormal:
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Shutdown tonight" not in out assert "Shutdown tonight" not in out
def test_skip_credits_and_streak_shown( def test_shutdown_bonus_and_streak_shown(
self, tmp_path: Path, capsys: pytest.CaptureFixture self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None: ) -> None:
"""skip_credits=3, streak=2, eb_ext=True → shown in output.""" """bonus_hours=3, streak=2, eb_ext=True → shown in output."""
eb_file = tmp_path / "eb.json" eb_file = tmp_path / "eb.json"
eb_file.write_text(json.dumps({"skip_credits": 3}))
locker = _make_locker(tmp_path / "log.json", n_filled=0) locker = _make_locker(tmp_path / "log.json", n_filled=0)
with ( with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file), patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.weekly_shutdown_bonus_hours", return_value=3),
patch("screen_locker._status.current_streak", return_value=2), patch("screen_locker._status.current_streak", return_value=2),
patch("screen_locker._status.has_extended_early_bird", return_value=True), patch("screen_locker._status.has_extended_early_bird", return_value=True),
patch("screen_locker._status.count_weekly_workouts", return_value=0), patch("screen_locker._status.count_weekly_workouts", return_value=0),
@ -179,7 +184,7 @@ class TestRunStatusNormal:
): ):
run_status(locker) run_status(locker)
out = capsys.readouterr().out out = capsys.readouterr().out
assert "Skip credits banked : 3" in out assert "Shutdown bonus (this wk): 3h" in out
assert "Streak (5+ wks) : 2" in out assert "Streak (5+ wks) : 2" in out
assert "Yes — until 09:00" in out assert "Yes — until 09:00" in out
@ -231,7 +236,7 @@ class TestRunStatusWorkoutLog:
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat() today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
log_file = tmp_path / "log.json" log_file = tmp_path / "log.json"
log_file.write_text( log_file.write_text(
json.dumps({today: {"workout_data": {"type": "early_bird", "source": ""}}}) json.dumps({today: {"workout_data": {"type": "heat_skip", "source": ""}}})
) )
eb_file = tmp_path / "eb.json" eb_file = tmp_path / "eb.json"
locker = _make_locker(log_file, n_filled=0) locker = _make_locker(log_file, n_filled=0)
@ -244,147 +249,4 @@ class TestRunStatusWorkoutLog:
): ):
run_status(locker) run_status(locker)
out = capsys.readouterr().out out = capsys.readouterr().out
assert "early_bird" in out assert "heat_skip" in out
class TestRunStatusFill:
"""Tests for RunnerUp scan paths in run_status."""
def test_fill_with_bonus_applied(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=2, bonus_applied=True)
# after_count=5 (> WEEKLY_WORKOUT_MINIMUM=4), before_count=3
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Auto-filled 2 workout(s)" in out
def test_fill_bonus_pending_when_adjust_fails(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled > 0, bonus > 0, adjust returns False → 'bonus pending' shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=2, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "bonus pending" in out
def test_fill_no_bonus_when_still_below_min(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled=1 but count still < 4 → no bonus line."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=3),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "shutdown bonus" not in out
class TestRunStatusMinimumStatus:
"""Tests for the 'remaining/extra/exactly met' summary lines."""
def test_extra_above_minimum(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""after_count > WEEKLY_WORKOUT_MINIMUM → 'above minimum' line.
n_filled=1 triggers the count_weekly_workouts() branch so after_count
is taken from that mock (5), not from the per-day log loop (0).
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "above minimum" in out
def test_exactly_at_minimum(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""after_count == WEEKLY_WORKOUT_MINIMUM → 'met exactly' line.
n_filled=1 so after_count = count_weekly_workouts() = 4 = WEEKLY_WORKOUT_MINIMUM.
bonus = max(0, 4 - max(4, 0)) = 0, so no bonus line is printed.
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=4),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Weekly minimum met exactly" in out
def test_sys_exit_called(self, tmp_path: Path) -> None:
"""run_status always calls sys.exit(0)."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=0)
mock_exit = MagicMock()
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit", mock_exit),
):
run_status(locker)
mock_exit.assert_called_once_with(0)
def test_loop_breaks_on_future_day(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Pin today to Monday so the loop hits d > today on day 2, covering line 64."""
from datetime import datetime, timezone
fake_now = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
class _FakeDatetime(datetime):
@classmethod
def now(cls, tz=None): # type: ignore[override]
return fake_now.astimezone(tz) if tz else fake_now
with (
patch("screen_locker._status.datetime", _FakeDatetime),
patch("screen_locker._status.EXTRA_BENEFITS_FILE", tmp_path / "eb.json"),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit"),
):
run_status(_make_locker(tmp_path / "log.json", n_filled=0))
out = capsys.readouterr().out
assert "Mon Jun 22" in out
assert "Tue Jun 23" not in out

View File

@ -0,0 +1,160 @@
"""Tests for screen_locker._status.run_status() -- RunnerUp fill + minimum summary.
Split from test_status.py to stay under the repo's 400-line file limit.
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from screen_locker._status import run_status
from screen_locker.tests.conftest import _make_locker
if TYPE_CHECKING:
from pathlib import Path
import pytest
class TestRunStatusFill:
"""Tests for RunnerUp scan paths in run_status."""
def test_fill_with_bonus_applied(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled > 0, bonus > 0, adjust succeeds → bonus line shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=2, bonus_applied=True)
# after_count=5 (> WEEKLY_WORKOUT_MINIMUM=4), before_count=3
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Auto-filled 2 workout(s)" in out
def test_fill_bonus_pending_when_adjust_fails(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled > 0, bonus > 0, adjust returns False → 'bonus pending' shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=2, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "bonus pending" in out
def test_fill_no_bonus_when_still_below_min(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""n_filled=1 but count still < 4 → no bonus line."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=3),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "shutdown bonus" not in out
class TestRunStatusMinimumStatus:
"""Tests for the 'remaining/extra/exactly met' summary lines."""
def test_extra_above_minimum(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""after_count > WEEKLY_WORKOUT_MINIMUM → 'above minimum' line.
n_filled=1 triggers the count_weekly_workouts() branch so after_count
is taken from that mock (5), not from the per-day log loop (0).
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=5),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "above minimum" in out
def test_exactly_at_minimum(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""after_count == WEEKLY_WORKOUT_MINIMUM → 'met exactly' line.
n_filled=1 so after_count = count_weekly_workouts() = 4 = WEEKLY_WORKOUT_MINIMUM.
bonus = max(0, 4 - max(4, 0)) = 0, so no bonus line is printed.
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=1, bonus_applied=False)
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=4),
patch("sys.exit"),
):
run_status(locker)
out = capsys.readouterr().out
assert "Weekly minimum met exactly" in out
def test_sys_exit_called(self, tmp_path: Path) -> None:
"""run_status always calls sys.exit(0)."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=0)
mock_exit = MagicMock()
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit", mock_exit),
):
run_status(locker)
mock_exit.assert_called_once_with(0)
def test_loop_breaks_on_future_day(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
"""Pin today to Monday so the loop hits d > today on day 2, covering line 64."""
from datetime import datetime, timezone
fake_now = datetime(2026, 6, 22, 12, 0, tzinfo=timezone.utc)
class _FakeDatetime(datetime):
@classmethod
def now(cls, tz=None): # type: ignore[override]
return fake_now.astimezone(tz) if tz else fake_now
with (
patch("screen_locker._status.datetime", _FakeDatetime),
patch("screen_locker._status.EXTRA_BENEFITS_FILE", tmp_path / "eb.json"),
patch("screen_locker._status.current_streak", return_value=0),
patch("screen_locker._status.has_extended_early_bird", return_value=False),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
patch("sys.exit"),
):
run_status(_make_locker(tmp_path / "log.json", n_filled=0))
out = capsys.readouterr().out
assert "Mon Jun 22" in out
assert "Tue Jun 23" not in out

View File

@ -15,45 +15,24 @@ if TYPE_CHECKING:
from pathlib import Path from pathlib import Path
class TestIsSickDayLog: class TestIsSickDayToday:
"""Tests for _is_sick_day_log method.""" """Tests for _is_sick_day_today method.
def test_no_log_file( sick_day is tracked in sick_history.json (via _sick_tracker.py) as the
sole source of truth -- not in workout_log.json. The autouse
_isolate_sick_history fixture redirects SICK_HISTORY_FILE to
tmp_path/sick_history.json for every test.
"""
def test_no_history_file(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when log file does not exist.""" """Return False when sick_history.json does not exist."""
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = tmp_path / "workout_log.json" assert locker._is_sick_day_today() is False
assert locker._is_sick_day_log() is False
def test_invalid_json(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when log file contains invalid JSON."""
log_file = tmp_path / "workout_log.json"
log_file.write_text("{bad json}")
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is False
def test_no_entry_today(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when no entry exists for today."""
log_file = tmp_path / "workout_log.json"
log_file.write_text(json.dumps({"2020-01-01": {}}))
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is False
def test_today_not_sick_day( def test_today_not_sick_day(
self, self,
@ -61,19 +40,11 @@ class TestIsSickDayLog:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return False when today's entry is a regular workout.""" """Return False when today is not in sick_history's sick_days list."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps(
{
today: {"workout_data": {"type": "phone_verified"}},
}
)
)
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file history_file = tmp_path / "sick_history.json"
assert locker._is_sick_day_log() is False history_file.write_text(json.dumps({"sick_days": ["2020-01-01"]}))
assert locker._is_sick_day_today() is False
def test_today_is_sick_day( def test_today_is_sick_day(
self, self,
@ -81,33 +52,12 @@ class TestIsSickDayLog:
mock_sys_exit: MagicMock, mock_sys_exit: MagicMock,
tmp_path: Path, tmp_path: Path,
) -> None: ) -> None:
"""Return True when today's entry is a sick day.""" """Return True when today is in sick_history's sick_days list."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(
json.dumps(
{
today: {"workout_data": {"type": "sick_day"}},
}
)
)
locker = create_locker(mock_tk, tmp_path) locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is True
def test_entry_missing_workout_data(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when entry has no workout_data key."""
log_file = tmp_path / "workout_log.json"
today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d") today = datetime.now(tz=timezone.utc).strftime("%Y-%m-%d")
log_file.write_text(json.dumps({today: {}})) history_file = tmp_path / "sick_history.json"
locker = create_locker(mock_tk, tmp_path) history_file.write_text(json.dumps({"sick_days": [today]}))
locker.log_file = log_file assert locker._is_sick_day_today() is True
assert locker._is_sick_day_log() is False
class TestVerifyOnlyInit: class TestVerifyOnlyInit:

View File

@ -36,8 +36,8 @@ class TestRelaxedDayBranch:
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False), patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object( patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
@ -68,8 +68,8 @@ class TestRelaxedDayBranch:
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False), patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object( patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False
@ -102,8 +102,8 @@ class TestRelaxedDayBranch:
with ( with (
patch.object(Path, "resolve", return_value=tmp_path), patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False), patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False), patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False), patch.object(ScreenLocker, "_is_early_bird_pending", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False), patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object( patch.object(
ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False ScreenLocker, "_try_auto_upgrade_early_bird", return_value=False

View File

@ -35,7 +35,7 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=True), patch.object(locker, "_is_early_bird_pending", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False), patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True), patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True),
): ):
@ -50,7 +50,7 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=True), patch.object(locker, "_is_early_bird_pending", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False), patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False), patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False),
): ):
@ -65,7 +65,7 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=True), patch.object(locker, "_is_early_bird_pending", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=True), patch.object(locker, "_is_early_bird_time", return_value=True),
): ):
result = locker._check_today_state_exits() result = locker._check_today_state_exits()
@ -79,13 +79,33 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=False), patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=True), patch.object(locker, "_is_sick_day_today", return_value=True),
patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True), patch.object(locker, "_try_auto_upgrade_sick_day", return_value=True),
): ):
result = locker._check_today_state_exits() result = locker._check_today_state_exits()
assert result is True assert result is True
def test_sick_day_no_upgrade_still_returns_true(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""A sick day already marked today halts startup even when no real
workout is found to upgrade it - sick_day no longer lives in
workout_log.json, so this halt must be explicit (see
_auto_upgrade.py's _check_today_state_exits), not an accidental
side effect of has_logged_today() catching a leftover log entry."""
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_today", return_value=True),
patch.object(locker, "_try_auto_upgrade_sick_day", return_value=False),
):
result = locker._check_today_state_exits()
assert result is True
def test_workout_skip_today_returns_true( def test_workout_skip_today_returns_true(
self, self,
mock_tk: MagicMock, mock_tk: MagicMock,
@ -94,8 +114,8 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=False), patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False), patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False), patch.object(locker, "has_logged_today", return_value=False),
patch( patch(
"screen_locker._auto_upgrade.has_workout_skip_today", "screen_locker._auto_upgrade.has_workout_skip_today",
@ -113,15 +133,15 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=False), patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False), patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False), patch.object(locker, "has_logged_today", return_value=False),
patch( patch(
"screen_locker._auto_upgrade.has_workout_skip_today", "screen_locker._auto_upgrade.has_workout_skip_today",
return_value=False, return_value=False,
), ),
patch.object(locker, "_is_early_bird_time", return_value=True), patch.object(locker, "_is_early_bird_time", return_value=True),
patch.object(locker, "_save_early_bird_log"), patch.object(locker, "_save_early_bird_pending"),
): ):
result = locker._check_today_state_exits() result = locker._check_today_state_exits()
assert result is True assert result is True
@ -134,8 +154,8 @@ class TestCheckTodayStateExits:
) -> None: ) -> None:
locker = self._make_locker(mock_tk, tmp_path) locker = self._make_locker(mock_tk, tmp_path)
with ( with (
patch.object(locker, "_is_early_bird_log", return_value=False), patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False), patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False), patch.object(locker, "has_logged_today", return_value=False),
patch( patch(
"screen_locker._auto_upgrade.has_workout_skip_today", "screen_locker._auto_upgrade.has_workout_skip_today",

View File

@ -80,15 +80,6 @@
}, },
"hmac": "f05ea3f7a5bd754d06e76001e8641628644145ea49f2ace7ed28ba1802428d95" "hmac": "f05ea3f7a5bd754d06e76001e8641628644145ea49f2ace7ed28ba1802428d95"
}, },
"2026-06-21": {
"timestamp": "2026-06-21T15:59:26.865946+00:00",
"workout_data": {
"type": "sick_day",
"note": "Sick day - shutdown moved earlier",
"debt": "1"
},
"hmac": "abf3ff2d0a362d7788034f8c4a97c5f47a1f1a1505c9c5a6cb593fb983b9f2c8"
},
"2026-06-22": { "2026-06-22": {
"timestamp": "2026-06-22T07:58:28.232279+00:00", "timestamp": "2026-06-22T07:58:28.232279+00:00",
"workout_data": { "workout_data": {
@ -135,13 +126,6 @@
}, },
"hmac": "0e6c6dde4185ca0980ff9d5fdf5e20734a7f86724b0b355e57a6cd6df5b7ace2" "hmac": "0e6c6dde4185ca0980ff9d5fdf5e20734a7f86724b0b355e57a6cd6df5b7ace2"
}, },
"2026-06-28": {
"timestamp": "2026-06-28T05:01:28.249360+00:00",
"workout_data": {
"type": "early_bird"
},
"hmac": "f6400e7af861ca8a157e623eafd490f87df723f536f6eb9f4e1acd353d7106c2"
},
"2026-06-29": { "2026-06-29": {
"timestamp": "2026-06-29T09:21:58.110418+00:00", "timestamp": "2026-06-29T09:21:58.110418+00:00",
"workout_data": { "workout_data": {