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 datetime import datetime, timezone
import json
import logging
import sys
from screen_locker import _sick_tracker
from screen_locker._wake_state import has_workout_skip_today
_logger = logging.getLogger(__name__)
@ -19,25 +24,14 @@ class AutoUpgradeMixin:
RunnerUpVerificationMixin, LogMixin, and ShutdownMixin via MRO.
"""
def _is_sick_day_log(self) -> bool:
"""Check if today's workout log is a sick day (not yet verified)."""
if not self.log_file.exists(): # type: ignore[attr-defined]
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 _is_sick_day_today(self) -> bool:
"""Check if today is marked as a sick day in sick_history.json."""
return _sick_tracker.is_sick_day(_sick_tracker.load_history())
def _check_early_exits(self, *, verify_only: bool) -> None:
"""Check startup conditions and exit early when appropriate."""
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.")
sys.exit(0)
return
@ -46,7 +40,7 @@ class AutoUpgradeMixin:
def _check_today_state_exits(self) -> bool:
"""Handle early-bird and today's log states. Return True to stop startup."""
if (
self._is_early_bird_log() # type: ignore[attr-defined]
self._is_early_bird_pending() # type: ignore[attr-defined]
and not self._is_early_bird_time() # type: ignore[attr-defined]
):
if self._try_auto_upgrade_early_bird():
@ -54,16 +48,19 @@ class AutoUpgradeMixin:
sys.exit(0)
return True
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.")
elif self._is_sick_day_log() and self._try_auto_upgrade_sick_day():
elif self._is_sick_day_today():
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]
_logger.info("Workout already logged today. Skipping screen lock.")
elif has_workout_skip_today():
_logger.info("Wake alarm earned workout skip. Skipping screen lock.")
elif self._is_early_bird_time(): # 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.")
else:
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"
# State file storing the base (pre-bonus) shutdown hours and last reset date.
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).

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
@ -6,9 +13,12 @@ from datetime import datetime, timezone
import json
import logging
from gatelock.log_integrity import compute_entry_hmac, verify_entry_hmac
from screen_locker._constants import (
EARLY_BIRD_END_HOUR,
EARLY_BIRD_END_MINUTE,
EARLY_BIRD_PENDING_FILE,
EARLY_BIRD_START_HOUR,
EXTRA_BENEFITS_FILE,
)
@ -17,8 +27,13 @@ from screen_locker._extra_benefits import has_extended_early_bird
_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:
"""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:
"""Return current local time as minutes from midnight."""
@ -40,22 +55,35 @@ class EarlyBirdMixin:
end = EARLY_BIRD_END_HOUR * 60 + EARLY_BIRD_END_MINUTE
return start <= minutes < end
def _is_early_bird_log(self) -> bool:
"""Check if today's workout log entry is an early_bird provisional entry."""
if not self.log_file.exists():
def _is_early_bird_pending(self) -> bool:
"""Check if today has an unresolved early-bird pending marker."""
if not EARLY_BIRD_PENDING_FILE.exists():
return False
try:
with self.log_file.open() as f:
logs = json.load(f)
with EARLY_BIRD_PENDING_FILE.open() as f:
state = 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:
if not isinstance(state, dict) or state.get("date") != _today_str():
return False
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
return entry.get("workout_data", {}).get("type") == "early_bird"
def _save_early_bird_log(self) -> None:
"""Save an early_bird provisional entry to the workout log."""
self.workout_data = {"type": "early_bird"}
self.save_workout_log()
def _save_early_bird_pending(self) -> None:
"""Save today's early-bird pending marker (self-expires tomorrow)."""
state: dict[str, object] = {"date": _today_str()}
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:
- 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.
State is persisted in ``extra_benefits_state.json`` next to this file.
@ -22,7 +25,7 @@ if TYPE_CHECKING:
_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
@ -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)
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]:
"""Process last week's results if we've entered a new ISO week.
Counts workouts from the previous ISO week. If count >= 5:
- 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).
- 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).
"""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week_str = f"{year}-W{week:02d}"
current_week_str = _current_iso_week(now)
state = _load_state(state_file)
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)
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", []))
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:
extra = prev_week_count - 4
streak += 1
skip_credits += extra
bonus_hours = extra
if current_week_str not in eb_weeks:
eb_weeks.append(current_week_str)
rewards.append(
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:
skip_credits += 1
rewards.append(f"{streak}-week streak milestone! +1 bonus skip credit")
bonus_hours += 1
rewards.append(f"{streak}-week streak milestone! +1h extra shutdown bonus")
weekly_bonus_hours[current_week_str] = bonus_hours
else:
if streak > 0:
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,
"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,
},
)
@ -122,25 +134,22 @@ def current_streak(state_file: Path) -> int:
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
def has_skip_credit(state_file: Path) -> bool:
"""Return True if at least one banked skip credit is available."""
return int(_load_state(state_file).get("skip_credits", 0)) > 0
def consume_skip_credit(state_file: Path) -> None:
"""Deduct one skip credit from the bank."""
state = _load_state(state_file)
credit_count = int(state.get("skip_credits", 0))
if credit_count > 0:
state["skip_credits"] = credit_count - 1
_save_state(state_file, state)
def weekly_shutdown_bonus_hours(
state_file: Path, *, today: datetime | None = None
) -> 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()
current_week_str = _current_iso_week(now)
bonus_hours: dict[str, int] = _load_state(state_file).get(
"weekly_shutdown_bonus_hours", {}
)
return int(bonus_hours.get(current_week_str, 0))
def has_extended_early_bird(state_file: Path) -> bool:
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
now = datetime.now(tz=timezone.utc).astimezone()
year, week, _ = now.isocalendar()
current_week_str = f"{year}-W{week:02d}"
current_week_str = _current_iso_week(now)
eb_weeks: list[str] = _load_state(state_file).get(
"extended_early_bird_iso_weeks", []
)

View File

@ -31,10 +31,10 @@ class LogMixin:
if entry is None:
return False
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:
_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")
return False

View File

@ -93,6 +93,12 @@ def save_history(history: SickHistory) -> bool:
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(
history: SickHistory,
days: int,

View File

@ -2,7 +2,7 @@
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
import json
import sys
from typing import TYPE_CHECKING
@ -11,7 +11,9 @@ from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
weekly_shutdown_bonus_hours,
)
from screen_locker._sick_tracker import load_history
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
WEEKLY_WORKOUT_MINIMUM,
@ -45,12 +47,31 @@ def _load_extra_benefits() -> dict:
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:
"""Print weekly workout status, run RunnerUp scan, apply bonus, then exit."""
today = datetime.now(tz=timezone.utc).astimezone().date()
monday = today - timedelta(days=today.weekday())
log_file: Path = locker.log_file # type: ignore[attr-defined]
log_data = _load_log(log_file)
sick_days = set(load_history().sick_days)
print("=== Weekly Workout Status ===")
@ -60,18 +81,7 @@ def run_status(locker: ScreenLocker) -> None:
d = monday + timedelta(days=i)
if d > today:
break
dstr = d.isoformat()
entry = log_data.get(dstr)
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:
if _print_day_line(d, log_data.get(d.isoformat()), sick_days):
before_count += 1
print()
@ -95,16 +105,13 @@ def run_status(locker: ScreenLocker) -> None:
print()
# Extra benefits summary
state = _load_extra_benefits()
credits = state.get("skip_credits", 0)
bonus_hours = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
streak = current_streak(EXTRA_BENEFITS_FILE)
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
eb_str = "Yes — until 09:00" if eb_ext else "No"
# Heat skips this month
from datetime import date
this_month = date.today().strftime("%Y-%m")
this_month = datetime.now(tz=timezone.utc).astimezone().date().strftime("%Y-%m")
heat_entries = [
(d, e)
for d, e in log_data.items()
@ -118,7 +125,7 @@ def run_status(locker: ScreenLocker) -> None:
else:
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" Early-bird extended : {eb_str}")
print(f" Heat skips (month) : {heat_str}")

View File

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

View File

@ -35,10 +35,9 @@ from screen_locker._constants import (
)
from screen_locker._early_bird import EarlyBirdMixin
from screen_locker._extra_benefits import (
consume_skip_credit,
current_streak,
has_skip_credit,
process_week_transition,
weekly_shutdown_bonus_hours,
)
from screen_locker._heat_skip import HeatSkipMixin
from screen_locker._log_mixin import LogMixin
@ -172,11 +171,17 @@ class ScreenLocker(
_logger.info("Today is a scheduled skip day. Skipping screen lock.")
sys.exit(0)
return
# Reset shutdown config to base (21:00) at the start of each new day
# so workout bonuses always layer on top of a known floor.
reset_to_base_if_new_day(
# 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()
# 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.).
@ -191,9 +196,6 @@ class ScreenLocker(
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)
# 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():
return
# Day-of-week routing: Tue/Wed/Thu relaxed (optional), Fri-Mon enforced.
@ -209,8 +211,11 @@ class ScreenLocker(
)
sys.exit(0)
return
# Offer heat skip before consuming a banked credit — credit is preserved
# for another day if the user chooses to skip due to temperature.
# 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).
hot_temp = is_too_hot(HEAT_SKIP_CITY, HEAT_SKIP_TEMP_THRESHOLD)
if hot_temp is not None:
_logger.info(
@ -222,12 +227,12 @@ class ScreenLocker(
_logger.info("User skipped workout due to heat (%.0f°C).", hot_temp)
sys.exit(0)
return
# Spend a banked skip credit if the minimum hasn't been reached yet.
if has_skip_credit(EXTRA_BENEFITS_FILE):
consume_skip_credit(EXTRA_BENEFITS_FILE)
_logger.info("Used a banked skip credit — no lock today.")
sys.exit(0)
return
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 _try_adjust_shutdown_for_workout(self) -> bool:
"""Try to adjust shutdown time later for actual workouts."""
@ -256,6 +261,9 @@ class ScreenLocker(
def unlock_screen(self) -> None:
"""Save workout log and display success message."""
# 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()
new_debt = self._clear_debt_on_verified_workout()

View File

@ -1,5 +1,5 @@
{
"base_mon_wed_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 contextlib import ExitStack
from datetime import datetime, timezone
import json
from pathlib import Path
import tkinter as tk
from types import SimpleNamespace
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -130,6 +133,74 @@ def _isolate_sick_history(tmp_path: Path) -> Iterator[None]:
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)
def _isolate_scheduled_skips(tmp_path: Path) -> Iterator[None]:
"""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"
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(
_mock_tk: MagicMock,
tmp_path: Path,
@ -218,10 +307,10 @@ def create_locker(
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(
ScreenLocker,
"_is_sick_day_log",
"_is_sick_day_today",
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,
@ -255,8 +344,8 @@ def create_locker_relaxed_day(
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_today", 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, "_try_auto_upgrade_early_bird", return_value=False),
patch("screen_locker.screen_lock.is_relaxed_day", return_value=True),
@ -294,9 +383,9 @@ def create_locker_early_bird(
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=has_logged),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
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(
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
class TestIsEarlyBirdLog:
"""Tests for _is_early_bird_log method."""
class TestIsEarlyBirdPending:
"""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,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> 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.log_file = tmp_path / "workout_log.json"
assert locker._is_early_bird_log() is False
assert locker._is_early_bird_pending() is False
def test_invalid_json(
self,
@ -169,12 +173,12 @@ class TestIsEarlyBirdLog:
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}")
"""Return False when the pending file contains invalid JSON."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_early_bird_log() is False
pending_file = tmp_path / "early_bird_pending.json"
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(
self,
@ -182,81 +186,136 @@ class TestIsEarlyBirdLog:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> 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)
mock_file = MagicMock()
mock_file.exists.return_value = True
mock_file.open.side_effect = OSError("permission denied")
locker.log_file = mock_file
assert locker._is_early_bird_log() is False
with patch("screen_locker._early_bird.EARLY_BIRD_PENDING_FILE", mock_file):
assert locker._is_early_bird_pending() is False
def test_no_entry_today(
def test_stale_date(
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": {}}))
"""Return False when the marker is from a previous day."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_early_bird_log() is False
pending_file = tmp_path / "early_bird_pending.json"
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,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when today's entry is phone_verified."""
log_file = tmp_path / "workout_log.json"
"""Return False when HMAC verification fails."""
locker = create_locker(mock_tk, tmp_path)
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.log_file = log_file
assert locker._is_early_bird_log() is False
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,
pending_file = tmp_path / "early_bird_pending.json"
pending_file.write_text(json.dumps({"date": today, "hmac": "bad"}))
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="sig"),
):
locker._save_early_bird_log()
assert locker._is_early_bird_pending() is False
assert log_file.exists()
with log_file.open() as f:
def test_today_valid_marker(
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)
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
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:
"""Integration tests for early bird branches in __init__."""
@ -137,15 +113,15 @@ class TestInitEarlyBirdFlow:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_today", 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,
"_try_auto_upgrade_early_bird",
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_verify_workout_check"),
patch(
@ -184,8 +160,8 @@ class TestInitEarlyBirdFlow:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=True),
patch.object(ScreenLocker, "_is_sick_day_today", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_pending", return_value=True),
patch.object(ScreenLocker, "_is_early_bird_time", return_value=False),
patch.object(
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
@ -10,11 +10,10 @@ from unittest.mock import MagicMock, patch
from screen_locker._extra_benefits import (
_load_state,
_save_state,
consume_skip_credit,
current_streak,
has_extended_early_bird,
has_skip_credit,
process_week_transition,
weekly_shutdown_bonus_hours,
)
if TYPE_CHECKING:
@ -32,8 +31,8 @@ class TestLoadState:
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
"""Valid JSON file returns the parsed dict."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3}))
assert _load_state(f) == {"skip_credits": 3}
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2026-W01": 3}}))
assert _load_state(f) == {"weekly_shutdown_bonus_hours": {"2026-W01": 3}}
def test_returns_empty_on_oserror(self) -> None:
"""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:
"""Valid path writes JSON content (lines 39-41)."""
f = tmp_path / "state.json"
_save_state(f, {"skip_credits": 2})
assert json.loads(f.read_text())["skip_credits"] == 2
_save_state(f, {"weekly_shutdown_bonus_hours": {"2026-W01": 2}})
assert json.loads(f.read_text())["weekly_shutdown_bonus_hours"] == {
"2026-W01": 2
}
def test_logs_warning_on_oserror(self) -> None:
"""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}"}))
assert process_week_transition(tmp_path / "log.json", f) == []
def test_awards_credits_for_5plus_workouts(self, tmp_path: Path) -> None:
"""5+ workouts in previous week: streak += 1, skip_credits += extra (lines 87-96)."""
@staticmethod
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.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 0,
"skip_credits": 0,
"weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [],
}
)
@ -99,20 +106,20 @@ class TestProcessWeekTransition:
rewards = process_week_transition(tmp_path / "log.json", f)
assert len(rewards) >= 1
assert "+2 skip credit" in rewards[0]
assert "+2h shutdown bonus" in rewards[0]
state = json.loads(f.read_text())
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:
"""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.write_text(
json.dumps(
{
"last_processed_iso_week": self._PAST_WEEK,
"consecutive_5plus_weeks": 3,
"skip_credits": 0,
"weekly_shutdown_bonus_hours": {},
"extended_early_bird_iso_weeks": [],
}
)
@ -125,7 +132,7 @@ class TestProcessWeekTransition:
assert any("milestone" in r for r in rewards)
state = json.loads(f.read_text())
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:
"""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,
"consecutive_5plus_weeks": 2,
"skip_credits": 3,
}
)
)
@ -233,38 +239,28 @@ class TestCurrentStreak:
assert current_streak(f) == 5
class TestHasSkipCredit:
"""Tests for has_skip_credit."""
class TestWeeklyShutdownBonusHours:
"""Tests for weekly_shutdown_bonus_hours."""
def test_returns_false_when_no_credits(self, tmp_path: Path) -> None:
"""Zero credits → False."""
def test_returns_zero_when_missing(self, tmp_path: Path) -> None:
"""No state file → 0."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 0}))
assert has_skip_credit(f) is False
assert weekly_shutdown_bonus_hours(f) == 0
def test_returns_true_when_credits_available(self, tmp_path: Path) -> None:
"""Non-zero credits → True."""
def test_returns_current_week_bonus(self, tmp_path: Path) -> None:
"""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.write_text(json.dumps({"skip_credits": 2}))
assert has_skip_credit(f) is True
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}}))
assert weekly_shutdown_bonus_hours(f) == 3
class TestConsumeSkipCredit:
"""Tests for consume_skip_credit."""
def test_decrements_credit_count(self, tmp_path: Path) -> None:
"""Credits > 0: decrement by 1 (lines 129-133)."""
def test_ignores_other_weeks(self, tmp_path: Path) -> None:
"""A bonus banked for a different ISO week is not returned."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"skip_credits": 3}))
consume_skip_credit(f)
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
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}}))
assert weekly_shutdown_bonus_hours(f) == 0
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.has_weekly_minimum", return_value=False),
patch("screen_locker.screen_lock.has_skip_credit", return_value=False),
patch("screen_locker.screen_lock.sys.exit"),
):
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.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.has_weekly_minimum", return_value=True),
@ -116,40 +115,79 @@ class TestCheckNonVerifyExitsExtras:
):
locker._check_non_verify_exits()
def test_uses_skip_credit_when_minimum_not_met(
def test_applies_weekly_bonus_on_fresh_day_reset(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> 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)
object.__setattr__(
locker,
"_scan_and_fill_week_runnerup",
MagicMock(return_value=0),
)
# Prevent time-dependent early-exit that would skip the skip-credit branch.
object.__setattr__(
locker,
"_check_today_state_exits",
MagicMock(return_value=False),
"_adjust_shutdown_time_by",
MagicMock(return_value=True),
)
mock_exit = MagicMock()
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(
"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=False),
patch("screen_locker.screen_lock.has_skip_credit", return_value=True),
patch("screen_locker.screen_lock.consume_skip_credit"),
patch("screen_locker.screen_lock.sys.exit", mock_exit),
patch("screen_locker.screen_lock.has_weekly_minimum", return_value=True),
patch("screen_locker.screen_lock.sys.exit"),
):
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:

View File

@ -4,11 +4,11 @@ from __future__ import annotations
import json
from pathlib import Path
from types import SimpleNamespace
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.tests.conftest import _make_locker
if TYPE_CHECKING:
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:
"""Tests for run_status display paths (no workouts in log)."""
@ -128,6 +110,29 @@ class TestRunStatusNormal:
assert "No new workouts found" 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(
self, tmp_path: Path, capsys: pytest.CaptureFixture
) -> None:
@ -163,15 +168,15 @@ class TestRunStatusNormal:
out = capsys.readouterr().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
) -> 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.write_text(json.dumps({"skip_credits": 3}))
locker = _make_locker(tmp_path / "log.json", n_filled=0)
with (
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.has_extended_early_bird", return_value=True),
patch("screen_locker._status.count_weekly_workouts", return_value=0),
@ -179,7 +184,7 @@ class TestRunStatusNormal:
):
run_status(locker)
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 "Yes — until 09:00" in out
@ -231,7 +236,7 @@ class TestRunStatusWorkoutLog:
today = datetime.now(tz=timezone.utc).astimezone().date().isoformat()
log_file = tmp_path / "log.json"
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"
locker = _make_locker(log_file, n_filled=0)
@ -244,147 +249,4 @@ class TestRunStatusWorkoutLog:
):
run_status(locker)
out = capsys.readouterr().out
assert "early_bird" 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
assert "heat_skip" 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
class TestIsSickDayLog:
"""Tests for _is_sick_day_log method."""
class TestIsSickDayToday:
"""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,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> 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.log_file = tmp_path / "workout_log.json"
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
assert locker._is_sick_day_today() is False
def test_today_not_sick_day(
self,
@ -61,19 +40,11 @@ class TestIsSickDayLog:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return False when today's entry is a regular workout."""
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"}},
}
)
)
"""Return False when today is not in sick_history's sick_days list."""
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is False
history_file = tmp_path / "sick_history.json"
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(
self,
@ -81,33 +52,12 @@ class TestIsSickDayLog:
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Return True when today's entry is a sick day."""
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"}},
}
)
)
"""Return True when today is in sick_history's sick_days list."""
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")
log_file.write_text(json.dumps({today: {}}))
locker = create_locker(mock_tk, tmp_path)
locker.log_file = log_file
assert locker._is_sick_day_log() is False
history_file = tmp_path / "sick_history.json"
history_file.write_text(json.dumps({"sick_days": [today]}))
assert locker._is_sick_day_today() is True
class TestVerifyOnlyInit:

View File

@ -36,8 +36,8 @@ class TestRelaxedDayBranch:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_today", 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, "_try_auto_upgrade_early_bird", return_value=False
@ -68,8 +68,8 @@ class TestRelaxedDayBranch:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_today", 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, "_try_auto_upgrade_early_bird", return_value=False
@ -102,8 +102,8 @@ class TestRelaxedDayBranch:
with (
patch.object(Path, "resolve", return_value=tmp_path),
patch.object(ScreenLocker, "has_logged_today", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_log", return_value=False),
patch.object(ScreenLocker, "_is_early_bird_log", return_value=False),
patch.object(ScreenLocker, "_is_sick_day_today", 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, "_try_auto_upgrade_early_bird", return_value=False

View File

@ -35,7 +35,7 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_pending", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=True),
):
@ -50,7 +50,7 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_pending", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=False),
patch.object(locker, "_try_auto_upgrade_early_bird", return_value=False),
):
@ -65,7 +65,7 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=True),
patch.object(locker, "_is_early_bird_pending", return_value=True),
patch.object(locker, "_is_early_bird_time", return_value=True),
):
result = locker._check_today_state_exits()
@ -79,13 +79,33 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=True),
patch.object(locker, "_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=True),
):
result = locker._check_today_state_exits()
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(
self,
mock_tk: MagicMock,
@ -94,8 +114,8 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker._auto_upgrade.has_workout_skip_today",
@ -113,15 +133,15 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker._auto_upgrade.has_workout_skip_today",
return_value=False,
),
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()
assert result is True
@ -134,8 +154,8 @@ class TestCheckTodayStateExits:
) -> None:
locker = self._make_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_is_early_bird_log", return_value=False),
patch.object(locker, "_is_sick_day_log", return_value=False),
patch.object(locker, "_is_early_bird_pending", return_value=False),
patch.object(locker, "_is_sick_day_today", return_value=False),
patch.object(locker, "has_logged_today", return_value=False),
patch(
"screen_locker._auto_upgrade.has_workout_skip_today",

View File

@ -80,15 +80,6 @@
},
"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": {
"timestamp": "2026-06-22T07:58:28.232279+00:00",
"workout_data": {
@ -135,13 +126,6 @@
},
"hmac": "0e6c6dde4185ca0980ff9d5fdf5e20734a7f86724b0b355e57a6cd6df5b7ace2"
},
"2026-06-28": {
"timestamp": "2026-06-28T05:01:28.249360+00:00",
"workout_data": {
"type": "early_bird"
},
"hmac": "f6400e7af861ca8a157e623eafd490f87df723f536f6eb9f4e1acd353d7106c2"
},
"2026-06-29": {
"timestamp": "2026-06-29T09:21:58.110418+00:00",
"workout_data": {