diff --git a/AGENTS.md b/AGENTS.md index 01f659c..3555da5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -36,7 +36,8 @@ weeks because a missing token `return`ed `[]` and real errors only logged at `/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. +`BASE_HOUR (19) + today's workout hours + the LeetCode hour + the reading 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 @@ -50,6 +51,14 @@ 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. +The reading hour is the same shape, from book-guard's ledger +(`_reading_bonus.py`): HMAC-verified `credit` entries with `detail.bonus == +"1"`, dated by `detail.ended_at` (when the reading happened, not when the quiz +was passed), flat +1h/day, fail closed. The base moves 20 -> 19 on +2026-10-01 (`base_hour()`, `READING_BASE_FROM`) -- book-guard's start date, so +the cut never lands before the hour that pays for it; the 23:00 best case is +unchanged. + ## The morning session is the carrot — and the early-bird window is it `EarlyBirdMixin._is_early_bird_time` no longer reads a clock: it is open diff --git a/README.md b/README.md index aa94271..e0859fc 100644 --- a/README.md +++ b/README.md @@ -6,10 +6,14 @@ 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`) +Every day starts at **19:00** from 2026-10-01, 20:00 before it (`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 +each further one +1h, an accepted LeetCode submission that day +1h (flat, +read from leetcode-guard's ledger, once per day), and a credited paper-book +reading session of 20+ pages / 20+ minutes +1h (flat, read from +[book-guard](https://github.com/kuhyx/book-guard)'s ledger, once per day, keyed +on when the reading ended). The config is re-derived from those sources on each new day, so nothing has to be "remembered" across reboots. A RunnerUp run is credited from its TCX export, found in the WebDAV drop diff --git a/screen_locker/_leetcode_bonus.py b/screen_locker/_leetcode_bonus.py index cc21adc..0da1d68 100644 --- a/screen_locker/_leetcode_bonus.py +++ b/screen_locker/_leetcode_bonus.py @@ -98,11 +98,12 @@ def _landed_today(entry: dict[str, object], *, window: tuple[float, float]) -> b return entry.get("day") == today -def _read_entries(ledger_path: Path) -> list[object] | None: +def _read_entries(ledger_path: Path, label: str = "LeetCode") -> list[object] | None: """Read the ledger's entry list, or ``None`` when it cannot be read. Args: - ledger_path: leetcode-guard's ledger file. + ledger_path: leetcode-guard's (or book-guard's) ledger file. + label: Whose ledger, for the log line. Returns: The raw entries, or ``None`` -- which is never "no entries". @@ -110,16 +111,16 @@ def _read_entries(ledger_path: Path) -> list[object] | None: 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) + _logger.warning("Cannot read the %s ledger at %s (%s)", label, ledger_path, exc) return None except ValueError as exc: _logger.warning( - "LeetCode ledger at %s is not valid JSON (%s)", ledger_path, exc + "%s ledger at %s is not valid JSON (%s)", label, 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) + _logger.warning("%s ledger at %s has no entries array", label, ledger_path) return None return rows diff --git a/screen_locker/_reading_bonus.py b/screen_locker/_reading_bonus.py new file mode 100644 index 0000000..da5c4a6 --- /dev/null +++ b/screen_locker/_reading_bonus.py @@ -0,0 +1,106 @@ +"""One extra shutdown hour on a day with a credited paper-book reading session. + +book-guard (``~/src/book-guard``) publishes reading through its ledger, the +same way leetcode-guard publishes solves, and steam-backlog-enforcer reads the +same fact for an hour of gaming. Like the LeetCode hour it is a *term* of the +daily derivation in :mod:`screen_locker._shutdown_base`, never an increment +another program writes into the config. + +The contract, pinned in book-guard's ``_quiz.record_verdict``: + +**Only ``credit`` rows count**, and only those with ``detail["bonus"] == +"1"`` -- book-guard sets that when the session passed its quiz *and* was big +enough (20+ pages, 20+ minutes). A ``reject`` is a failed quiz; an +``escape`` forgives a lock and earns nothing. + +**The reading time is ``detail["ended_at"]``**, not the row's creation: a +session read at 22:30 and quizzed next morning earns for the evening it was +read, which by then is over -- never for the quiz day. + +**"Cannot check" is not "did not read"**: an unreadable ledger or key yields +``None`` and the caller fails closed to no hour. +""" + +from __future__ import annotations + +from datetime import UTC, datetime +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 +from screen_locker._leetcode_bonus import _read_entries, _today_window + +_logger: Final = logging.getLogger(__name__) + +READING_BONUS_HOURS: Final = 1 + +# book-guard's real ledger. Read-only from here; resolved at call time so the +# test suite's path redirect (tests/_isolated_state.py) applies. +READING_LEDGER_FILE: Final = Path.home() / ".local/share/book_guard/ledger.json" + + +def _read_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool: + """Whether this verified credit is bonus-eligible and was read today.""" + detail = entry.get("detail") + if not isinstance(detail, dict) or detail.get("bonus") != "1": + return False + try: + ended = float(str(detail.get("ended_at"))) + except ValueError: + _logger.warning( + "reading credit %r has no usable ended_at", entry.get("entry_id") + ) + return False + start, end = window + return start <= ended <= end + + +def read_today( + ledger_path: Path | None = None, + *, + now: datetime | None = None, +) -> bool | None: + """Whether a verified, bonus-eligible reading session ended today, locally. + + Returns: + True or False when the ledger could be read and verified, ``None`` + when it could not -- which is never "did not read". + """ + target = READING_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 + if not target.exists(): + return False # book-guard never registered a book: no reading, no fault + rows = _read_entries(target, "book-guard") + if rows is None: + return None + window = _today_window(now or datetime.now(tz=UTC)) + return any( + isinstance(row, dict) + and row.get("kind") == "credit" + and verify_entry_hmac(row, key_file=HMAC_KEY_FILE) + and _read_today(row, window=window) + for row in rows + ) + + +def reading_bonus_hours(ledger_path: Path | None = None) -> int: + """The shutdown hours today's reading has earned, failing closed. + + A ledger that does not exist yet (book-guard never ran) is a plain 0, + logged by :func:`_read_entries` like any unreadable one. + """ + done = read_today(ledger_path) + if done is None: + _logger.warning("Reading state could not be checked; no shutdown hour") + return 0 + return READING_BONUS_HOURS if done else 0 diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index 2613a2c..c8a7b45 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -1,7 +1,7 @@ """Daily shutdown-time base reset for the screen locker. 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 +(:data:`BASE_HOUR`, 19:00) so that the day's bonuses always layer on top of a known floor rather than accumulating indefinitely across days. The base is a constant, not state. It used to be persisted in the state file @@ -10,8 +10,9 @@ 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 +in one go: the workout hours from today's log, the LeetCode hour from +leetcode-guard's ledger (:mod:`screen_locker._leetcode_bonus`) and the reading +hour from book-guard's (:mod:`screen_locker._reading_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 @@ -24,6 +25,7 @@ overwrite the fresh base when it runs later in the same startup. from __future__ import annotations +from datetime import date, datetime import json import logging from typing import TYPE_CHECKING, Any @@ -31,22 +33,39 @@ 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._reading_bonus import READING_BONUS_HOURS, reading_bonus_hours from screen_locker._weekly_check import count_day_credits from screen_locker._workout_credit import earned_shutdown_bonus_hours if TYPE_CHECKING: + from collections.abc import Callable from pathlib import Path _logger = logging.getLogger(__name__) -# The hour every day starts from before any bonus. Moved 21 -> 20 on 2026-09-15. -BASE_HOUR = 20 +# The hour every day starts from before any bonus. Moved 21 -> 20 on +# 2026-09-15, and 20 -> 19 together with book-guard's reading hour: the +# ceiling stays 23:00, so every day's best case (workouts + LeetCode + reading) +# is unchanged and a day without reading ends an hour earlier. The cut waits +# for book-guard's gate (2026-10-01) -- taking an hour the reader cannot yet +# earn back was a same-day loss when it first shipped on 2026-09-26. +BASE_HOUR = 19 +READING_BASE_FROM = date(2026, 10, 1) + + +def base_hour(day: date | None = None) -> int: + """The base for ``day`` (default today): 20 before the reading cut, then 19.""" + target = day or datetime.now().astimezone().date() + return BASE_HOUR if target >= READING_BASE_FROM else BASE_HOUR + 1 + + # 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" +_READING_STAMP = "reading_bonus_date" def today_earned_bonus_hours(log_file: Path, today: str) -> int: @@ -126,7 +145,9 @@ def reset_to_base_if_new_day( earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0 leetcode = leetcode_bonus_hours() - target = min(_RESTORE_CEILING_HOUR, BASE_HOUR + earned + leetcode) + reading = reading_bonus_hours() + base = base_hour() + target = min(_RESTORE_CEILING_HOUR, base + earned + leetcode + reading) # Preserve the morning-end hour from the live config. config = mixin._read_shutdown_config() @@ -139,43 +160,84 @@ def reset_to_base_if_new_day( _clear_sick_day_state(sick_day_state_file) - # 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. + # The flat hours are stamped here too: the reset already included them, + # so the live pass below must not add them a second time today. new_state: dict[str, Any] = {"last_reset_date": today} if leetcode: new_state[_LEETCODE_STAMP] = today + if reading: + new_state[_READING_STAMP] = today _save_state(state_file, new_state) _logger.info( - "Daily base reset: %02d:00 (base %d + %dh workout + %dh LeetCode " - "already earned today).", + "Daily base reset: %02d:00 (base %d + %dh workout + %dh LeetCode + " + "%dh reading already earned today).", target, - BASE_HOUR, + base, earned, leetcode, + reading, ) 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. +def _apply_flat_bonus( + state_file: Path, + mixin: object, + *, + stamp: str, + hours: Callable[[], int], + label: str, +) -> bool: + """Push shutdown later by a once-per-day flat hour, 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. + The daily reset only sees a bonus earned before it ran; this is the live + counterpart for the usual case, where the solve or the reading lands + hours later and is picked up by the next timer tick. Idempotent through + the ``stamp`` key 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: + if state.get(stamp) == today: return False - if not mixin._adjust_shutdown_time_by(LEETCODE_BONUS_HOURS): - _logger.warning("LeetCode bonus: failed to write shutdown config.") + earned = hours() + if earned == 0: return False - state[_LEETCODE_STAMP] = today + if not mixin._adjust_shutdown_time_by(earned): + _logger.warning("%s bonus: failed to write shutdown config.", label) + return False + state[stamp] = today _save_state(state_file, state) - _logger.info("LeetCode bonus: +%dh shutdown time today.", LEETCODE_BONUS_HOURS) + _logger.info("%s bonus: +%dh shutdown time today.", label, earned) return True + + +def apply_leetcode_bonus_if_new(state_file: Path, mixin: object) -> bool: + """The LeetCode hour, live (see :func:`_apply_flat_bonus`).""" + return _apply_flat_bonus( + state_file, + mixin, + stamp=_LEETCODE_STAMP, + hours=lambda: LEETCODE_BONUS_HOURS if leetcode_bonus_hours() else 0, + label="LeetCode", + ) + + +def apply_reading_bonus_if_new(state_file: Path, mixin: object) -> bool: + """The reading hour, live (see :func:`_apply_flat_bonus`).""" + return _apply_flat_bonus( + state_file, + mixin, + stamp=_READING_STAMP, + hours=lambda: READING_BONUS_HOURS if reading_bonus_hours() else 0, + label="Reading", + ) + + +def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None: + """Every once-per-day flat hour: LeetCode, then reading.""" + apply_leetcode_bonus_if_new(state_file, mixin) + apply_reading_bonus_if_new(state_file, mixin) diff --git a/screen_locker/_startup_checks.py b/screen_locker/_startup_checks.py index 1ea7982..82a2f32 100644 --- a/screen_locker/_startup_checks.py +++ b/screen_locker/_startup_checks.py @@ -31,7 +31,7 @@ from screen_locker._degraded_sources import degraded_sources from screen_locker._extra_benefits import process_week_transition from screen_locker._lock_invariant import refuse_to_lock_over_logged_workout from screen_locker._shutdown_base import ( - apply_leetcode_bonus_if_new, + apply_flat_bonuses_if_new, reset_to_base_if_new_day, ) from screen_locker._sync_mixin import SyncMixin @@ -109,7 +109,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 (20:00) at the start of each new day, + # Reset shutdown config to base (19: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, @@ -118,9 +118,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) + # A LeetCode solve or a reading credit usually lands hours after the + # reset; this 5-minute tick turns it into the extra hour the same day. + apply_flat_bonuses_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_projection.py b/screen_locker/_status_projection.py index 1b23679..8825504 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 BASE_HOUR +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 @@ -61,7 +61,8 @@ def _shutdown_projection( """Build the shutdown-time projection: tonight, rest of week, next week.""" tonight = read_shutdown_config(shutdown_config_file) bonus = weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local) - rest_of_week = _week_rows(BASE_HOUR + bonus, BASE_HOUR + bonus, speculative=False) + base = base_hour(today_local.date()) + rest_of_week = _week_rows(base + bonus, base + bonus, speculative=False) this_week_count = count_weekly_workouts(log_file, today=today_local) streak = current_streak(extra_benefits_file) @@ -69,7 +70,7 @@ def _shutdown_projection( this_week_count, streak ) next_week_preview = _week_rows( - BASE_HOUR + would_be_bonus, BASE_HOUR + would_be_bonus, speculative=True + base + would_be_bonus, base + would_be_bonus, speculative=True ) return ShutdownProjection( diff --git a/screen_locker/_sync_mixin.py b/screen_locker/_sync_mixin.py index d9fb4c1..8bcf863 100644 --- a/screen_locker/_sync_mixin.py +++ b/screen_locker/_sync_mixin.py @@ -15,7 +15,7 @@ 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._shutdown_base import apply_flat_bonuses_if_new from screen_locker._workout_sync import ( pull_all_manual_records, pull_all_session_records, @@ -46,9 +46,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) + # Same reason: a LeetCode solve or a reading session made after login + # must not wait for the next login to earn its hour. + apply_flat_bonuses_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 727aa1c..67d5fb1 100644 --- a/screen_locker/tests/_isolated_state.py +++ b/screen_locker/tests/_isolated_state.py @@ -79,6 +79,12 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = ( "leetcode_ledger.json", ("_leetcode_bonus.LEETCODE_LEDGER_FILE",), ), + # book-guard's ledger, for the same reason: a real reading credit would + # earn a fake shutdown hour inside an assertion. + ( + "reading_ledger.json", + ("_reading_bonus.READING_LEDGER_FILE",), + ), # wake-alarm's workday-stick file. A test that forgot this would read # the developer's real penalty state. ( diff --git a/screen_locker/tests/conftest.py b/screen_locker/tests/conftest.py index 7514530..0857ba8 100644 --- a/screen_locker/tests/conftest.py +++ b/screen_locker/tests/conftest.py @@ -181,6 +181,9 @@ def _isolate_state_files(tmp_path: Path) -> Iterator[None]: yield +_SEEDED_STAMPS = ("last_reset_date", "leetcode_bonus_date", "reading_bonus_date") + + @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. @@ -188,14 +191,13 @@ 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. The LeetCode stamp is seeded - too, so apply_leetcode_bonus_if_new() never reads a ledger by default. + same as the rest of the suite already does. The LeetCode and reading + stamps are seeded too, so apply_flat_bonuses_if_new() never reads a + ledger (or the host's real HMAC key) by default. """ target = tmp_path / "shutdown_base.json" today = datetime.now(tz=UTC).strftime("%Y-%m-%d") - target.write_text( - json.dumps({"last_reset_date": today, "leetcode_bonus_date": today}) - ) + target.write_text(json.dumps(dict.fromkeys(_SEEDED_STAMPS, today))) with ( patch("screen_locker._constants.SHUTDOWN_BASE_FILE", target), patch("screen_locker._startup_checks.SHUTDOWN_BASE_FILE", target), diff --git a/screen_locker/tests/test_leetcode_bonus.py b/screen_locker/tests/test_leetcode_bonus.py index a93684d..cdbac59 100644 --- a/screen_locker/tests/test_leetcode_bonus.py +++ b/screen_locker/tests/test_leetcode_bonus.py @@ -172,3 +172,24 @@ class TestBonusHours: ): assert leetcode_bonus_hours() == 0 assert "could not be checked" in caplog.text + + +class TestReadEntriesLabel: + """_read_entries is shared with book-guard's reader; the label names whose.""" + + def test_default_label_is_leetcode( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + with caplog.at_level(logging.WARNING): + assert _leetcode_bonus._read_entries(tmp_path / "missing.json") is None + assert "Cannot read the LeetCode ledger" in caplog.text + + def test_custom_label_names_the_ledger( + self, tmp_path: Path, caplog: pytest.LogCaptureFixture + ) -> None: + path = tmp_path / "ledger.json" + path.write_text(json.dumps({"no": "entries"})) + with caplog.at_level(logging.WARNING): + assert _leetcode_bonus._read_entries(path, "book-guard") is None + assert "book-guard ledger at" in caplog.text + assert "LeetCode" not in caplog.text diff --git a/screen_locker/tests/test_lock_invariant.py b/screen_locker/tests/test_lock_invariant.py index 0462960..bf20718 100644 --- a/screen_locker/tests/test_lock_invariant.py +++ b/screen_locker/tests/test_lock_invariant.py @@ -74,7 +74,7 @@ class TestEnforcePathChokepoint: "screen_locker._startup_checks.reset_to_base_if_new_day", return_value=False, ), - patch("screen_locker._startup_checks.apply_leetcode_bonus_if_new"), + patch("screen_locker._startup_checks.apply_flat_bonuses_if_new"), ): stack.enter_context(patcher) return stack diff --git a/screen_locker/tests/test_reading_bonus.py b/screen_locker/tests/test_reading_bonus.py new file mode 100644 index 0000000..6ce0b2a --- /dev/null +++ b/screen_locker/tests/test_reading_bonus.py @@ -0,0 +1,214 @@ +"""Tests for the reading shutdown hour: reading book-guard's ledger, failing closed. + +Rows 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. The key is patched on ``_reading_bonus`` itself: the module holds its +own binding of ``HMAC_KEY_FILE``. +""" + +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 _reading_bonus +from screen_locker._reading_bonus import ( + READING_BONUS_HOURS, + read_today, + reading_bonus_hours, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_NOW = datetime(2026, 10, 2, 21, 0).astimezone() + + +@pytest.fixture +def key_file(tmp_path: Path) -> Iterator[Path]: + key = tmp_path / "hmac.key" + key.write_bytes(b"test-key-bytes") + with patch.object(_reading_bonus, "HMAC_KEY_FILE", key): + yield key + + +def _row( + kind: str = "credit", + *, + key: Path, + ended_at: object = None, + bonus: str = "1", + detail: object = None, +) -> dict[str, Any]: + """One signed ledger row; ``detail`` overrides the default detail body.""" + if detail is None: + stamp = _NOW - timedelta(hours=1) if ended_at is None else ended_at + if isinstance(stamp, datetime): + stamp = str(int(stamp.timestamp())) + detail = {"bonus": bonus, "ended_at": stamp} + body: dict[str, Any] = {"entry_id": f"{kind}:x", "kind": kind, "detail": detail} + 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 TestReadToday: + def test_verified_bonus_credit_read_today( + self, tmp_path: Path, key_file: Path + ) -> None: + ledger = _ledger(tmp_path, [_row(key=key_file)]) + assert read_today(ledger, now=_NOW) is True + + def test_missing_ledger_is_a_plain_false( + self, tmp_path: Path, key_file: Path + ) -> None: + """book-guard never ran: no reading, but no fault either.""" + assert read_today(tmp_path / "missing.json", now=_NOW) is False + + def test_session_ended_yesterday_does_not_count( + self, tmp_path: Path, key_file: Path + ) -> None: + """Read at 22:30, quizzed next morning: earns the evening, not today.""" + row = _row(key=key_file, ended_at=_NOW - timedelta(days=1)) + assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False + + def test_session_ending_after_now_does_not_count( + self, tmp_path: Path, key_file: Path + ) -> None: + row = _row(key=key_file, ended_at=_NOW + timedelta(minutes=5)) + assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False + + def test_small_session_without_bonus_flag_does_not_count( + self, tmp_path: Path, key_file: Path + ) -> None: + row = _row(key=key_file, bonus="0") + assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False + + def test_non_credit_rows_never_count(self, tmp_path: Path, key_file: Path) -> None: + rows = [_row("reject", key=key_file), _row("escape", key=key_file), "junk"] + assert read_today(_ledger(tmp_path, rows), now=_NOW) is False + + def test_non_dict_detail_does_not_count( + self, tmp_path: Path, key_file: Path + ) -> None: + row = _row(key=key_file, detail="not-a-dict") + assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False + + def test_forged_credit_is_ignored(self, tmp_path: Path, key_file: Path) -> None: + row = _row(key=key_file, bonus="0") + row["detail"]["bonus"] = "1" # body no longer matches its hmac + assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False + + @pytest.mark.parametrize("ended_at", ["soon", None]) + def test_unusable_ended_at_does_not_count_and_warns( + self, + tmp_path: Path, + key_file: Path, + caplog: pytest.LogCaptureFixture, + ended_at: object, + ) -> None: + """A missing ended_at stringifies to "None"; neither may invent a day.""" + row = _row(key=key_file, detail={"bonus": "1", "ended_at": ended_at}) + with caplog.at_level(logging.WARNING): + assert read_today(_ledger(tmp_path, [row]), now=_NOW) is False + assert "no usable ended_at" in caplog.text + + def test_one_good_row_among_bad_ones_counts( + self, tmp_path: Path, key_file: Path + ) -> None: + rows = [_row(key=key_file, bonus="0"), _row(key=key_file)] + assert read_today(_ledger(tmp_path, rows), now=_NOW) is True + + def test_defaults_resolve_module_path_and_clock( + self, tmp_path: Path, key_file: Path + ) -> None: + row = _row(key=key_file, ended_at=datetime.now().astimezone()) + ledger = _ledger(tmp_path, [row]) + with patch.object(_reading_bonus, "READING_LEDGER_FILE", ledger): + assert read_today() is True + + +class TestCannotCheck: + """Every unreadable state is None -- never a confident False.""" + + def test_unreadable_key(self, tmp_path: Path) -> None: + with patch.object(_reading_bonus, "HMAC_KEY_FILE", tmp_path / "no.key"): + assert read_today(_ledger(tmp_path, [])) is None + + def test_unreadable_key_wins_over_a_missing_ledger(self, tmp_path: Path) -> None: + """The key is checked first: without it even "no ledger" is unknown.""" + with patch.object(_reading_bonus, "HMAC_KEY_FILE", tmp_path / "no.key"): + assert read_today(tmp_path / "missing.json") is None + + def test_empty_key(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None: + key = tmp_path / "hmac.key" + key.write_bytes(b" \n") + with ( + patch.object(_reading_bonus, "HMAC_KEY_FILE", key), + caplog.at_level(logging.WARNING), + ): + assert read_today(_ledger(tmp_path, [])) is None + assert "is empty" in caplog.text + + def test_invalid_json_is_logged_as_book_guards( + self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture + ) -> None: + path = tmp_path / "ledger.json" + path.write_text("{not json") + with caplog.at_level(logging.WARNING): + assert read_today(path) is None + assert "book-guard ledger at" in caplog.text + assert "not valid JSON" in caplog.text + + 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 read_today(path) is None + + def test_unreadable_ledger(self, tmp_path: Path, key_file: Path) -> None: + """A directory where the file should be: exists, but read_text fails.""" + path = tmp_path / "ledger.json" + path.mkdir() + assert read_today(path) is None + + +class TestBonusHours: + def test_read_earns_the_hour(self) -> None: + with patch.object(_reading_bonus, "read_today", return_value=True): + assert reading_bonus_hours() == READING_BONUS_HOURS == 1 + + def test_not_read_earns_nothing(self) -> None: + with patch.object(_reading_bonus, "read_today", return_value=False): + assert reading_bonus_hours() == 0 + + def test_cannot_check_fails_closed_and_warns( + self, caplog: pytest.LogCaptureFixture + ) -> None: + with ( + patch.object(_reading_bonus, "read_today", return_value=None), + caplog.at_level(logging.WARNING), + ): + assert reading_bonus_hours() == 0 + assert "could not be checked" in caplog.text + + def test_passes_the_ledger_path_through(self, tmp_path: Path) -> None: + path = tmp_path / "ledger.json" + with patch.object(_reading_bonus, "read_today", return_value=True) as m: + reading_bonus_hours(path) + m.assert_called_once_with(path) + + def test_isolated_suite_never_sees_a_real_ledger(self, key_file: Path) -> None: + """The conftest redirect points at an absent tmp file, so: 0, not None.""" + assert not _reading_bonus.READING_LEDGER_FILE.exists() + assert reading_bonus_hours() == 0 diff --git a/screen_locker/tests/test_shutdown_base.py b/screen_locker/tests/test_shutdown_base.py index b56a74b..cb96019 100644 --- a/screen_locker/tests/test_shutdown_base.py +++ b/screen_locker/tests/test_shutdown_base.py @@ -8,8 +8,8 @@ from typing import TYPE_CHECKING from unittest.mock import MagicMock from screen_locker._shutdown_base import ( - BASE_HOUR, _load_state, + base_hour, reset_to_base_if_new_day, ) @@ -60,7 +60,7 @@ class TestLoadState: 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 + base_hour(), base_hour(), 5, restore=True ) assert "base_mon_wed_hour" not in json.loads(f.read_text()) @@ -88,7 +88,10 @@ class TestResetToBaseIfNewDay: 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(20, 20, 5, restore=True) + base = base_hour() + mixin._write_shutdown_config.assert_called_once_with( + base, base, 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).""" @@ -112,7 +115,10 @@ 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(20, 20, 5, restore=True) + base = base_hour() + mixin._write_shutdown_config.assert_called_once_with( + base, base, 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 8671e2a..959b6ba 100644 --- a/screen_locker/tests/test_shutdown_base_replay.py +++ b/screen_locker/tests/test_shutdown_base_replay.py @@ -15,6 +15,7 @@ from unittest.mock import MagicMock from screen_locker._day import today_str from screen_locker._shutdown_base import ( + base_hour, reset_to_base_if_new_day, today_earned_bonus_hours, ) @@ -157,18 +158,23 @@ class TestResetReplaysTodaysCredit: return state def test_writes_base_plus_todays_earned_hours(self, tmp_path: Path) -> None: - """The 2026-09-13 case: base 20 + one football → 22, not 20.""" + """The 2026-09-13 case: base + one football (2h), not the bare base.""" 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(22, 22, 5, restore=True) + mixin._write_shutdown_config.assert_called_once_with( + base_hour() + 2, base_hour() + 2, 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(20, 20, 5, restore=True) + base = base_hour() + mixin._write_shutdown_config.assert_called_once_with( + base, base, 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()) @@ -176,16 +182,20 @@ 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(20, 20, 5, restore=True) + base = base_hour() + mixin._write_shutdown_config.assert_called_once_with( + base, base, 5, restore=True + ) def test_earned_hours_are_capped_at_the_restore_ceiling( self, tmp_path: Path ) -> None: - """base 20 + 2h football + 2 extra workouts = 24; the script clamps at 23.""" + """base + 2h football + 3 extra workouts > 23 under either base; clamped.""" entries = [ *_real_2026_09_13(), _manual("manual:2026-09-13T12:00"), _manual("manual:2026-09-13T18:00"), + _manual("manual:2026-09-13T20:00"), ] log = _write_log(tmp_path / "log.json", today_str(), entries) mixin = self._mixin() @@ -193,7 +203,7 @@ class TestResetReplaysTodaysCredit: mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True) 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.""" + """Tomorrow's reset must start from the constant, not from today's total.""" 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) diff --git a/screen_locker/tests/test_shutdown_leetcode.py b/screen_locker/tests/test_shutdown_leetcode.py index 72ce468..62af92b 100644 --- a/screen_locker/tests/test_shutdown_leetcode.py +++ b/screen_locker/tests/test_shutdown_leetcode.py @@ -14,8 +14,8 @@ 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, + base_hour, reset_to_base_if_new_day, ) @@ -28,12 +28,19 @@ import pytest def _mixin(*, adjust_ok: bool = True) -> MagicMock: mixin = MagicMock() - mixin._read_shutdown_config.return_value = (BASE_HOUR, BASE_HOUR, 5) + 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(autouse=True) +def _no_reading() -> Iterator[None]: + """Zero the reading hour so the stamps asserted here are LeetCode's only.""" + with patch.object(_shutdown_base, "reading_bonus_hours", return_value=0): + yield + + @pytest.fixture def solved() -> Iterator[MagicMock]: with patch.object(_shutdown_base, "leetcode_bonus_hours", return_value=1) as m: @@ -54,7 +61,7 @@ class TestResetIncludesTheHour: 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 + base_hour() + 1, base_hour() + 1, 5, restore=True ) assert json.loads(state.read_text()) == { "last_reset_date": today_str(), @@ -121,7 +128,7 @@ class TestLivePass: 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 + 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() diff --git a/screen_locker/tests/test_shutdown_reading.py b/screen_locker/tests/test_shutdown_reading.py new file mode 100644 index 0000000..69b2add --- /dev/null +++ b/screen_locker/tests/test_shutdown_reading.py @@ -0,0 +1,207 @@ +"""Tests for the reading hour as a term of the shutdown derivation. + +Covers the base cut it pays for (20 -> 19 from 2026-10-01), the daily reset +folding the hour in and stamping it, and the live pass that adds it once for a +reading credit landing after the reset. Mirrors test_shutdown_leetcode.py. +""" + +from __future__ import annotations + +from datetime import UTC, date, datetime +import json +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from screen_locker import _shutdown_base +from screen_locker._day import today_str +from screen_locker._shutdown_base import ( + READING_BASE_FROM, + apply_flat_bonuses_if_new, + apply_reading_bonus_if_new, + base_hour, + reset_to_base_if_new_day, +) +from screen_locker._status_data import gather_status +from screen_locker.tests.test_status_data import _files + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + + +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 hours() -> Iterator[tuple[MagicMock, MagicMock]]: + """(leetcode, reading) hour sources, both 0 until a test sets them.""" + with ( + patch.object(_shutdown_base, "leetcode_bonus_hours", return_value=0) as lc, + patch.object(_shutdown_base, "reading_bonus_hours", return_value=0) as rd, + ): + yield lc, rd + + +class TestBaseHour: + def test_day_before_the_cut_keeps_twenty(self) -> None: + assert base_hour(date(2026, 9, 30)) == 20 + + def test_cut_day_is_nineteen(self) -> None: + assert date(2026, 10, 1) == READING_BASE_FROM + assert base_hour(date(2026, 10, 1)) == 19 + + def test_long_after_the_cut_stays_nineteen(self) -> None: + assert base_hour(date(2030, 1, 1)) == 19 + + def test_default_is_the_local_today(self) -> None: + assert base_hour() == base_hour(datetime.now().astimezone().date()) + + +class TestResetIncludesReading: + def test_reset_writes_base_plus_reading_and_stamps_it( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + hours[1].return_value = 1 + 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(), + "reading_bonus_date": today_str(), + } + + def test_reset_with_both_flat_hours_stamps_both( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + hours[0].return_value = 1 + hours[1].return_value = 1 + 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() + 2, base_hour() + 2, 5, restore=True + ) + assert json.loads(state.read_text()) == { + "last_reset_date": today_str(), + "leetcode_bonus_date": today_str(), + "reading_bonus_date": today_str(), + } + apply_flat_bonuses_if_new(state, mixin) + mixin._adjust_shutdown_time_by.assert_not_called() + + def test_reset_is_still_capped_at_the_ceiling( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + hours[0].return_value = 1 + hours[1].return_value = 1 + mixin = _mixin() + with patch.object(_shutdown_base, "today_earned_bonus_hours", return_value=9): + reset_to_base_if_new_day( + tmp_path / "state.json", mixin, log_file=tmp_path / "log.json" + ) + mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True) + + +class TestReadingLivePass: + def test_applies_once_and_stamps( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + hours[1].return_value = 1 + state = tmp_path / "state.json" + state.write_text(json.dumps({"last_reset_date": today_str()})) + mixin = _mixin() + assert apply_reading_bonus_if_new(state, mixin) is True + assert apply_reading_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(), + "reading_bonus_date": today_str(), + } + + def test_stamped_today_never_reads_the_ledger( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + state = tmp_path / "state.json" + state.write_text(json.dumps({"reading_bonus_date": today_str()})) + assert apply_reading_bonus_if_new(state, _mixin()) is False + hours[1].assert_not_called() + + def test_no_reading_no_write( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + mixin = _mixin() + assert apply_reading_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_and_warns( + self, + tmp_path: Path, + hours: tuple[MagicMock, MagicMock], + caplog: pytest.LogCaptureFixture, + ) -> None: + """A stamp without a write would silently forfeit the hour for the day.""" + hours[1].return_value = 1 + state = tmp_path / "state.json" + mixin = _mixin(adjust_ok=False) + with caplog.at_level("WARNING"): + assert apply_reading_bonus_if_new(state, mixin) is False + assert "Reading bonus: failed to write" in caplog.text + assert not state.exists() + + +class TestFlatBonuses: + def test_both_earned_apply_both_and_keep_both_stamps( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + """The second save must not overwrite the first one's stamp.""" + hours[0].return_value = 1 + hours[1].return_value = 1 + state = tmp_path / "state.json" + mixin = _mixin() + apply_flat_bonuses_if_new(state, mixin) + assert mixin._adjust_shutdown_time_by.call_count == 2 + assert json.loads(state.read_text()) == { + "leetcode_bonus_date": today_str(), + "reading_bonus_date": today_str(), + } + apply_flat_bonuses_if_new(state, mixin) + assert mixin._adjust_shutdown_time_by.call_count == 2 + + def test_only_reading_earned_applies_only_reading( + self, tmp_path: Path, hours: tuple[MagicMock, MagicMock] + ) -> None: + hours[1].return_value = 1 + state = tmp_path / "state.json" + mixin = _mixin() + apply_flat_bonuses_if_new(state, mixin) + mixin._adjust_shutdown_time_by.assert_called_once_with(1) + assert json.loads(state.read_text()) == {"reading_bonus_date": today_str()} + + +class TestProjectionFollowsTheCut: + @pytest.mark.parametrize( + ("now", "expected"), + [ + (datetime(2026, 9, 30, 12, 0, tzinfo=UTC), 20), + (datetime(2026, 10, 2, 12, 0, tzinfo=UTC), 19), + ], + ) + def test_rest_of_week_uses_the_base_for_that_day( + self, tmp_path: Path, now: datetime, expected: int + ) -> None: + with patch( + "screen_locker._status_data.has_workout_skip_today", return_value=False + ): + snap = gather_status(**_files(tmp_path), now=now) + assert snap.shutdown.rest_of_week[0].hour == expected + assert snap.shutdown.next_week_preview[0].hour == expected