diff --git a/screen_locker/_auto_upgrade.py b/screen_locker/_auto_upgrade.py index 47b7a20..6822039 100644 --- a/screen_locker/_auto_upgrade.py +++ b/screen_locker/_auto_upgrade.py @@ -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(): - _logger.info("Auto-upgraded today's sick_day entry to phone_verified.") + 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 diff --git a/screen_locker/_constants.py b/screen_locker/_constants.py index 7924cf1..204829e 100644 --- a/screen_locker/_constants.py +++ b/screen_locker/_constants.py @@ -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). diff --git a/screen_locker/_early_bird.py b/screen_locker/_early_bird.py index 1d64600..a6c6118 100644 --- a/screen_locker/_early_bird.py +++ b/screen_locker/_early_bird.py @@ -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 - return entry.get("workout_data", {}).get("type") == "early_bird" + if verify_entry_hmac(state): + return True + if compute_entry_hmac({"_probe": True}) is None and "hmac" not in state: + _logger.info("HMAC key unavailable — accepting unsigned pending marker") + return True + _logger.warning("HMAC verification failed for early-bird pending marker") + return False - def _save_early_bird_log(self) -> None: - """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) diff --git a/screen_locker/_extra_benefits.py b/screen_locker/_extra_benefits.py index 4e4bf5e..8a071c7 100644 --- a/screen_locker/_extra_benefits.py +++ b/screen_locker/_extra_benefits.py @@ -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", [] ) diff --git a/screen_locker/_log_mixin.py b/screen_locker/_log_mixin.py index 2a0218d..08bf50c 100644 --- a/screen_locker/_log_mixin.py +++ b/screen_locker/_log_mixin.py @@ -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 diff --git a/screen_locker/_sick_tracker.py b/screen_locker/_sick_tracker.py index c2d87f4..a526bd3 100644 --- a/screen_locker/_sick_tracker.py +++ b/screen_locker/_sick_tracker.py @@ -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, diff --git a/screen_locker/_status.py b/screen_locker/_status.py index c174874..d166e7e 100644 --- a/screen_locker/_status.py +++ b/screen_locker/_status.py @@ -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,19 +81,8 @@ 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: - before_count += 1 + 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}") diff --git a/screen_locker/extra_benefits_state.json b/screen_locker/extra_benefits_state.json index 45bafea..769f362 100644 --- a/screen_locker/extra_benefits_state.json +++ b/screen_locker/extra_benefits_state.json @@ -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" ] diff --git a/screen_locker/screen_lock.py b/screen_locker/screen_lock.py index 6420241..20d8071 100755 --- a/screen_locker/screen_lock.py +++ b/screen_locker/screen_lock.py @@ -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,7 +261,10 @@ class ScreenLocker( def unlock_screen(self) -> None: """Save workout log and display success message.""" - self.save_workout_log() + # sick_day is already persisted to sick_history.json by + # _finalize_sick_day — workout_log.json is reserved for real outcomes. + if self.workout_data.get("type") != "sick_day": + self.save_workout_log() shutdown_adjusted = self._try_adjust_shutdown_for_workout() new_debt = self._clear_debt_on_verified_workout() diff --git a/screen_locker/shutdown_base.json b/screen_locker/shutdown_base.json index 4d80992..da337fa 100644 --- a/screen_locker/shutdown_base.json +++ b/screen_locker/shutdown_base.json @@ -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" } \ No newline at end of file diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index ac5dbb5..6588e1d 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -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 diff --git a/screen_locker/tests/test_early_bird.py b/screen_locker/tests/test_early_bird.py index a157835..893e77b 100644 --- a/screen_locker/tests/test_early_bird.py +++ b/screen_locker/tests/test_early_bird.py @@ -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() diff --git a/screen_locker/tests/test_early_bird_part2.py b/screen_locker/tests/test_early_bird_part2.py index edd7f2b..c442217 100644 --- a/screen_locker/tests/test_early_bird_part2.py +++ b/screen_locker/tests/test_early_bird_part2.py @@ -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 diff --git a/screen_locker/tests/test_extra_benefits.py b/screen_locker/tests/test_extra_benefits.py index 85f23cf..b679f05 100644 --- a/screen_locker/tests/test_extra_benefits.py +++ b/screen_locker/tests/test_extra_benefits.py @@ -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: diff --git a/screen_locker/tests/test_screen_lock_coverage_part1.py b/screen_locker/tests/test_screen_lock_coverage_part1.py index af7678c..f0a7228 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part1.py +++ b/screen_locker/tests/test_screen_lock_coverage_part1.py @@ -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: diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index 96849de..b2b37b5 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -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 diff --git a/screen_locker/tests/test_status_part2.py b/screen_locker/tests/test_status_part2.py new file mode 100644 index 0000000..152613a --- /dev/null +++ b/screen_locker/tests/test_status_part2.py @@ -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 diff --git a/screen_locker/tests/test_verify_workout.py b/screen_locker/tests/test_verify_workout.py index 8895834..59c844b 100644 --- a/screen_locker/tests/test_verify_workout.py +++ b/screen_locker/tests/test_verify_workout.py @@ -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: diff --git a/screen_locker/tests/test_weekly_logic.py b/screen_locker/tests/test_weekly_logic.py index e1b066d..20ed460 100644 --- a/screen_locker/tests/test_weekly_logic.py +++ b/screen_locker/tests/test_weekly_logic.py @@ -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 diff --git a/screen_locker/tests/test_weekly_logic_part2.py b/screen_locker/tests/test_weekly_logic_part2.py index 65b8af4..4a6b264 100644 --- a/screen_locker/tests/test_weekly_logic_part2.py +++ b/screen_locker/tests/test_weekly_logic_part2.py @@ -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", diff --git a/screen_locker/workout_log.json b/screen_locker/workout_log.json index 44674c7..f592040 100644 --- a/screen_locker/workout_log.json +++ b/screen_locker/workout_log.json @@ -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": {