From 02a93f1322e155ca1b6f854fdf2feb1bccec0834 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Tue, 15 Sep 2026 20:06:26 +0200 Subject: [PATCH] Shutdown +1h on a day with a LeetCode solve; base moves 21:00 -> 20:00 Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 18 ++ README.md | 8 + install_systemd.sh | 7 +- screen_locker/_leetcode_bonus.py | 182 ++++++++++++++++++ screen_locker/_shutdown_base.py | 174 ++++++++++------- screen_locker/_startup_checks.py | 10 +- screen_locker/_status_data.py | 3 - screen_locker/_status_projection.py | 8 +- screen_locker/_sync_mixin.py | 6 +- screen_locker/tests/_isolated_state.py | 7 + screen_locker/tests/conftest.py | 8 +- screen_locker/tests/test_leetcode_bonus.py | 174 +++++++++++++++++ screen_locker/tests/test_shutdown_base.py | 73 ++++--- .../tests/test_shutdown_base_replay.py | 42 ++-- screen_locker/tests/test_shutdown_leetcode.py | 135 +++++++++++++ screen_locker/tests/test_status_data.py | 1 - screen_locker/tests/test_status_data_part2.py | 4 +- 17 files changed, 710 insertions(+), 150 deletions(-) create mode 100644 screen_locker/_leetcode_bonus.py create mode 100644 screen_locker/tests/test_leetcode_bonus.py create mode 100644 screen_locker/tests/test_shutdown_leetcode.py diff --git a/AGENTS.md b/AGENTS.md index 158090c..5626046 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -24,3 +24,21 @@ weeks because a missing token `return`ed `[]` and real errors only logged at should read like a sentence a human can act on. - **No escape hatch** — `# noqa` is banned repo-wide. If a failure really is benign, log it at `warning` and move on. + +## The shutdown hour is derived, never incremented into + +`/etc/shutdown-schedule.conf` is recomputed from scratch by +`_shutdown_base.reset_to_base_if_new_day` on each new day: +`BASE_HOUR (20) + today's workout hours + the LeetCode hour`, capped at 23. +Anything that pushes the hour with a plain read-add-write and is *not* a term +of that derivation gets wiped by the next reset -- that is how a 00:02 workout +lost its hours on 2026-09-13. Add a new bonus source as a term first, then +(optionally) as a live pass stamped in `shutdown_base.json` so the two do not +double-apply. The base is the constant, not the state file: the file once +persisted `base_*_hour`, which made the code's default dead. + +The LeetCode hour is read from leetcode-guard's ledger +(`_leetcode_bonus.py`): HMAC-verified `credit` entries, keyed on LeetCode's +own `submitted_at`, flat +1h/day, and *fail closed* -- an unreadable ledger or +key earns nothing. leetcode-guard itself stays read-only; it never writes +the config. diff --git a/README.md b/README.md index 80c5e22..5c39261 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,14 @@ Tkinter/systemd screen locker with workout tracking, sick-day management, and wake-alarm integration. It enforces a workout cadence by locking the screen until a RunnerUp-verified (or explicitly justified) workout is logged. +## Shutdown time + +Every day starts at **20:00** (`BASE_HOUR` in `screen_locker/_shutdown_base.py`) +and bonuses push it later, capped at 23:00: the first counted workout +2h, +each further one +1h, and an accepted LeetCode submission that day +1h (flat, +read from leetcode-guard's ledger, once per day). The config is re-derived from +those sources on each new day, so nothing has to be "remembered" across reboots. + ## MCP server (Claude Code integration) screen-locker exposes a **read-only** MCP server (`screen_locker._mcp`) so diff --git a/install_systemd.sh b/install_systemd.sh index 4f91585..e1e3716 100755 --- a/install_systemd.sh +++ b/install_systemd.sh @@ -88,11 +88,12 @@ if systemctl --user is-active "workout-locker.timer" &>/dev/null; then fi rm -f "$USER_SERVICE_DIR/workout-locker.timer" -# Seed shutdown_base.json with base=21 if not already present +# Seed shutdown_base.json if not already present. It holds only date stamps; +# the base hour itself is BASE_HOUR in screen_locker/_shutdown_base.py. SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json" if [[ ! -f "$SHUTDOWN_BASE" ]]; then - printf '{\n "base_mon_wed_hour": 21,\n "base_thu_sun_hour": 21,\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE" - echo "✓ Created shutdown_base.json with base=21:00" + printf '{\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE" + echo "✓ Created shutdown_base.json" fi # Copy service file to user systemd directory diff --git a/screen_locker/_leetcode_bonus.py b/screen_locker/_leetcode_bonus.py new file mode 100644 index 0000000..cc21adc --- /dev/null +++ b/screen_locker/_leetcode_bonus.py @@ -0,0 +1,182 @@ +"""One extra shutdown hour on a day with an accepted LeetCode submission. + +leetcode-guard never writes the shutdown config itself; it only publishes the +fact through its ledger, the same way ``steam-backlog-enforcer`` reads it for +an hour of gaming budget. The write has to live here because +:func:`~screen_locker._shutdown_base.reset_to_base_if_new_day` recomputes the +config from scratch on every new day -- a bonus written by another program +would be wiped by that reset, exactly as a pre-tick workout credit was on +2026-09-13. So the hour is a *term* of the derived target, not an increment +that survives by luck. + +Three things are pinned deliberately, mirroring leetcode-guard's own +``solved_today`` contract: + +**Only ``credit`` entries count.** A ``seen`` entry is first-run seeding worth +zero, and a ``charge`` proves only that the day was *settled* -- which also +happens from banked credit, from the escape hatch and from a classified +outage, so it can never stand in for a solve. + +**The solve time is ``detail["submitted_at"]``, not ``day``.** ``day`` is the +*harvesting* run's date, and a problem solved at 23:50 and harvested the next +morning carries the next day's key. It earns for the day it was solved on, +which by then is over -- never for the next one. + +**"Cannot check" is not "not solved".** An unreadable ledger or key yields +``None``, and the caller fails closed to no hour. Failing open would let the +coupling be defeated by deleting a file. +""" + +from __future__ import annotations + +from datetime import UTC, datetime, time +import json +import logging +from pathlib import Path +from typing import Final + +from gatelock.log_integrity import verify_entry_hmac + +from screen_locker._constants import HMAC_KEY_FILE + +_logger: Final = logging.getLogger(__name__) + +# Flat, once per day, regardless of how many problems were solved -- the +# workout is the harder thing and keeps the bigger, per-session reward. +LEETCODE_BONUS_HOURS: Final = 1 + +# leetcode-guard's real ledger (its DATA_DIR / "ledger.json"). Read-only from +# here; resolved at call time so the test suite's path redirect applies. +LEETCODE_LEDGER_FILE: Final = Path.home() / ".local/share/leetcode_guard/ledger.json" + +_CREDIT: Final = "credit" + + +def _today_window(now: datetime | None = None) -> tuple[float, float]: + """Local midnight and now, as unix seconds. + + Args: + now: Stand-in for the current local time, for tests. + + Returns: + The inclusive window a solve must fall in to count for today. + """ + moment = (now or datetime.now(tz=UTC)).astimezone() + midnight = datetime.combine(moment.date(), time.min, moment.tzinfo) + return midnight.timestamp(), moment.timestamp() + + +def _landed_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool: + """Whether this verified credit's accepted submission happened today. + + Args: + entry: One ledger entry, already known to be a verified credit. + window: Local midnight and now, as unix seconds. + + Returns: + Whether the solve falls inside today. Without a usable + ``submitted_at`` the harvest ``day`` is the fallback: it runs late, + never early, so it can miss a late solve but never invent one. + """ + detail = entry.get("detail") + raw = detail.get("submitted_at") if isinstance(detail, dict) else None + if raw is not None: + try: + stamp = float(str(raw)) + except ValueError: + _logger.warning( + "LeetCode credit %r has an unparsable submitted_at (%r); " + "falling back to its day key", + entry.get("entry_id"), + raw, + ) + else: + start, end = window + return start <= stamp <= end + start, _ = window + today = datetime.fromtimestamp(start).astimezone().date().isoformat() + return entry.get("day") == today + + +def _read_entries(ledger_path: Path) -> list[object] | None: + """Read the ledger's entry list, or ``None`` when it cannot be read. + + Args: + ledger_path: leetcode-guard's ledger file. + + Returns: + The raw entries, or ``None`` -- which is never "no entries". + """ + try: + raw = json.loads(ledger_path.read_text(encoding="utf-8")) + except OSError as exc: + _logger.warning("Cannot read the LeetCode ledger at %s (%s)", ledger_path, exc) + return None + except ValueError as exc: + _logger.warning( + "LeetCode ledger at %s is not valid JSON (%s)", ledger_path, exc + ) + return None + rows = raw.get("entries") if isinstance(raw, dict) else None + if not isinstance(rows, list): + _logger.warning("LeetCode ledger at %s has no entries array", ledger_path) + return None + return rows + + +def leetcode_solved_today( + ledger_path: Path | None = None, + *, + now: datetime | None = None, +) -> bool | None: + """Whether a verified accepted submission landed today, locally. + + Args: + ledger_path: The ledger to read. None resolves + :data:`LEETCODE_LEDGER_FILE` at call time. + now: Stand-in for the current local time, for tests. + + Returns: + True or False when the ledger could be read and verified, ``None`` + when it could not -- which is never "nothing was solved". + """ + target = LEETCODE_LEDGER_FILE if ledger_path is None else ledger_path + try: + key_usable = bool(HMAC_KEY_FILE.read_bytes().strip()) + except OSError as exc: + _logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc) + return None + if not key_usable: + _logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE) + return None + + rows = _read_entries(target) + if rows is None: + return None + + window = _today_window(now) + return any( + isinstance(row, dict) + and row.get("kind") == _CREDIT + and verify_entry_hmac(row, key_file=HMAC_KEY_FILE) + and _landed_today(row, window=window) + for row in rows + ) + + +def leetcode_bonus_hours(ledger_path: Path | None = None) -> int: + """The shutdown hours today's LeetCode solve has earned, failing closed. + + Args: + ledger_path: The ledger to read. None resolves the real one. + + Returns: + :data:`LEETCODE_BONUS_HOURS` on a day with a verified solve, else 0. + A ledger that cannot be checked is 0 too, and logged -- it must never + be mistaken for a confident "not solved", but it earns nothing either. + """ + solved = leetcode_solved_today(ledger_path) + if solved is None: + _logger.warning("LeetCode solve state could not be checked; no shutdown hour") + return 0 + return LEETCODE_BONUS_HOURS if solved else 0 diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index 5956aa0..2613a2c 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -1,14 +1,22 @@ """Daily shutdown-time base reset for the screen locker. -On each new calendar day the shutdown config is reset to base hours (21:00 -by default) so that the day's workout bonuses always layer on top of a known -floor rather than accumulating indefinitely across days. +On each new calendar day the shutdown config is reset to the base hour +(:data:`BASE_HOUR`, 20:00) so that the day's bonuses always layer on top of a +known floor rather than accumulating indefinitely across days. -The reset re-derives what today's log has ALREADY earned and writes base plus -that in one go. Live credits are read-add-write against the config, so a -workout credited between local midnight and the first locker tick of the day -(a manual log at 00:02, say) had its hours wiped by the reset that followed — -on 2026-09-13 that left the bar at 21:00 with a counted workout on disk. +The base is a constant, not state. It used to be persisted in the state file +and read back on every reset, which made the code's default dead: the file +kept re-writing whatever it was installed with, so changing the constant +changed nothing on the machine. The state file now carries only date stamps. + +The reset re-derives what today has ALREADY earned and writes base plus that +in one go: the workout hours from today's log, and the LeetCode hour from +leetcode-guard's ledger (:mod:`screen_locker._leetcode_bonus`). Live credits +are read-add-write against the config, so a workout credited between local +midnight and the first locker tick of the day (a manual log at 00:02, say) +had its hours wiped by the reset that followed -- on 2026-09-13 that left the +bar at 21:00 with a counted workout on disk. Any bonus source that is not a +term of this derivation has that bug. The sick-day state file is cleared on reset so the sick-restore path cannot overwrite the fresh base when it runs later in the same startup. @@ -21,6 +29,7 @@ import logging from typing import TYPE_CHECKING, Any from screen_locker._day import today_str +from screen_locker._leetcode_bonus import LEETCODE_BONUS_HOURS, leetcode_bonus_hours from screen_locker._log_io import load_workout_log from screen_locker._weekly_check import count_day_credits from screen_locker._workout_credit import earned_shutdown_bonus_hours @@ -30,12 +39,15 @@ if TYPE_CHECKING: _logger = logging.getLogger(__name__) -_DEFAULT_BASE_HOUR = 21 +# The hour every day starts from before any bonus. Moved 21 -> 20 on 2026-09-15. +BASE_HOUR = 20 # Mirrors RESTORE_CEILING in adjust_shutdown_schedule.sh: the script clamps any # restore above it, so the target is computed against the same ceiling here # rather than asking for hours the write will silently cut. _RESTORE_CEILING_HOUR = 23 +_LEETCODE_STAMP = "leetcode_bonus_date" + def today_earned_bonus_hours(log_file: Path, today: str) -> int: """Return the shutdown hours today's logged workouts have already earned. @@ -48,29 +60,47 @@ def today_earned_bonus_hours(log_file: Path, today: str) -> int: return earned_shutdown_bonus_hours(credit_count) -def get_base_hours(state_file: Path) -> tuple[int, int]: - """Return ``(base_mon_wed_hour, base_thu_sun_hour)`` from *state_file*. +def _load_state(state_file: Path) -> dict[str, Any]: + """Return the reset state, or an empty dict when it is missing or corrupt. - Falls back to ``(21, 21)`` if the file is missing or corrupt. + A corrupt file is logged and treated as "nothing stamped": the reset then + runs again, which is idempotent, and the LeetCode hour is re-applied at + worst once, capped by the ceiling. """ if not state_file.exists(): - return (_DEFAULT_BASE_HOUR, _DEFAULT_BASE_HOUR) + return {} try: with state_file.open() as f: state: dict[str, Any] = json.load(f) - return ( - int(state.get("base_mon_wed_hour", _DEFAULT_BASE_HOUR)), - int(state.get("base_thu_sun_hour", _DEFAULT_BASE_HOUR)), - ) - except (OSError, json.JSONDecodeError, ValueError) as exc: + except (OSError, json.JSONDecodeError) as exc: _logger.warning( - "Could not read shutdown base hours from %s: %s — defaulting both " - "to %02d:00", + "Could not read shutdown reset state from %s: %s -- treating today " + "as not yet reset", state_file, exc, - _DEFAULT_BASE_HOUR, ) - return (_DEFAULT_BASE_HOUR, _DEFAULT_BASE_HOUR) + return {} + return state + + +def _save_state(state_file: Path, state: dict[str, Any]) -> None: + """Write the reset state, logging rather than raising on failure.""" + try: + with state_file.open("w") as f: + json.dump(state, f, indent=2) + except OSError as exc: + _logger.warning("Failed to write shutdown reset state: %s", exc) + + +def _clear_sick_day_state(sick_day_state_file: Path | None) -> None: + """Remove stale sick-day state so it does not override the base reset.""" + if sick_day_state_file is None or not sick_day_state_file.exists(): + return + try: + sick_day_state_file.unlink() + _logger.info("Daily base reset: cleared stale sick-day state.") + except OSError as exc: + _logger.warning("Daily base reset: could not remove sick-day state: %s", exc) def reset_to_base_if_new_day( @@ -79,77 +109,73 @@ def reset_to_base_if_new_day( sick_day_state_file: Path | None = None, log_file: Path | None = None, ) -> bool: - """Reset the shutdown config to base hours if a new calendar day has begun. + """Reset the shutdown config to the base hour if a new calendar day began. - Writes base hours plus whatever *log_file* shows today has already earned - (see :func:`today_earned_bonus_hours`) via *mixin*._write_shutdown_config - (with restore=True so the script allows moving the time earlier), updates - ``last_reset_date`` in *state_file*, and removes *sick_day_state_file* if - it exists so the sick-restore path does not fight with the fresh base on - the same startup. + Writes :data:`BASE_HOUR` plus whatever today has already earned -- the + workout hours in *log_file* (see :func:`today_earned_bonus_hours`) and the + LeetCode hour -- via *mixin*._write_shutdown_config (with restore=True so + the script allows moving the time earlier), stamps ``last_reset_date`` in + *state_file*, and removes *sick_day_state_file* if it exists so the + sick-restore path does not fight with the fresh base on the same startup. Returns True if a reset was performed, False if today was already reset. """ today = today_str() + if _load_state(state_file).get("last_reset_date") == today: + return False - if state_file.exists(): - try: - with state_file.open() as f: - state: dict[str, Any] = json.load(f) - if state.get("last_reset_date") == today: - return False - except (OSError, json.JSONDecodeError) as exc: - _logger.warning( - "Could not read last_reset_date from %s: %s — cannot tell if " - "today was already reset, so resetting to base hours again", - state_file, - exc, - ) - - base_mw, base_ts = get_base_hours(state_file) earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0 - target_mw = min(_RESTORE_CEILING_HOUR, base_mw + earned) - target_ts = min(_RESTORE_CEILING_HOUR, base_ts + earned) + leetcode = leetcode_bonus_hours() + target = min(_RESTORE_CEILING_HOUR, BASE_HOUR + earned + leetcode) # Preserve the morning-end hour from the live config. config = mixin._read_shutdown_config() morning_end = config[2] if config else 5 - ok: bool = mixin._write_shutdown_config( - target_mw, target_ts, morning_end, restore=True - ) + ok: bool = mixin._write_shutdown_config(target, target, morning_end, restore=True) if not ok: _logger.warning("Daily base reset: failed to write shutdown config.") return False - # Clear stale sick-day state so it does not override the base reset. - if sick_day_state_file is not None and sick_day_state_file.exists(): - try: - sick_day_state_file.unlink() - _logger.info("Daily base reset: cleared stale sick-day state.") - except OSError as exc: - _logger.warning( - "Daily base reset: could not remove sick-day state: %s", exc - ) + _clear_sick_day_state(sick_day_state_file) - new_state: dict[str, Any] = { - "base_mon_wed_hour": base_mw, - "base_thu_sun_hour": base_ts, - "last_reset_date": today, - } - try: - with state_file.open("w") as f: - json.dump(new_state, f, indent=2) - except OSError as exc: - _logger.warning("Daily base reset: failed to write state file: %s", exc) + # The LeetCode hour is stamped here too: the reset already included it, so + # the live pass below must not add it a second time today. + new_state: dict[str, Any] = {"last_reset_date": today} + if leetcode: + new_state[_LEETCODE_STAMP] = today + _save_state(state_file, new_state) _logger.info( - "Daily base reset: Mon-Wed=%d, Thu-Sun=%d (base %d/%d + %dh already " - "earned today).", - target_mw, - target_ts, - base_mw, - base_ts, + "Daily base reset: %02d:00 (base %d + %dh workout + %dh LeetCode " + "already earned today).", + target, + BASE_HOUR, earned, + leetcode, ) return True + + +def apply_leetcode_bonus_if_new(state_file: Path, mixin: object) -> bool: + """Push shutdown later by the LeetCode hour, once per day, if it is earned. + + The daily reset only sees a solve that happened before it ran; this is + the live counterpart for the usual case, where the solve lands hours + later and is picked up by the next timer tick. Idempotent through the + ``leetcode_bonus_date`` stamp in *state_file*, which the reset sets as + well when it already included the hour. + + Returns True if the hour was applied on this call. + """ + today = today_str() + state = _load_state(state_file) + if state.get(_LEETCODE_STAMP) == today or leetcode_bonus_hours() == 0: + return False + if not mixin._adjust_shutdown_time_by(LEETCODE_BONUS_HOURS): + _logger.warning("LeetCode bonus: failed to write shutdown config.") + return False + state[_LEETCODE_STAMP] = today + _save_state(state_file, state) + _logger.info("LeetCode bonus: +%dh shutdown time today.", LEETCODE_BONUS_HOURS) + return True diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index 8e6e386..90f1d52 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -29,7 +29,10 @@ from screen_locker._decision_log import ( ) from screen_locker._degraded_sources import degraded_sources from screen_locker._extra_benefits import process_week_transition -from screen_locker._shutdown_base import reset_to_base_if_new_day +from screen_locker._shutdown_base import ( + apply_leetcode_bonus_if_new, + reset_to_base_if_new_day, +) from screen_locker._sync_mixin import SyncMixin from screen_locker._temperature import ( HARD_TIMEOUT_SECONDS, @@ -105,7 +108,7 @@ class StartupChecksMixin(SyncMixin): # 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, + # Reset shutdown config to base (20: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, @@ -114,6 +117,9 @@ class StartupChecksMixin(SyncMixin): log_file=self.log_file, ): self._apply_weekly_shutdown_bonus() + # A LeetCode solve usually lands hours after the reset; this 5-minute + # tick is what turns it into the extra hour the same day. + apply_leetcode_bonus_if_new(SHUTDOWN_BASE_FILE, self) # Ingest any manual workouts synced from the phone (or another device) # before the early-exit checks, so a manual logged off-app still counts # toward today's/this week's minimum. diff --git a/screen_locker/_status_data.py b/screen_locker/_status_data.py index 9519c1c..b86850d 100644 --- a/screen_locker/_status_data.py +++ b/screen_locker/_status_data.py @@ -46,7 +46,6 @@ from screen_locker._constants import ( EXTRA_BENEFITS_FILE, MANUAL_WORKOUT_BUDGET_PER_7_DAYS, MANUAL_WORKOUT_BUDGET_PER_30_DAYS, - SHUTDOWN_BASE_FILE, SHUTDOWN_CONFIG_FILE, SICK_BUDGET_PER_7_DAYS, SICK_BUDGET_PER_30_DAYS, @@ -171,7 +170,6 @@ def gather_status( *, log_file: Path = _DEFAULT_LOG_FILE, extra_benefits_file: Path = EXTRA_BENEFITS_FILE, - shutdown_base_file: Path = SHUTDOWN_BASE_FILE, shutdown_config_file: Path = SHUTDOWN_CONFIG_FILE, early_bird_pending_file: Path = EARLY_BIRD_PENDING_FILE, now: datetime | None = None, @@ -212,7 +210,6 @@ def gather_status( streak=current_streak(extra_benefits_file), early_bird_extended=early_bird_extended, shutdown=_shutdown_projection( - shutdown_base_file=shutdown_base_file, shutdown_config_file=shutdown_config_file, extra_benefits_file=extra_benefits_file, log_file=log_file, diff --git a/screen_locker/_status_projection.py b/screen_locker/_status_projection.py index 2cb7f97..1b23679 100644 --- a/screen_locker/_status_projection.py +++ b/screen_locker/_status_projection.py @@ -19,7 +19,7 @@ from screen_locker._extra_benefits import ( weekly_shutdown_bonus_hours, ) from screen_locker._shutdown import read_shutdown_config -from screen_locker._shutdown_base import get_base_hours +from screen_locker._shutdown_base import BASE_HOUR from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay from screen_locker._weekly_check import count_weekly_workouts @@ -53,7 +53,6 @@ def _week_rows( def _shutdown_projection( *, - shutdown_base_file: Path, shutdown_config_file: Path, extra_benefits_file: Path, log_file: Path, @@ -61,9 +60,8 @@ def _shutdown_projection( ) -> ShutdownProjection: """Build the shutdown-time projection: tonight, rest of week, next week.""" tonight = read_shutdown_config(shutdown_config_file) - base_mw, base_ts = get_base_hours(shutdown_base_file) bonus = weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local) - rest_of_week = _week_rows(base_mw + bonus, base_ts + bonus, speculative=False) + rest_of_week = _week_rows(BASE_HOUR + bonus, BASE_HOUR + bonus, speculative=False) this_week_count = count_weekly_workouts(log_file, today=today_local) streak = current_streak(extra_benefits_file) @@ -71,7 +69,7 @@ def _shutdown_projection( this_week_count, streak ) next_week_preview = _week_rows( - base_mw + would_be_bonus, base_ts + would_be_bonus, speculative=True + BASE_HOUR + would_be_bonus, BASE_HOUR + would_be_bonus, speculative=True ) return ShutdownProjection( diff --git a/screen_locker/_sync_mixin.py b/screen_locker/_sync_mixin.py index def1574..e897914 100644 --- a/screen_locker/_sync_mixin.py +++ b/screen_locker/_sync_mixin.py @@ -10,11 +10,12 @@ from __future__ import annotations import logging -from screen_locker._constants import EXTRA_BENEFITS_FILE +from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_BASE_FILE from screen_locker._extra_benefits import weekly_shutdown_bonus_hours from screen_locker._manual_push import push_pc_workouts from screen_locker._manual_sync import ingest_manual_records from screen_locker._session_sync import ingest_session_records +from screen_locker._shutdown_base import apply_leetcode_bonus_if_new from screen_locker._weekly_check import ( WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts, @@ -49,6 +50,9 @@ class SyncMixin: self._ingest_synced_manual_workouts() self._ingest_synced_sessions() self._auto_fill_week_runnerup_bonus() + # Same reason: a LeetCode solve made after login must not wait for the + # next login to earn its hour. + apply_leetcode_bonus_if_new(SHUTDOWN_BASE_FILE, self) def _ingest_synced_manual_workouts(self) -> None: """Sync manual workouts: publish this PC's, ingest everyone else's. diff --git a/screen_locker/tests/_isolated_state.py b/screen_locker/tests/_isolated_state.py index d79b427..ec8bae8 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -73,4 +73,11 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( "instance.lock", ("_constants.INSTANCE_LOCK_FILE", "screen_lock.INSTANCE_LOCK_FILE"), ), + # leetcode-guard's ledger. Read-only from here, but a test must never see + # the user's real solves: a real credit would earn a fake shutdown hour + # inside an assertion written against an empty day. + ( + "leetcode_ledger.json", + ("_leetcode_bonus.LEETCODE_LEDGER_FILE",), + ), ) diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 5ad4f2c..2987f7c 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -173,18 +173,18 @@ def _isolate_shutdown_base(tmp_path: Path) -> Iterator[None]: 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. + same as the rest of the suite already does. The LeetCode stamp is seeded + too, so apply_leetcode_bonus_if_new() never reads a ledger by default. """ target = tmp_path / "shutdown_base.json" today = datetime.now(tz=UTC).strftime("%Y-%m-%d") target.write_text( - json.dumps( - {"base_mon_wed_hour": 21, "base_thu_sun_hour": 21, "last_reset_date": today} - ) + json.dumps({"last_reset_date": today, "leetcode_bonus_date": today}) ) with ( patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target), patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target), + patch("screen_locker._sync_mixin.SHUTDOWN_BASE_FILE", target), ): yield diff --git a/screen_locker/tests/test_leetcode_bonus.py b/screen_locker/tests/test_leetcode_bonus.py new file mode 100644 index 0000000..a93684d --- /dev/null +++ b/screen_locker/tests/test_leetcode_bonus.py @@ -0,0 +1,174 @@ +"""Tests for the LeetCode shutdown hour: reading the ledger, failing closed. + +Entries are signed against a temp key with gatelock's own ``compute_entry_hmac`` +so a forged credit and a genuine one differ in exactly the way they do on the +machine. +""" + +from __future__ import annotations + +from datetime import datetime, timedelta +import json +import logging +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +from gatelock.log_integrity import compute_entry_hmac +import pytest + +from screen_locker import _leetcode_bonus +from screen_locker._leetcode_bonus import ( + LEETCODE_BONUS_HOURS, + leetcode_bonus_hours, + leetcode_solved_today, +) + +if TYPE_CHECKING: + from pathlib import Path + +_NOW = datetime(2026, 9, 15, 14, 0).astimezone() + + +@pytest.fixture +def key_file(tmp_path: Path) -> Path: + key = tmp_path / "hmac.key" + key.write_bytes(b"test-key-bytes") + with patch.object(_leetcode_bonus, "HMAC_KEY_FILE", key): + yield key + + +def _entry( + kind: str, *, submitted_at: object, key: Path, day: str = "2026-09-15" +) -> dict[str, Any]: + body: dict[str, Any] = { + "entry_id": f"{kind}:{submitted_at}", + "kind": kind, + "day": day, + "detail": {"submitted_at": submitted_at}, + } + return {**body, "hmac": compute_entry_hmac(body, key_file=key)} + + +def _ledger(tmp_path: Path, entries: list[Any]) -> Path: + path = tmp_path / "ledger.json" + path.write_text(json.dumps({"entries": entries})) + return path + + +class TestSolvedToday: + def test_verified_credit_today_is_solved( + self, tmp_path: Path, key_file: Path + ) -> None: + stamp = (_NOW - timedelta(hours=2)).timestamp() + ledger = _ledger(tmp_path, [_entry("credit", submitted_at=stamp, key=key_file)]) + assert leetcode_solved_today(ledger, now=_NOW) is True + + def test_credit_from_yesterday_is_not_solved( + self, tmp_path: Path, key_file: Path + ) -> None: + """A 23:50 solve harvested this morning counts for yesterday, never today.""" + stamp = (_NOW - timedelta(days=1)).timestamp() + ledger = _ledger(tmp_path, [_entry("credit", submitted_at=stamp, key=key_file)]) + assert leetcode_solved_today(ledger, now=_NOW) is False + + def test_seen_and_charge_entries_never_count( + self, tmp_path: Path, key_file: Path + ) -> None: + stamp = _NOW.timestamp() + ledger = _ledger( + tmp_path, + [ + _entry("seen", submitted_at=stamp, key=key_file), + _entry("charge", submitted_at=stamp, key=key_file), + "not-a-dict", + ], + ) + assert leetcode_solved_today(ledger, now=_NOW) is False + + def test_forged_credit_is_ignored(self, tmp_path: Path, key_file: Path) -> None: + entry = _entry("credit", submitted_at=_NOW.timestamp(), key=key_file) + entry["detail"]["submitted_at"] = ( + _NOW.timestamp() - 1 + ) # body no longer matches hmac + ledger = _ledger(tmp_path, [entry]) + assert leetcode_solved_today(ledger, now=_NOW) is False + + def test_unparsable_submitted_at_falls_back_to_day_key( + self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture + ) -> None: + ledger = _ledger( + tmp_path, + [ + _entry( + "credit", + submitted_at="soon", + key=key_file, + day=_NOW.date().isoformat(), + ) + ], + ) + with caplog.at_level(logging.WARNING): + assert leetcode_solved_today(ledger, now=_NOW) is True + assert "unparsable submitted_at" in caplog.text + + def test_missing_submitted_at_uses_day_key( + self, tmp_path: Path, key_file: Path + ) -> None: + body: dict[str, Any] = {"entry_id": "x", "kind": "credit", "day": "2000-01-01"} + entry = {**body, "hmac": compute_entry_hmac(body, key_file=key_file)} + assert leetcode_solved_today(_ledger(tmp_path, [entry]), now=_NOW) is False + + def test_resolves_module_path_when_none_given( + self, tmp_path: Path, key_file: Path + ) -> None: + stamp = _NOW.timestamp() + ledger = _ledger(tmp_path, [_entry("credit", submitted_at=stamp, key=key_file)]) + with patch.object(_leetcode_bonus, "LEETCODE_LEDGER_FILE", ledger): + assert leetcode_solved_today(now=_NOW) is True + + +class TestCannotCheck: + """Every unreadable state is None -- never a confident False.""" + + def test_missing_ledger(self, tmp_path: Path, key_file: Path) -> None: + assert leetcode_solved_today(tmp_path / "missing.json") is None + + def test_invalid_json(self, tmp_path: Path, key_file: Path) -> None: + path = tmp_path / "ledger.json" + path.write_text("{not json") + assert leetcode_solved_today(path) is None + + def test_no_entries_array(self, tmp_path: Path, key_file: Path) -> None: + path = tmp_path / "ledger.json" + path.write_text(json.dumps({"entries": "nope"})) + assert leetcode_solved_today(path) is None + + def test_unreadable_key(self, tmp_path: Path) -> None: + with patch.object(_leetcode_bonus, "HMAC_KEY_FILE", tmp_path / "no.key"): + assert leetcode_solved_today(_ledger(tmp_path, [])) is None + + def test_empty_key(self, tmp_path: Path) -> None: + key = tmp_path / "hmac.key" + key.write_bytes(b" \n") + with patch.object(_leetcode_bonus, "HMAC_KEY_FILE", key): + assert leetcode_solved_today(_ledger(tmp_path, [])) is None + + +class TestBonusHours: + def test_solved_earns_the_hour(self) -> None: + with patch.object(_leetcode_bonus, "leetcode_solved_today", return_value=True): + assert leetcode_bonus_hours() == LEETCODE_BONUS_HOURS + + def test_not_solved_earns_nothing(self) -> None: + with patch.object(_leetcode_bonus, "leetcode_solved_today", return_value=False): + assert leetcode_bonus_hours() == 0 + + def test_cannot_check_fails_closed_and_warns( + self, caplog: pytest.LogCaptureFixture + ) -> None: + with ( + patch.object(_leetcode_bonus, "leetcode_solved_today", return_value=None), + caplog.at_level(logging.WARNING), + ): + assert leetcode_bonus_hours() == 0 + assert "could not be checked" in caplog.text diff --git a/screen_locker/tests/test_shutdown_base.py b/screen_locker/tests/test_shutdown_base.py index b753b04..b56a74b 100644 --- a/screen_locker/tests/test_shutdown_base.py +++ b/screen_locker/tests/test_shutdown_base.py @@ -7,43 +7,62 @@ import json from typing import TYPE_CHECKING from unittest.mock import MagicMock -from screen_locker._shutdown_base import get_base_hours, reset_to_base_if_new_day +from screen_locker._shutdown_base import ( + BASE_HOUR, + _load_state, + reset_to_base_if_new_day, +) if TYPE_CHECKING: from pathlib import Path -class TestGetBaseHours: - """Tests for get_base_hours.""" +class TestLoadState: + """Tests for the state-file reader behind both entry points.""" - def test_returns_defaults_when_file_missing(self, tmp_path: Path) -> None: - """Missing file → (21, 21) without errors (lines 29-30).""" - assert get_base_hours(tmp_path / "nonexistent.json") == (21, 21) + def test_missing_file_is_empty(self, tmp_path: Path) -> None: + """Missing file → {} without errors.""" + assert _load_state(tmp_path / "nonexistent.json") == {} - def test_returns_stored_hours(self, tmp_path: Path) -> None: - """Valid file with custom hours → exact values (lines 31-37).""" + def test_returns_stored_stamps(self, tmp_path: Path) -> None: + """Valid file → exact contents.""" f = tmp_path / "state.json" - f.write_text(json.dumps({"base_mon_wed_hour": 22, "base_thu_sun_hour": 20})) - assert get_base_hours(f) == (22, 20) + f.write_text(json.dumps({"last_reset_date": "2000-01-01"})) + assert _load_state(f) == {"last_reset_date": "2000-01-01"} - def test_returns_defaults_on_corrupt_json(self, tmp_path: Path) -> None: - """Corrupt JSON → (21, 21) via except (lines 38-39).""" + def test_corrupt_json_is_empty(self, tmp_path: Path) -> None: + """Corrupt JSON → {} via except.""" f = tmp_path / "state.json" f.write_text("not-json") - assert get_base_hours(f) == (21, 21) + assert _load_state(f) == {} - def test_returns_defaults_on_oserror(self) -> None: - """OSError on open → (21, 21) via except (lines 38-39).""" + def test_oserror_is_empty(self) -> None: + """OSError on open → {} via except.""" mock_path = MagicMock() mock_path.exists.return_value = True mock_path.open.side_effect = OSError("read fail") - assert get_base_hours(mock_path) == (21, 21) + assert _load_state(mock_path) == {} - def test_uses_default_when_key_missing(self, tmp_path: Path) -> None: - """Keys absent in JSON → each defaults to 21.""" + def test_stale_base_keys_are_ignored(self, tmp_path: Path) -> None: + """A file installed before the base became a constant must not revive 21.""" f = tmp_path / "state.json" - f.write_text(json.dumps({})) - assert get_base_hours(f) == (21, 21) + f.write_text( + json.dumps( + { + "last_reset_date": "2000-01-01", + "base_mon_wed_hour": 21, + "base_thu_sun_hour": 21, + } + ) + ) + mixin = MagicMock() + mixin._read_shutdown_config.return_value = (21, 21, 5) + mixin._write_shutdown_config.return_value = True + assert reset_to_base_if_new_day(f, mixin) is True + mixin._write_shutdown_config.assert_called_once_with( + BASE_HOUR, BASE_HOUR, 5, restore=True + ) + assert "base_mon_wed_hour" not in json.loads(f.read_text()) class TestResetToBaseIfNewDay: @@ -66,18 +85,10 @@ class TestResetToBaseIfNewDay: def test_resets_when_new_day(self, tmp_path: Path) -> None: """Different last_reset_date → reset performed, returns True (lines 67-100).""" f = tmp_path / "state.json" - f.write_text( - json.dumps( - { - "last_reset_date": "2000-01-01", - "base_mon_wed_hour": 21, - "base_thu_sun_hour": 21, - } - ) - ) + f.write_text(json.dumps({"last_reset_date": "2000-01-01"})) mixin = self._make_mixin() assert reset_to_base_if_new_day(f, mixin) is True - mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True) + mixin._write_shutdown_config.assert_called_once_with(20, 20, 5, restore=True) def test_resets_when_no_state_file(self, tmp_path: Path) -> None: """No state file → treated as new day, reset performed (lines 67-100).""" @@ -101,7 +112,7 @@ class TestResetToBaseIfNewDay: mixin._read_shutdown_config.return_value = None mixin._write_shutdown_config.return_value = True reset_to_base_if_new_day(f, mixin) - mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True) + mixin._write_shutdown_config.assert_called_once_with(20, 20, 5, restore=True) def test_clears_sick_day_state_file_on_reset(self, tmp_path: Path) -> None: """Existing sick-day file is deleted during reset (lines 79-82).""" diff --git a/screen_locker/tests/test_shutdown_base_replay.py b/screen_locker/tests/test_shutdown_base_replay.py index a0b7580..8671e2a 100644 --- a/screen_locker/tests/test_shutdown_base_replay.py +++ b/screen_locker/tests/test_shutdown_base_replay.py @@ -147,36 +147,28 @@ class TestResetReplaysTodaysCredit: def _mixin(self) -> MagicMock: mixin = MagicMock() - mixin._read_shutdown_config.return_value = (21, 21, 5) + mixin._read_shutdown_config.return_value = (20, 20, 5) mixin._write_shutdown_config.return_value = True return mixin - def _state(self, tmp_path: Path, base: int = 21) -> Path: + def _state(self, tmp_path: Path) -> Path: state = tmp_path / "state.json" - state.write_text( - json.dumps( - { - "last_reset_date": "2000-01-01", - "base_mon_wed_hour": base, - "base_thu_sun_hour": base, - } - ) - ) + state.write_text(json.dumps({"last_reset_date": "2000-01-01"})) return state def test_writes_base_plus_todays_earned_hours(self, tmp_path: Path) -> None: - """The 2026-09-13 case: base 21 + one football → 23, not 21.""" + """The 2026-09-13 case: base 20 + one football → 22, not 20.""" log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13()) mixin = self._mixin() assert ( reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True ) - mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True) + mixin._write_shutdown_config.assert_called_once_with(22, 22, 5, restore=True) def test_without_log_file_writes_plain_base(self, tmp_path: Path) -> None: mixin = self._mixin() assert reset_to_base_if_new_day(self._state(tmp_path), mixin) is True - mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True) + mixin._write_shutdown_config.assert_called_once_with(20, 20, 5, restore=True) def test_empty_day_writes_plain_base(self, tmp_path: Path) -> None: log = _write_log(tmp_path / "log.json", "2000-01-01", _real_2026_09_13()) @@ -184,23 +176,25 @@ class TestResetReplaysTodaysCredit: assert ( reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True ) - mixin._write_shutdown_config.assert_called_once_with(21, 21, 5, restore=True) + mixin._write_shutdown_config.assert_called_once_with(20, 20, 5, restore=True) def test_earned_hours_are_capped_at_the_restore_ceiling( self, tmp_path: Path ) -> None: - """base 22 + 2h earned would be 24; the script clamps at 23, so ask for 23.""" - log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13()) + """base 20 + 2h football + 2 extra workouts = 24; the script clamps at 23.""" + entries = [ + *_real_2026_09_13(), + _manual("manual:2026-09-13T12:00"), + _manual("manual:2026-09-13T18:00"), + ] + log = _write_log(tmp_path / "log.json", today_str(), entries) mixin = self._mixin() - state = self._state(tmp_path, base=22) - assert reset_to_base_if_new_day(state, mixin, log_file=log) is True + assert reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True) - def test_state_file_still_records_base_not_target(self, tmp_path: Path) -> None: - """Tomorrow's reset must start from 21 again, not from today's 23.""" + def test_state_file_records_only_the_date(self, tmp_path: Path) -> None: + """Tomorrow's reset must start from the constant, not from today's 22.""" log = _write_log(tmp_path / "log.json", today_str(), _real_2026_09_13()) state = self._state(tmp_path) reset_to_base_if_new_day(state, self._mixin(), log_file=log) - saved = json.loads(state.read_text()) - assert (saved["base_mon_wed_hour"], saved["base_thu_sun_hour"]) == (21, 21) - assert saved["last_reset_date"] == today_str() + assert json.loads(state.read_text()) == {"last_reset_date": today_str()} diff --git a/screen_locker/tests/test_shutdown_leetcode.py b/screen_locker/tests/test_shutdown_leetcode.py new file mode 100644 index 0000000..72ce468 --- /dev/null +++ b/screen_locker/tests/test_shutdown_leetcode.py @@ -0,0 +1,135 @@ +"""Tests for the LeetCode hour as a term of the shutdown derivation. + +Two entry points share one stamp: the daily reset folds the hour into its +target and stamps it, and the live pass adds it once for a solve that lands +after the reset. Neither may apply it twice, and neither may lose it. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from screen_locker import _shutdown_base +from screen_locker._day import today_str +from screen_locker._shutdown_base import ( + BASE_HOUR, + apply_leetcode_bonus_if_new, + reset_to_base_if_new_day, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +import pytest + + +def _mixin(*, adjust_ok: bool = True) -> MagicMock: + mixin = MagicMock() + mixin._read_shutdown_config.return_value = (BASE_HOUR, BASE_HOUR, 5) + mixin._write_shutdown_config.return_value = True + mixin._adjust_shutdown_time_by.return_value = adjust_ok + return mixin + + +@pytest.fixture +def solved() -> Iterator[MagicMock]: + with patch.object(_shutdown_base, "leetcode_bonus_hours", return_value=1) as m: + yield m + + +@pytest.fixture +def unsolved() -> Iterator[MagicMock]: + with patch.object(_shutdown_base, "leetcode_bonus_hours", return_value=0) as m: + yield m + + +class TestResetIncludesTheHour: + def test_reset_writes_base_plus_leetcode( + self, tmp_path: Path, solved: MagicMock + ) -> None: + state = tmp_path / "state.json" + mixin = _mixin() + assert reset_to_base_if_new_day(state, mixin) is True + mixin._write_shutdown_config.assert_called_once_with( + BASE_HOUR + 1, BASE_HOUR + 1, 5, restore=True + ) + assert json.loads(state.read_text()) == { + "last_reset_date": today_str(), + "leetcode_bonus_date": today_str(), + } + + def test_reset_without_solve_leaves_no_stamp( + self, tmp_path: Path, unsolved: MagicMock + ) -> None: + state = tmp_path / "state.json" + assert reset_to_base_if_new_day(state, _mixin()) is True + assert json.loads(state.read_text()) == {"last_reset_date": today_str()} + + def test_live_pass_after_a_reset_that_included_it_is_a_no_op( + self, tmp_path: Path, solved: MagicMock + ) -> None: + """The 2026-09-13 shape, inverted: reset first, then the tick.""" + state = tmp_path / "state.json" + mixin = _mixin() + reset_to_base_if_new_day(state, mixin) + assert apply_leetcode_bonus_if_new(state, mixin) is False + mixin._adjust_shutdown_time_by.assert_not_called() + + +class TestLivePass: + def test_applies_once_and_stamps(self, tmp_path: Path, solved: MagicMock) -> None: + state = tmp_path / "state.json" + state.write_text(json.dumps({"last_reset_date": today_str()})) + mixin = _mixin() + assert apply_leetcode_bonus_if_new(state, mixin) is True + assert apply_leetcode_bonus_if_new(state, mixin) is False + mixin._adjust_shutdown_time_by.assert_called_once_with(1) + assert json.loads(state.read_text()) == { + "last_reset_date": today_str(), + "leetcode_bonus_date": today_str(), + } + + def test_yesterdays_stamp_does_not_block_today( + self, tmp_path: Path, solved: MagicMock + ) -> None: + state = tmp_path / "state.json" + state.write_text(json.dumps({"leetcode_bonus_date": "2000-01-01"})) + assert apply_leetcode_bonus_if_new(state, _mixin()) is True + + def test_no_solve_no_write(self, tmp_path: Path, unsolved: MagicMock) -> None: + mixin = _mixin() + assert apply_leetcode_bonus_if_new(tmp_path / "state.json", mixin) is False + mixin._adjust_shutdown_time_by.assert_not_called() + + def test_failed_write_leaves_no_stamp( + self, tmp_path: Path, solved: MagicMock + ) -> None: + """A stamp without a write would silently forfeit the hour for the day.""" + state = tmp_path / "state.json" + assert apply_leetcode_bonus_if_new(state, _mixin(adjust_ok=False)) is False + assert not state.exists() + + def test_reset_after_a_live_pass_does_not_double( + self, tmp_path: Path, solved: MagicMock + ) -> None: + """Live pass before today's reset: the reset rewrites from scratch.""" + state = tmp_path / "state.json" + mixin = _mixin() + assert apply_leetcode_bonus_if_new(state, mixin) is True + assert reset_to_base_if_new_day(state, mixin) is True + mixin._write_shutdown_config.assert_called_once_with( + BASE_HOUR + 1, BASE_HOUR + 1, 5, restore=True + ) + assert apply_leetcode_bonus_if_new(state, mixin) is False + mixin._adjust_shutdown_time_by.assert_called_once() + + def test_state_write_failure_is_logged_not_raised( + self, tmp_path: Path, solved: MagicMock + ) -> None: + state = MagicMock() + state.exists.return_value = False + state.open.side_effect = OSError("disk full") + assert apply_leetcode_bonus_if_new(state, _mixin()) is True diff --git a/screen_locker/tests/test_status_data.py b/screen_locker/tests/test_status_data.py index a0940a6..234fe93 100644 --- a/screen_locker/tests/test_status_data.py +++ b/screen_locker/tests/test_status_data.py @@ -20,7 +20,6 @@ def _files(tmp_path: Path) -> dict[str, Path]: return { "log_file": tmp_path / "log.json", "extra_benefits_file": tmp_path / "extra_benefits_state.json", - "shutdown_base_file": tmp_path / "shutdown_base.json", "shutdown_config_file": tmp_path / "shutdown_config.conf", "early_bird_pending_file": tmp_path / "early_bird_pending.json", } diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py index 83a2786..b371493 100644 --- a/screen_locker/tests/test_status_data_part2.py +++ b/screen_locker/tests/test_status_data_part2.py @@ -40,7 +40,7 @@ class TestGatherStatus: assert snap.week.remaining == 5 assert snap.week.extra == 0 assert snap.shutdown.tonight is None - assert snap.shutdown.rest_of_week[0].hour == 21 + assert snap.shutdown.rest_of_week[0].hour == 20 assert snap.shutdown.rest_of_week[0].speculative is False assert snap.sick_budget.used_7d == 0 assert snap.sick_budget.exhausted is False @@ -124,7 +124,7 @@ class TestGatherStatus: assert snap.bonus_hours_this_week == 3 assert snap.streak == 2 assert snap.early_bird_extended is True - assert snap.shutdown.rest_of_week[0].hour == 24 # 21 base + 3 bonus + assert snap.shutdown.rest_of_week[0].hour == 23 # 20 base + 3 bonus def test_next_week_preview_is_speculative_rest_of_week_is_not( self, tmp_path: Path