mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-07-04 12:03:09 +02:00
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:
parent
26f23911f0
commit
e25d806742
@ -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
|
||||
|
||||
@ -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).
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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", []
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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}")
|
||||
|
||||
@ -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"
|
||||
]
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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"
|
||||
}
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
160
screen_locker/tests/test_status_part2.py
Normal file
160
screen_locker/tests/test_status_part2.py
Normal 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
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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": {
|
||||
|
||||
Loading…
Reference in New Issue
Block a user