mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
Reading hour from book-guard: +1h shutdown; base 20->19 from 2026-10-01
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter-coverage (push) Waiting to run
Tests / web-coverage (push) Waiting to run
release-apk / release (APK) (push) Waiting to run
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
2b0f5d96a4
commit
6a07dc56aa
11
AGENTS.md
11
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
|
||||
|
||||
10
README.md
10
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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
106
screen_locker/_reading_bonus.py
Normal file
106
screen_locker/_reading_bonus.py
Normal file
@ -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
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
(
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
214
screen_locker/tests/test_reading_bonus.py
Normal file
214
screen_locker/tests/test_reading_bonus.py
Normal file
@ -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
|
||||
@ -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)."""
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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()
|
||||
|
||||
207
screen_locker/tests/test_shutdown_reading.py
Normal file
207
screen_locker/tests/test_shutdown_reading.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user