mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-13 14:10:57 +01:00
shutdown: derive base and bonuses from the shared earned_time registry
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 (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (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 (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
The base hour, the workout/LeetCode/reading hours and the reading cut date were duplicated here and in steam-backlog-enforcer. They now come from earned_time (kuhyx/utils, earned-time-v0.1.0): the reset sums the registry, the live pass iterates its flat earners (stamps unchanged: <name>_bonus_date), and _leetcode_bonus/_reading_bonus are replaced by the shared HMAC ledger reader. A gate registered later needs no code here. The schedule still stores whole hours; _earned.to_hours raises on a remainder rather than flooring it. Verified identical to the old code on today's real ledgers and log (reset target, stamps, live passes, base hours). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Aktgaq93yt51c6D3X5Fzsm
This commit is contained in:
parent
6f3f93ecec
commit
ed5fa133d0
@ -134,6 +134,7 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.1.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
@ -154,6 +155,7 @@ repos:
|
||||
# than merely an old one.
|
||||
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.1.0#subdirectory=earned_time
|
||||
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||
- mcp>=2,<3
|
||||
- pydantic
|
||||
|
||||
40
AGENTS.md
40
AGENTS.md
@ -35,29 +35,31 @@ weeks because a missing token `return`ed `[]` and real errors only logged at
|
||||
## The shutdown hour is derived, never incremented into
|
||||
|
||||
`/etc/shutdown-schedule.conf` is recomputed from scratch by
|
||||
`_shutdown_base.reset_to_base_if_new_day` on each new day:
|
||||
`BASE_HOUR (19) + today's workout hours + the LeetCode hour + the reading hour`,
|
||||
capped at 23.
|
||||
`_shutdown_base.reset_to_base_if_new_day` on each new day: the base plus
|
||||
every earned term, capped at 23. The numbers are NOT in this repo: they live
|
||||
in the shared earner registry `earned_time` (kuhyx/utils, `earned_time/`),
|
||||
which steam-backlog-enforcer reads for gaming time too. Base = 20:00 minus
|
||||
each earner's cut once its `penalty_from` day arrives (reading, from
|
||||
2026-10-01 -> 19:00); terms = workout 2h + 1h per further session, LeetCode
|
||||
1h, reading 1h, plus whatever is registered later.
|
||||
Anything that pushes the hour with a plain read-add-write and is *not* a term
|
||||
of that derivation gets wiped by the next reset -- that is how a 00:02 workout
|
||||
lost its hours on 2026-09-13. Add a new bonus source as a term first, then
|
||||
(optionally) as a live pass stamped in `shutdown_base.json` so the two do not
|
||||
double-apply. The base is the constant, not the state file: the file once
|
||||
persisted `base_*_hour`, which made the code's default dead.
|
||||
lost its hours on 2026-09-13. A new bonus source is an `Earner` in the
|
||||
registry, never code here: the reset includes it as a term, and the live pass
|
||||
(`apply_flat_bonuses_if_new`, stamped `<name>_bonus_date` in
|
||||
`shutdown_base.json`) picks it up for flat earners. The base is the registry
|
||||
constant, not the state file: the file once persisted `base_*_hour`, which
|
||||
made the code's default dead.
|
||||
|
||||
The LeetCode hour is read from leetcode-guard's ledger
|
||||
(`_leetcode_bonus.py`): HMAC-verified `credit` entries, keyed on LeetCode's
|
||||
own `submitted_at`, flat +1h/day, and *fail closed* -- an unreadable ledger or
|
||||
key earns nothing. leetcode-guard itself stays read-only; it never writes
|
||||
the config.
|
||||
Flat earners are read from their gate's ledger by `earned_time.done_today`
|
||||
via `_earned.py`: HMAC-verified `credit` rows matched by the earner's own
|
||||
rule (LeetCode's `submitted_at`, book-guard's `detail.bonus == "1"` +
|
||||
`ended_at`), and *fail closed* -- an unreadable ledger or key earns nothing,
|
||||
logged. The gates stay read-only; none 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 schedule stores whole hours; `_earned.to_hours` raises on a remainder
|
||||
instead of flooring, so a 30-minute earner needs the schedule to learn
|
||||
minutes first.
|
||||
|
||||
## The morning session is the carrot — and the early-bird window is it
|
||||
|
||||
|
||||
@ -89,7 +89,7 @@ fi
|
||||
rm -f "$USER_SERVICE_DIR/workout-locker.timer"
|
||||
|
||||
# Seed shutdown_base.json if not already present. It holds only date stamps;
|
||||
# the base hour itself is BASE_HOUR in screen_locker/_shutdown_base.py.
|
||||
# the base hour itself comes from the earned_time registry (kuhyx/utils).
|
||||
SHUTDOWN_BASE="$SCRIPT_DIR/screen_locker/shutdown_base.json"
|
||||
if [[ ! -f "$SHUTDOWN_BASE" ]]; then
|
||||
printf '{\n "last_reset_date": ""\n}\n' > "$SHUTDOWN_BASE"
|
||||
|
||||
@ -10,6 +10,7 @@ dependencies = [
|
||||
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
|
||||
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync",
|
||||
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays",
|
||||
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.1.0#subdirectory=earned_time",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
|
||||
@ -4,6 +4,7 @@ bandit==1.9.4
|
||||
codespell==2.4.3
|
||||
coverage==7.16.2
|
||||
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.1#subdirectory=crdt-sync
|
||||
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.1.0#subdirectory=earned_time
|
||||
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.0#subdirectory=freedays
|
||||
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
|
||||
mcp==2.3.0
|
||||
|
||||
90
screen_locker/_earned.py
Normal file
90
screen_locker/_earned.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""Shutdown hours from the shared earner registry (``earned_time``, kuhyx/utils).
|
||||
|
||||
Every gate's reward -- the workout, the LeetCode hour, the reading hour and
|
||||
whatever is registered next -- is defined once in ``earned_time``, which
|
||||
steam-backlog-enforcer reads for gaming time too. This module is the
|
||||
screen-locker side of it: where the ledgers live, and the hour conversion.
|
||||
|
||||
The gates publish facts only; none writes the shutdown config. Their ledgers
|
||||
are HMAC-verified against :data:`HMAC_KEY_FILE` and *fail closed* -- an
|
||||
unreadable ledger or key earns nothing, and says so.
|
||||
|
||||
**Whole hours only, for now.** ``/etc/shutdown-schedule.conf`` stores hours,
|
||||
while the registry is in minutes. :func:`to_hours` refuses a remainder rather
|
||||
than flooring it, so a 30-minute earner cannot silently become 0 here before
|
||||
the schedule learns minutes.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._constants import HMAC_KEY_FILE
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import datetime
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
# Every earner's ``ledger`` is relative to this. Read-only from here; resolved
|
||||
# at call time so the test suite's redirect (tests/_isolated_state.py) applies.
|
||||
LEDGER_HOME: Final = Path.home()
|
||||
|
||||
_MINUTES_PER_HOUR: Final = 60
|
||||
|
||||
|
||||
def to_hours(minutes: int) -> int:
|
||||
"""Convert a registry amount to the schedule's whole hours.
|
||||
|
||||
Raises:
|
||||
ValueError: ``minutes`` is not a whole number of hours. The shutdown
|
||||
schedule cannot express it, and flooring would quietly drop it.
|
||||
"""
|
||||
hours, remainder = divmod(minutes, _MINUTES_PER_HOUR)
|
||||
if remainder:
|
||||
msg = (
|
||||
f"{minutes} minutes is not a whole hour; /etc/shutdown-schedule.conf "
|
||||
"stores hours only"
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return hours
|
||||
|
||||
|
||||
def ledger_file(earner: earned_time.Earner) -> Path:
|
||||
"""Where ``earner``'s gate keeps its ledger."""
|
||||
if earner.ledger is None:
|
||||
msg = f"earner {earner.name!r} has no ledger"
|
||||
raise ValueError(msg)
|
||||
return LEDGER_HOME / earner.ledger
|
||||
|
||||
|
||||
def flat_earners() -> tuple[earned_time.Earner, ...]:
|
||||
"""The once-per-day, ledger-backed earners (the workout is counted here)."""
|
||||
return tuple(
|
||||
e for e in earned_time.EARNERS if e.kind == "flat" and e.ledger is not None
|
||||
)
|
||||
|
||||
|
||||
def earned_today(
|
||||
earner: earned_time.Earner, now: datetime | None = None
|
||||
) -> bool | None:
|
||||
"""Whether ``earner``'s gate recorded today's credit; ``None`` if unknown."""
|
||||
return earned_time.done_today(earner, ledger_file(earner), HMAC_KEY_FILE, now=now)
|
||||
|
||||
|
||||
def flat_answers() -> dict[str, bool | None]:
|
||||
"""Today's answer from every flat earner, an unknown one logged."""
|
||||
answers: dict[str, bool | None] = {}
|
||||
for earner in flat_earners():
|
||||
answer = earned_today(earner)
|
||||
if answer is None:
|
||||
_logger.warning(
|
||||
"%s state could not be checked; no shutdown time for it",
|
||||
earner.label,
|
||||
)
|
||||
answers[earner.name] = answer
|
||||
return answers
|
||||
@ -1,183 +0,0 @@
|
||||
"""One extra shutdown hour on a day with an accepted LeetCode submission.
|
||||
|
||||
leetcode-guard never writes the shutdown config itself; it only publishes the
|
||||
fact through its ledger, the same way ``steam-backlog-enforcer`` reads it for
|
||||
an hour of gaming budget. The write has to live here because
|
||||
:func:`~screen_locker._shutdown_base.reset_to_base_if_new_day` recomputes the
|
||||
config from scratch on every new day -- a bonus written by another program
|
||||
would be wiped by that reset, exactly as a pre-tick workout credit was on
|
||||
2026-09-13. So the hour is a *term* of the derived target, not an increment
|
||||
that survives by luck.
|
||||
|
||||
Three things are pinned deliberately, mirroring leetcode-guard's own
|
||||
``solved_today`` contract:
|
||||
|
||||
**Only ``credit`` entries count.** A ``seen`` entry is first-run seeding worth
|
||||
zero, and a ``charge`` proves only that the day was *settled* -- which also
|
||||
happens from banked credit, from the escape hatch and from a classified
|
||||
outage, so it can never stand in for a solve.
|
||||
|
||||
**The solve time is ``detail["submitted_at"]``, not ``day``.** ``day`` is the
|
||||
*harvesting* run's date, and a problem solved at 23:50 and harvested the next
|
||||
morning carries the next day's key. It earns for the day it was solved on,
|
||||
which by then is over -- never for the next one.
|
||||
|
||||
**"Cannot check" is not "not solved".** An unreadable ledger or key yields
|
||||
``None``, and the caller fails closed to no hour. Failing open would let the
|
||||
coupling be defeated by deleting a file.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, time
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Final
|
||||
|
||||
from gatelock.log_integrity import verify_entry_hmac
|
||||
|
||||
from screen_locker._constants import HMAC_KEY_FILE
|
||||
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
|
||||
# Flat, once per day, regardless of how many problems were solved -- the
|
||||
# workout is the harder thing and keeps the bigger, per-session reward.
|
||||
LEETCODE_BONUS_HOURS: Final = 1
|
||||
|
||||
# leetcode-guard's real ledger (its DATA_DIR / "ledger.json"). Read-only from
|
||||
# here; resolved at call time so the test suite's path redirect applies.
|
||||
LEETCODE_LEDGER_FILE: Final = Path.home() / ".local/share/leetcode_guard/ledger.json"
|
||||
|
||||
_CREDIT: Final = "credit"
|
||||
|
||||
|
||||
def _today_window(now: datetime | None = None) -> tuple[float, float]:
|
||||
"""Local midnight and now, as unix seconds.
|
||||
|
||||
Args:
|
||||
now: Stand-in for the current local time, for tests.
|
||||
|
||||
Returns:
|
||||
The inclusive window a solve must fall in to count for today.
|
||||
"""
|
||||
moment = (now or datetime.now(tz=UTC)).astimezone()
|
||||
midnight = datetime.combine(moment.date(), time.min, moment.tzinfo)
|
||||
return midnight.timestamp(), moment.timestamp()
|
||||
|
||||
|
||||
def _landed_today(entry: dict[str, object], *, window: tuple[float, float]) -> bool:
|
||||
"""Whether this verified credit's accepted submission happened today.
|
||||
|
||||
Args:
|
||||
entry: One ledger entry, already known to be a verified credit.
|
||||
window: Local midnight and now, as unix seconds.
|
||||
|
||||
Returns:
|
||||
Whether the solve falls inside today. Without a usable
|
||||
``submitted_at`` the harvest ``day`` is the fallback: it runs late,
|
||||
never early, so it can miss a late solve but never invent one.
|
||||
"""
|
||||
detail = entry.get("detail")
|
||||
raw = detail.get("submitted_at") if isinstance(detail, dict) else None
|
||||
if raw is not None:
|
||||
try:
|
||||
stamp = float(str(raw))
|
||||
except ValueError:
|
||||
_logger.warning(
|
||||
"LeetCode credit %r has an unparsable submitted_at (%r); "
|
||||
"falling back to its day key",
|
||||
entry.get("entry_id"),
|
||||
raw,
|
||||
)
|
||||
else:
|
||||
start, end = window
|
||||
return start <= stamp <= end
|
||||
start, _ = window
|
||||
today = datetime.fromtimestamp(start).astimezone().date().isoformat()
|
||||
return entry.get("day") == today
|
||||
|
||||
|
||||
def _read_entries(ledger_path: Path, 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 (or book-guard's) ledger file.
|
||||
label: Whose ledger, for the log line.
|
||||
|
||||
Returns:
|
||||
The raw entries, or ``None`` -- which is never "no entries".
|
||||
"""
|
||||
try:
|
||||
raw = json.loads(ledger_path.read_text(encoding="utf-8"))
|
||||
except OSError as exc:
|
||||
_logger.warning("Cannot read the %s ledger at %s (%s)", label, ledger_path, exc)
|
||||
return None
|
||||
except ValueError as exc:
|
||||
_logger.warning(
|
||||
"%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("%s ledger at %s has no entries array", label, ledger_path)
|
||||
return None
|
||||
return rows
|
||||
|
||||
|
||||
def leetcode_solved_today(
|
||||
ledger_path: Path | None = None,
|
||||
*,
|
||||
now: datetime | None = None,
|
||||
) -> bool | None:
|
||||
"""Whether a verified accepted submission landed today, locally.
|
||||
|
||||
Args:
|
||||
ledger_path: The ledger to read. None resolves
|
||||
:data:`LEETCODE_LEDGER_FILE` at call time.
|
||||
now: Stand-in for the current local time, for tests.
|
||||
|
||||
Returns:
|
||||
True or False when the ledger could be read and verified, ``None``
|
||||
when it could not -- which is never "nothing was solved".
|
||||
"""
|
||||
target = LEETCODE_LEDGER_FILE if ledger_path is None else ledger_path
|
||||
try:
|
||||
key_usable = bool(HMAC_KEY_FILE.read_bytes().strip())
|
||||
except OSError as exc:
|
||||
_logger.warning("Cannot read the integrity key at %s (%s)", HMAC_KEY_FILE, exc)
|
||||
return None
|
||||
if not key_usable:
|
||||
_logger.warning("Integrity key at %s is empty", HMAC_KEY_FILE)
|
||||
return None
|
||||
|
||||
rows = _read_entries(target)
|
||||
if rows is None:
|
||||
return None
|
||||
|
||||
window = _today_window(now)
|
||||
return any(
|
||||
isinstance(row, dict)
|
||||
and row.get("kind") == _CREDIT
|
||||
and verify_entry_hmac(row, key_file=HMAC_KEY_FILE)
|
||||
and _landed_today(row, window=window)
|
||||
for row in rows
|
||||
)
|
||||
|
||||
|
||||
def leetcode_bonus_hours(ledger_path: Path | None = None) -> int:
|
||||
"""The shutdown hours today's LeetCode solve has earned, failing closed.
|
||||
|
||||
Args:
|
||||
ledger_path: The ledger to read. None resolves the real one.
|
||||
|
||||
Returns:
|
||||
:data:`LEETCODE_BONUS_HOURS` on a day with a verified solve, else 0.
|
||||
A ledger that cannot be checked is 0 too, and logged -- it must never
|
||||
be mistaken for a confident "not solved", but it earns nothing either.
|
||||
"""
|
||||
solved = leetcode_solved_today(ledger_path)
|
||||
if solved is None:
|
||||
_logger.warning("LeetCode solve state could not be checked; no shutdown hour")
|
||||
return 0
|
||||
return LEETCODE_BONUS_HOURS if solved else 0
|
||||
@ -1,106 +0,0 @@
|
||||
"""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,23 +1,24 @@
|
||||
"""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`, 19:00) so that the day's bonuses always layer on top of a
|
||||
known floor rather than accumulating indefinitely across days.
|
||||
On each new calendar day the shutdown config is reset to the day's base 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
|
||||
and read back on every reset, which made the code's default dead: the file
|
||||
kept re-writing whatever it was installed with, so changing the constant
|
||||
changed nothing on the machine. The state file now carries only date stamps.
|
||||
The base and every bonus come from the shared earner registry
|
||||
(``earned_time``, see :mod:`screen_locker._earned`): 20:00 before any penalty,
|
||||
minus each earner's cut once its ``penalty_from`` day arrives (book-guard's
|
||||
reading hour, from 2026-10-01). Nothing here is state. The base used to be
|
||||
persisted in the state file and read back on every reset, which made the
|
||||
code's default dead; the 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, 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
|
||||
bar at 21:00 with a counted workout on disk. Any bonus source that is not a
|
||||
term of this derivation has that bug.
|
||||
in one go: the workout hours from today's log and every flat earner's hour
|
||||
from its gate's ledger. Live credits are read-add-write against the config,
|
||||
so a workout credited between local midnight and the first locker tick of the
|
||||
day (a manual log at 00:02, say) had its hours wiped by the reset that
|
||||
followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on
|
||||
disk. Any bonus source that is not a term of this derivation has that bug;
|
||||
registering it in ``earned_time`` makes it one.
|
||||
|
||||
The sick-day state file is cleared on reset so the sick-restore path cannot
|
||||
overwrite the fresh base when it runs later in the same startup.
|
||||
@ -30,61 +31,46 @@ import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._leetcode_bonus import LEETCODE_BONUS_HOURS, leetcode_bonus_hours
|
||||
from screen_locker._earned import earned_today, flat_answers, flat_earners, to_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, 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."""
|
||||
"""The base for ``day`` (default today), every penalty in force taken off."""
|
||||
target = day or datetime.now().astimezone().date()
|
||||
return BASE_HOUR if target >= READING_BASE_FROM else BASE_HOUR + 1
|
||||
return to_hours(earned_time.base_for(target, earned_time.EARNERS).shutdown_minutes)
|
||||
|
||||
|
||||
# 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 _stamp(earner: earned_time.Earner) -> str:
|
||||
"""The once-per-day key of ``earner``'s live pass in the state file."""
|
||||
return f"{earner.name}_bonus_date"
|
||||
|
||||
|
||||
def today_earned_bonus_hours(log_file: Path, today: str) -> int:
|
||||
"""Return the shutdown hours today's logged workouts have already earned.
|
||||
def today_credit_count(log_file: Path, today: str) -> int:
|
||||
"""Return how many distinct workouts today's log counts.
|
||||
|
||||
Counted with the same :func:`~screen_locker._weekly_check.count_day_credits`
|
||||
rule as the weekly total, so a workout recorded twice earns once.
|
||||
"""
|
||||
entries = load_workout_log(log_file).get(today, [])
|
||||
credit_count = count_day_credits(today, [e for e in entries if isinstance(e, dict)])
|
||||
return earned_shutdown_bonus_hours(credit_count)
|
||||
return count_day_credits(today, [e for e in entries if isinstance(e, dict)])
|
||||
|
||||
|
||||
def _load_state(state_file: Path) -> dict[str, Any]:
|
||||
"""Return the reset state, or an empty dict when it is missing or corrupt.
|
||||
|
||||
A corrupt file is logged and treated as "nothing stamped": the reset then
|
||||
runs again, which is idempotent, and the LeetCode hour is re-applied at
|
||||
worst once, capped by the ceiling.
|
||||
runs again, which is idempotent, and a flat hour is re-applied at worst
|
||||
once, capped by the ceiling.
|
||||
"""
|
||||
if not state_file.exists():
|
||||
return {}
|
||||
@ -130,9 +116,9 @@ def reset_to_base_if_new_day(
|
||||
) -> bool:
|
||||
"""Reset the shutdown config to the base hour if a new calendar day began.
|
||||
|
||||
Writes :data:`BASE_HOUR` plus whatever today has already earned -- the
|
||||
workout hours in *log_file* (see :func:`today_earned_bonus_hours`) and the
|
||||
LeetCode hour -- via *mixin*._write_shutdown_config (with restore=True so
|
||||
Writes the base plus whatever today has already earned -- the workout
|
||||
hours in *log_file* (see :func:`today_credit_count`) and every flat
|
||||
earner's hour -- via *mixin*._write_shutdown_config (with restore=True so
|
||||
the script allows moving the time earlier), stamps ``last_reset_date`` in
|
||||
*state_file*, and removes *sick_day_state_file* if it exists so the
|
||||
sick-restore path does not fight with the fresh base on the same startup.
|
||||
@ -143,11 +129,16 @@ def reset_to_base_if_new_day(
|
||||
if _load_state(state_file).get("last_reset_date") == today:
|
||||
return False
|
||||
|
||||
earned = today_earned_bonus_hours(log_file, today) if log_file is not None else 0
|
||||
leetcode = leetcode_bonus_hours()
|
||||
reading = reading_bonus_hours()
|
||||
base = base_hour()
|
||||
target = min(_RESTORE_CEILING_HOUR, base + earned + leetcode + reading)
|
||||
answers: dict[str, int | bool | None] = dict(flat_answers())
|
||||
answers[earned_time.WORKOUT.name] = (
|
||||
today_credit_count(log_file, today) if log_file is not None else 0
|
||||
)
|
||||
# The registry is passed explicitly: flat_answers iterated this same
|
||||
# tuple, so the earners asked and the earners summed can never differ.
|
||||
day = earned_time.resolve(
|
||||
answers, datetime.now().astimezone().date(), earned_time.EARNERS
|
||||
)
|
||||
target = to_hours(day.shutdown_minutes)
|
||||
|
||||
# Preserve the morning-end hour from the live config.
|
||||
config = mixin._read_shutdown_config()
|
||||
@ -163,20 +154,19 @@ def reset_to_base_if_new_day(
|
||||
# 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
|
||||
for term in day.terms:
|
||||
if term.earner.kind == "flat" and term.shutdown_minutes:
|
||||
new_state[_stamp(term.earner)] = today
|
||||
_save_state(state_file, new_state)
|
||||
|
||||
earned = " + ".join(
|
||||
f"{to_hours(t.shutdown_minutes)}h {t.earner.label}" for t in day.terms
|
||||
)
|
||||
_logger.info(
|
||||
"Daily base reset: %02d:00 (base %d + %dh workout + %dh LeetCode + "
|
||||
"%dh reading already earned today).",
|
||||
"Daily base reset: %02d:00 (base %d + %s already earned today).",
|
||||
target,
|
||||
base,
|
||||
to_hours(day.base.shutdown_minutes),
|
||||
earned,
|
||||
leetcode,
|
||||
reading,
|
||||
)
|
||||
return True
|
||||
|
||||
@ -184,60 +174,46 @@ def reset_to_base_if_new_day(
|
||||
def _apply_flat_bonus(
|
||||
state_file: Path,
|
||||
mixin: object,
|
||||
*,
|
||||
stamp: str,
|
||||
hours: Callable[[], int],
|
||||
label: str,
|
||||
earner: earned_time.Earner,
|
||||
) -> bool:
|
||||
"""Push shutdown later by a once-per-day flat hour, if it is earned.
|
||||
"""Push shutdown later by one earner's once-per-day hour, if it is earned.
|
||||
|
||||
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
|
||||
the earner's stamp in *state_file*, which the reset sets as well when it
|
||||
already included the hour.
|
||||
|
||||
Returns True if the hour was applied on this call.
|
||||
"""
|
||||
today = today_str()
|
||||
state = _load_state(state_file)
|
||||
stamp = _stamp(earner)
|
||||
if state.get(stamp) == today:
|
||||
return False
|
||||
earned = hours()
|
||||
if earned == 0:
|
||||
answer = earned_today(earner)
|
||||
if answer is None:
|
||||
_logger.warning(
|
||||
"%s state could not be checked; no shutdown time for it", earner.label
|
||||
)
|
||||
return False
|
||||
if not mixin._adjust_shutdown_time_by(earned):
|
||||
_logger.warning("%s bonus: failed to write shutdown config.", label)
|
||||
if not answer:
|
||||
return False
|
||||
hours = to_hours(earner.shutdown_minutes)
|
||||
if not mixin._adjust_shutdown_time_by(hours):
|
||||
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
|
||||
return False
|
||||
state[stamp] = today
|
||||
_save_state(state_file, state)
|
||||
_logger.info("%s bonus: +%dh shutdown time today.", label, earned)
|
||||
_logger.info("%s bonus: +%dh shutdown time today.", earner.label, hours)
|
||||
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)
|
||||
"""Every once-per-day flat hour, in registry order (LeetCode, reading, ...).
|
||||
|
||||
The workout is not among them: it is counted, and its live credit is
|
||||
applied by :class:`~screen_locker._workout_credit.WorkoutCreditMixin`.
|
||||
"""
|
||||
for earner in flat_earners():
|
||||
_apply_flat_bonus(state_file, mixin, earner)
|
||||
|
||||
@ -13,7 +13,10 @@ from dataclasses import dataclass
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker import _sick_tracker
|
||||
from screen_locker._earned import to_hours
|
||||
from screen_locker._weekly_check import (
|
||||
COUNTED_WORKOUT_TYPES,
|
||||
PC_WORKOUT_TYPE,
|
||||
@ -25,21 +28,19 @@ if TYPE_CHECKING:
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# The shutdown reward for a day's workouts: the first counted workout pushes
|
||||
# shutdown later by FIRST_WORKOUT_BONUS_HOURS, every further one by
|
||||
# EXTRA_WORKOUT_BONUS_HOURS. Both the live credit paths below and the daily
|
||||
# base reset (``_shutdown_base``) derive from these two numbers, so a day's
|
||||
# earned hours can be recomputed from the log instead of being lost when the
|
||||
# credit landed before the reset ran.
|
||||
FIRST_WORKOUT_BONUS_HOURS = 2
|
||||
EXTRA_WORKOUT_BONUS_HOURS = 1
|
||||
# The shutdown reward for a day's workouts, from the shared registry
|
||||
# (``earned_time.WORKOUT``): the first counted workout pushes shutdown later by
|
||||
# FIRST_WORKOUT_BONUS_HOURS, every further one by EXTRA_WORKOUT_BONUS_HOURS.
|
||||
# The live credit paths below and the daily base reset (``_shutdown_base``)
|
||||
# both derive from the registry, so a day's earned hours can be recomputed
|
||||
# from the log instead of being lost when the credit landed before the reset.
|
||||
FIRST_WORKOUT_BONUS_HOURS = to_hours(earned_time.WORKOUT.shutdown_minutes)
|
||||
EXTRA_WORKOUT_BONUS_HOURS = to_hours(earned_time.WORKOUT.extra_shutdown_minutes)
|
||||
|
||||
|
||||
def earned_shutdown_bonus_hours(credit_count: int) -> int:
|
||||
"""Return the shutdown hours a day with *credit_count* distinct workouts earned."""
|
||||
if credit_count <= 0:
|
||||
return 0
|
||||
return FIRST_WORKOUT_BONUS_HOURS + (credit_count - 1) * EXTRA_WORKOUT_BONUS_HOURS
|
||||
return to_hours(earned_time.WORKOUT.shutdown_for(credit_count))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
115
screen_locker/tests/_earned_fixtures.py
Normal file
115
screen_locker/tests/_earned_fixtures.py
Normal file
@ -0,0 +1,115 @@
|
||||
"""Shared helpers for the earner tests: signed ledgers and canned answers.
|
||||
|
||||
Ledgers are written where the code under test reads them -- under
|
||||
``_earned.LEDGER_HOME``, which the autouse redirect (``_isolated_state.py``)
|
||||
points at tmp_path -- and signed with ``earned_time.entry_signature`` against
|
||||
a temp key patched onto ``_earned.HMAC_KEY_FILE``. ``_earned`` holds its own
|
||||
binding of the key and the suite does not redirect it, so a test that reads a
|
||||
ledger without :func:`signing_key` would read the host's real key.
|
||||
|
||||
Plain helpers rather than fixtures, so test modules wrap them in their own
|
||||
fixtures instead of importing fixture names they never reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from datetime import datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any, Final
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker import _earned, _shutdown_base
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
KEY: Final = b"test-key-bytes"
|
||||
|
||||
|
||||
def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
|
||||
"""EXTRA's rule: any verified credit counts, whenever it landed."""
|
||||
del row, window
|
||||
return True
|
||||
|
||||
|
||||
# A flat earner the registry does not have yet, for tests that register one
|
||||
# (``monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))``)
|
||||
# to pin that nothing in screen-locker needs code for it.
|
||||
EXTRA: Final = earned_time.Earner(
|
||||
name="extra",
|
||||
label="Extra",
|
||||
gaming_minutes=60,
|
||||
shutdown_minutes=60,
|
||||
ledger=".local/share/extra_guard/ledger.json",
|
||||
match=_any_credit,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def signing_key(tmp_path: Path, content: bytes = KEY) -> Iterator[Path]:
|
||||
"""Write ``content`` as the HMAC key and point ``_earned`` at it."""
|
||||
key = tmp_path / "hmac.key"
|
||||
key.write_bytes(content)
|
||||
with patch.object(_earned, "HMAC_KEY_FILE", key):
|
||||
yield key
|
||||
|
||||
|
||||
def signed(body: dict[str, Any]) -> dict[str, Any]:
|
||||
"""``body`` plus the ``hmac`` a genuine gate would have written."""
|
||||
return {**body, "hmac": earned_time.entry_signature(body, KEY)}
|
||||
|
||||
|
||||
def write_ledger(earner: earned_time.Earner, entries: list[Any]) -> Path:
|
||||
"""Write ``entries`` as ``earner``'s ledger, where screen-locker reads it."""
|
||||
path = _earned.ledger_file(earner)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"entries": entries}), encoding="utf-8")
|
||||
return path
|
||||
|
||||
|
||||
def credit(earner: earned_time.Earner, when: datetime | None = None) -> dict[str, Any]:
|
||||
"""A signed credit row that counts for ``earner`` at ``when`` (default now).
|
||||
|
||||
Whole seconds, floored: a stamp can never land after the reader's "now".
|
||||
"""
|
||||
moment = when or datetime.now().astimezone()
|
||||
stamp = int(moment.timestamp())
|
||||
detail: dict[str, Any] = (
|
||||
{"bonus": "1", "ended_at": str(stamp)}
|
||||
if earner.name == earned_time.READING.name
|
||||
else {"submitted_at": stamp}
|
||||
)
|
||||
return signed(
|
||||
{
|
||||
"entry_id": f"{earner.name}:{stamp}",
|
||||
"kind": "credit",
|
||||
"day": moment.date().isoformat(),
|
||||
"detail": detail,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def answering(answers: dict[str, bool | None]) -> Iterator[MagicMock]:
|
||||
"""Every flat earner answers from ``answers`` (absent: ``False``).
|
||||
|
||||
``earned_today`` is bound twice -- the reset reaches it through
|
||||
``_earned.flat_answers``, the live pass through ``_shutdown_base`` -- so
|
||||
both names get the same mock. ``answers`` is read at call time; a test may
|
||||
change it after entering.
|
||||
"""
|
||||
|
||||
def _answer(earner: earned_time.Earner, now: datetime | None = None) -> bool | None:
|
||||
del now
|
||||
return answers.get(earner.name, False)
|
||||
|
||||
mock = MagicMock(side_effect=_answer)
|
||||
with (
|
||||
patch.object(_earned, "earned_today", mock),
|
||||
patch.object(_shutdown_base, "earned_today", mock),
|
||||
):
|
||||
yield mock
|
||||
@ -72,18 +72,20 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
"instance.lock",
|
||||
("_constants.INSTANCE_LOCK_FILE", "screen_lock.INSTANCE_LOCK_FILE"),
|
||||
),
|
||||
# leetcode-guard's ledger. Read-only from here, but a test must never see
|
||||
# the user's real solves: a real credit would earn a fake shutdown hour
|
||||
# inside an assertion written against an empty day.
|
||||
# The home every earner's ledger (leetcode-guard's, book-guard's, ...) is
|
||||
# resolved under. Read-only from here, but a test must never see the
|
||||
# user's real credits: one would earn a fake shutdown hour inside an
|
||||
# assertion written against an empty day.
|
||||
(
|
||||
"leetcode_ledger.json",
|
||||
("_leetcode_bonus.LEETCODE_LEDGER_FILE",),
|
||||
"ledger_home",
|
||||
("_earned.LEDGER_HOME",),
|
||||
),
|
||||
# book-guard's ledger, for the same reason: a real reading credit would
|
||||
# earn a fake shutdown hour inside an assertion.
|
||||
# The key those ledgers are verified with, as ``_earned`` binds it. Absent
|
||||
# by default, so a read no test asked for fails closed instead of using
|
||||
# the host's real key; earner tests write their own (_earned_fixtures).
|
||||
(
|
||||
"reading_ledger.json",
|
||||
("_reading_bonus.READING_LEDGER_FILE",),
|
||||
"earned_hmac.key",
|
||||
("_earned.HMAC_KEY_FILE",),
|
||||
),
|
||||
# wake-alarm's workday-stick file. A test that forgot this would read
|
||||
# the developer's real penalty state.
|
||||
|
||||
@ -20,6 +20,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from screen_locker._degraded_sources import clear_degraded_sources
|
||||
from screen_locker._earned import flat_earners
|
||||
from screen_locker.tests._gatelock_fixtures import (
|
||||
FAKE_OUTPUTS,
|
||||
TWO_OUTPUTS,
|
||||
@ -181,7 +182,8 @@ def _isolate_state_files(tmp_path: Path) -> Iterator[None]:
|
||||
yield
|
||||
|
||||
|
||||
_SEEDED_STAMPS = ("last_reset_date", "leetcode_bonus_date", "reading_bonus_date")
|
||||
# Every registered flat earner's live-pass stamp, so a future one is seeded too.
|
||||
_SEEDED_STAMPS = ("last_reset_date", *(f"{e.name}_bonus_date" for e in flat_earners()))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -191,9 +193,9 @@ 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 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.
|
||||
same as the rest of the suite already does. Every flat earner's stamp is
|
||||
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")
|
||||
|
||||
215
screen_locker/tests/test_earned.py
Normal file
215
screen_locker/tests/test_earned.py
Normal file
@ -0,0 +1,215 @@
|
||||
"""Tests for ``_earned``: where screen-locker reads each earner's ledger, and how.
|
||||
|
||||
Which rows count -- forged rows, yesterday's solve, an unparsable stamp -- is
|
||||
``earned_time``'s rule and is tested there. Pinned here is screen-locker's
|
||||
side of it: the ledger lives under ``LEDGER_HOME``, the key is
|
||||
``HMAC_KEY_FILE``, an unreadable one fails closed to ``None`` and is logged,
|
||||
and the schedule's hours are whole.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _constants, _earned
|
||||
from screen_locker._earned import (
|
||||
earned_today,
|
||||
flat_answers,
|
||||
flat_earners,
|
||||
ledger_file,
|
||||
to_hours,
|
||||
)
|
||||
from screen_locker.tests._earned_fixtures import (
|
||||
EXTRA,
|
||||
credit,
|
||||
signing_key,
|
||||
write_ledger,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_NOW = datetime(2026, 10, 2, 14, 0).astimezone()
|
||||
_LEDGERED = pytest.mark.parametrize(
|
||||
"earner", [earned_time.LEETCODE, earned_time.READING], ids=lambda e: e.name
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key(tmp_path: Path) -> Iterator[Path]:
|
||||
with signing_key(tmp_path) as path:
|
||||
yield path
|
||||
|
||||
|
||||
class TestToHours:
|
||||
def test_whole_hours_convert(self) -> None:
|
||||
assert to_hours(0) == 0
|
||||
assert to_hours(120) == 2
|
||||
|
||||
def test_a_remainder_raises_instead_of_flooring(self) -> None:
|
||||
"""A 30-minute earner must not silently become 0 hours here."""
|
||||
with pytest.raises(ValueError, match="not a whole hour"):
|
||||
to_hours(90)
|
||||
|
||||
|
||||
class TestLedgerFile:
|
||||
@_LEDGERED
|
||||
def test_resolves_under_ledger_home(self, earner: earned_time.Earner) -> None:
|
||||
assert earner.ledger is not None
|
||||
assert ledger_file(earner) == _earned.LEDGER_HOME / earner.ledger
|
||||
|
||||
def test_ledger_home_and_key_are_redirected_for_the_suite(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
assert tmp_path / "ledger_home" == _earned.LEDGER_HOME
|
||||
assert tmp_path / "earned_hmac.key" == _earned.HMAC_KEY_FILE
|
||||
assert not _earned.HMAC_KEY_FILE.exists()
|
||||
|
||||
def test_suite_never_sees_a_real_ledger(self) -> None:
|
||||
assert not any(ledger_file(e).exists() for e in flat_earners())
|
||||
|
||||
def test_earner_without_a_ledger_raises(self) -> None:
|
||||
with pytest.raises(ValueError, match="has no ledger"):
|
||||
ledger_file(earned_time.WORKOUT)
|
||||
|
||||
|
||||
class TestFlatEarners:
|
||||
def test_registry_order_without_the_counted_workout(self) -> None:
|
||||
assert flat_earners() == (earned_time.LEETCODE, earned_time.READING)
|
||||
|
||||
def test_a_newly_registered_earner_is_picked_up(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
|
||||
assert flat_earners()[-1] is EXTRA
|
||||
|
||||
def test_a_flat_earner_without_a_ledger_is_left_out(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""No ledger means no shared reader: its answer is the consumer's."""
|
||||
unledgered = earned_time.Earner(
|
||||
name="manual", label="manual", gaming_minutes=60, shutdown_minutes=60
|
||||
)
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, unledgered))
|
||||
assert unledgered not in flat_earners()
|
||||
|
||||
def test_conftest_seeds_every_flat_earners_stamp(self) -> None:
|
||||
"""So the live pass never reads a ledger (or the real key) by default."""
|
||||
seeded = json.loads(_constants.SHUTDOWN_BASE_FILE.read_text())
|
||||
for earner in flat_earners():
|
||||
assert f"{earner.name}_bonus_date" in seeded
|
||||
|
||||
|
||||
class TestEarnedToday:
|
||||
@_LEDGERED
|
||||
def test_signed_credit_today_earns(
|
||||
self, key: Path, earner: earned_time.Earner
|
||||
) -> None:
|
||||
write_ledger(earner, [credit(earner)])
|
||||
assert earned_today(earner) is True
|
||||
|
||||
@_LEDGERED
|
||||
def test_now_is_passed_through(self, key: Path, earner: earned_time.Earner) -> None:
|
||||
write_ledger(earner, [credit(earner, _NOW - timedelta(hours=1))])
|
||||
assert earned_today(earner, now=_NOW) is True
|
||||
assert earned_today(earner, now=_NOW - timedelta(hours=2)) is False
|
||||
assert earned_today(earner, now=_NOW + timedelta(days=1)) is False
|
||||
|
||||
@_LEDGERED
|
||||
def test_rows_are_verified_against_hmac_key_file(
|
||||
self, tmp_path: Path, earner: earned_time.Earner
|
||||
) -> None:
|
||||
"""Signed with another key than the one on disk: a forgery, no time."""
|
||||
write_ledger(earner, [credit(earner)])
|
||||
with signing_key(tmp_path, b"another-key"):
|
||||
assert earned_today(earner) is False
|
||||
|
||||
@_LEDGERED
|
||||
def test_a_ledger_outside_ledger_home_is_never_read(
|
||||
self, tmp_path: Path, key: Path, earner: earned_time.Earner
|
||||
) -> None:
|
||||
stray = tmp_path / "ledger.json"
|
||||
stray.write_text(json.dumps({"entries": [credit(earner)]}))
|
||||
assert earned_today(earner) is not True
|
||||
|
||||
|
||||
class TestCannotCheck:
|
||||
"""Every unreadable state is None -- never a confident False -- and logged."""
|
||||
|
||||
@_LEDGERED
|
||||
def test_missing_key(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
earner: earned_time.Earner,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""The key is checked first: without it even "no ledger" is unknown."""
|
||||
monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert earned_today(earner) is None
|
||||
assert "Cannot read the integrity key" in caplog.text
|
||||
|
||||
def test_empty_key(self, tmp_path: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)])
|
||||
with signing_key(tmp_path, b" \n"), caplog.at_level(logging.WARNING):
|
||||
assert earned_today(earned_time.LEETCODE) is None
|
||||
assert "is empty" in caplog.text
|
||||
|
||||
def test_missing_leetcode_ledger_is_unknown(
|
||||
self, key: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert earned_today(earned_time.LEETCODE) is None
|
||||
assert "Cannot read the LeetCode ledger" in caplog.text
|
||||
|
||||
def test_missing_reading_ledger_is_a_plain_no(self, key: Path) -> None:
|
||||
"""book-guard never ran: no reading, but no fault either."""
|
||||
assert earned_today(earned_time.READING) is False
|
||||
|
||||
@_LEDGERED
|
||||
def test_corrupt_ledger(
|
||||
self,
|
||||
key: Path,
|
||||
earner: earned_time.Earner,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
path = ledger_file(earner)
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_text("{not json")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert earned_today(earner) is None
|
||||
assert "not valid JSON" in caplog.text
|
||||
|
||||
|
||||
class TestFlatAnswers:
|
||||
def test_every_flat_earner_answers_by_name(self, key: Path) -> None:
|
||||
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)])
|
||||
assert flat_answers() == {"leetcode": True, "reading": False}
|
||||
|
||||
def test_an_unknown_answer_is_none_and_warned(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
monkeypatch.setattr(_earned, "HMAC_KEY_FILE", tmp_path / "no.key")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert flat_answers() == {"leetcode": None, "reading": None}
|
||||
assert "LeetCode state could not be checked" in caplog.text
|
||||
assert "reading state could not be checked" in caplog.text
|
||||
|
||||
def test_a_no_is_not_warned(
|
||||
self, key: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
write_ledger(earned_time.LEETCODE, [])
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert flat_answers() == {"leetcode": False, "reading": False}
|
||||
assert "could not be checked" not in caplog.text
|
||||
@ -1,195 +0,0 @@
|
||||
"""Tests for the LeetCode shutdown hour: reading the ledger, failing closed.
|
||||
|
||||
Entries are signed against a temp key with gatelock's own ``compute_entry_hmac``
|
||||
so a forged credit and a genuine one differ in exactly the way they do on the
|
||||
machine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from gatelock.log_integrity import compute_entry_hmac
|
||||
import pytest
|
||||
|
||||
from screen_locker import _leetcode_bonus
|
||||
from screen_locker._leetcode_bonus import (
|
||||
LEETCODE_BONUS_HOURS,
|
||||
leetcode_bonus_hours,
|
||||
leetcode_solved_today,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NOW = datetime(2026, 9, 15, 14, 0).astimezone()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_file(tmp_path: Path) -> Path:
|
||||
key = tmp_path / "hmac.key"
|
||||
key.write_bytes(b"test-key-bytes")
|
||||
with patch.object(_leetcode_bonus, "HMAC_KEY_FILE", key):
|
||||
yield key
|
||||
|
||||
|
||||
def _entry(
|
||||
kind: str, *, submitted_at: object, key: Path, day: str = "2026-09-15"
|
||||
) -> dict[str, Any]:
|
||||
body: dict[str, Any] = {
|
||||
"entry_id": f"{kind}:{submitted_at}",
|
||||
"kind": kind,
|
||||
"day": day,
|
||||
"detail": {"submitted_at": submitted_at},
|
||||
}
|
||||
return {**body, "hmac": compute_entry_hmac(body, key_file=key)}
|
||||
|
||||
|
||||
def _ledger(tmp_path: Path, entries: list[Any]) -> Path:
|
||||
path = tmp_path / "ledger.json"
|
||||
path.write_text(json.dumps({"entries": entries}))
|
||||
return path
|
||||
|
||||
|
||||
class TestSolvedToday:
|
||||
def test_verified_credit_today_is_solved(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
stamp = (_NOW - timedelta(hours=2)).timestamp()
|
||||
ledger = _ledger(tmp_path, [_entry("credit", submitted_at=stamp, key=key_file)])
|
||||
assert leetcode_solved_today(ledger, now=_NOW) is True
|
||||
|
||||
def test_credit_from_yesterday_is_not_solved(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
"""A 23:50 solve harvested this morning counts for yesterday, never today."""
|
||||
stamp = (_NOW - timedelta(days=1)).timestamp()
|
||||
ledger = _ledger(tmp_path, [_entry("credit", submitted_at=stamp, key=key_file)])
|
||||
assert leetcode_solved_today(ledger, now=_NOW) is False
|
||||
|
||||
def test_seen_and_charge_entries_never_count(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
stamp = _NOW.timestamp()
|
||||
ledger = _ledger(
|
||||
tmp_path,
|
||||
[
|
||||
_entry("seen", submitted_at=stamp, key=key_file),
|
||||
_entry("charge", submitted_at=stamp, key=key_file),
|
||||
"not-a-dict",
|
||||
],
|
||||
)
|
||||
assert leetcode_solved_today(ledger, now=_NOW) is False
|
||||
|
||||
def test_forged_credit_is_ignored(self, tmp_path: Path, key_file: Path) -> None:
|
||||
entry = _entry("credit", submitted_at=_NOW.timestamp(), key=key_file)
|
||||
entry["detail"]["submitted_at"] = (
|
||||
_NOW.timestamp() - 1
|
||||
) # body no longer matches hmac
|
||||
ledger = _ledger(tmp_path, [entry])
|
||||
assert leetcode_solved_today(ledger, now=_NOW) is False
|
||||
|
||||
def test_unparsable_submitted_at_falls_back_to_day_key(
|
||||
self, tmp_path: Path, key_file: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
ledger = _ledger(
|
||||
tmp_path,
|
||||
[
|
||||
_entry(
|
||||
"credit",
|
||||
submitted_at="soon",
|
||||
key=key_file,
|
||||
day=_NOW.date().isoformat(),
|
||||
)
|
||||
],
|
||||
)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert leetcode_solved_today(ledger, now=_NOW) is True
|
||||
assert "unparsable submitted_at" in caplog.text
|
||||
|
||||
def test_missing_submitted_at_uses_day_key(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
body: dict[str, Any] = {"entry_id": "x", "kind": "credit", "day": "2000-01-01"}
|
||||
entry = {**body, "hmac": compute_entry_hmac(body, key_file=key_file)}
|
||||
assert leetcode_solved_today(_ledger(tmp_path, [entry]), now=_NOW) is False
|
||||
|
||||
def test_resolves_module_path_when_none_given(
|
||||
self, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
stamp = _NOW.timestamp()
|
||||
ledger = _ledger(tmp_path, [_entry("credit", submitted_at=stamp, key=key_file)])
|
||||
with patch.object(_leetcode_bonus, "LEETCODE_LEDGER_FILE", ledger):
|
||||
assert leetcode_solved_today(now=_NOW) is True
|
||||
|
||||
|
||||
class TestCannotCheck:
|
||||
"""Every unreadable state is None -- never a confident False."""
|
||||
|
||||
def test_missing_ledger(self, tmp_path: Path, key_file: Path) -> None:
|
||||
assert leetcode_solved_today(tmp_path / "missing.json") is None
|
||||
|
||||
def test_invalid_json(self, tmp_path: Path, key_file: Path) -> None:
|
||||
path = tmp_path / "ledger.json"
|
||||
path.write_text("{not json")
|
||||
assert leetcode_solved_today(path) is None
|
||||
|
||||
def test_no_entries_array(self, tmp_path: Path, key_file: Path) -> None:
|
||||
path = tmp_path / "ledger.json"
|
||||
path.write_text(json.dumps({"entries": "nope"}))
|
||||
assert leetcode_solved_today(path) is None
|
||||
|
||||
def test_unreadable_key(self, tmp_path: Path) -> None:
|
||||
with patch.object(_leetcode_bonus, "HMAC_KEY_FILE", tmp_path / "no.key"):
|
||||
assert leetcode_solved_today(_ledger(tmp_path, [])) is None
|
||||
|
||||
def test_empty_key(self, tmp_path: Path) -> None:
|
||||
key = tmp_path / "hmac.key"
|
||||
key.write_bytes(b" \n")
|
||||
with patch.object(_leetcode_bonus, "HMAC_KEY_FILE", key):
|
||||
assert leetcode_solved_today(_ledger(tmp_path, [])) is None
|
||||
|
||||
|
||||
class TestBonusHours:
|
||||
def test_solved_earns_the_hour(self) -> None:
|
||||
with patch.object(_leetcode_bonus, "leetcode_solved_today", return_value=True):
|
||||
assert leetcode_bonus_hours() == LEETCODE_BONUS_HOURS
|
||||
|
||||
def test_not_solved_earns_nothing(self) -> None:
|
||||
with patch.object(_leetcode_bonus, "leetcode_solved_today", return_value=False):
|
||||
assert leetcode_bonus_hours() == 0
|
||||
|
||||
def test_cannot_check_fails_closed_and_warns(
|
||||
self, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with (
|
||||
patch.object(_leetcode_bonus, "leetcode_solved_today", return_value=None),
|
||||
caplog.at_level(logging.WARNING),
|
||||
):
|
||||
assert leetcode_bonus_hours() == 0
|
||||
assert "could not be checked" in caplog.text
|
||||
|
||||
|
||||
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
|
||||
@ -1,214 +0,0 @@
|
||||
"""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
|
||||
@ -17,7 +17,7 @@ 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,
|
||||
today_credit_count,
|
||||
)
|
||||
from screen_locker._weekly_check import (
|
||||
count_day_credits,
|
||||
@ -124,23 +124,24 @@ class TestSyncedCopySharesCredit:
|
||||
assert credit_key(_DAY, 0, entry, {"42": 5}) == ("manual_workout", f"{_DAY}#0")
|
||||
|
||||
|
||||
class TestTodayEarnedBonusHours:
|
||||
"""The log → hours derivation the reset relies on."""
|
||||
class TestTodayCreditCount:
|
||||
"""The log -> credit count the reset hands to the registry."""
|
||||
|
||||
def test_real_day_earns_two_hours(self, tmp_path: Path) -> None:
|
||||
def test_real_day_counts_one_workout_worth_two_hours(self, tmp_path: Path) -> None:
|
||||
log = _write_log(tmp_path / "log.json", _DAY, _real_2026_09_13())
|
||||
assert today_earned_bonus_hours(log, _DAY) == 2
|
||||
assert today_credit_count(log, _DAY) == 1
|
||||
assert earned_shutdown_bonus_hours(today_credit_count(log, _DAY)) == 2
|
||||
|
||||
def test_day_absent_from_log_earns_nothing(self, tmp_path: Path) -> None:
|
||||
def test_day_absent_from_log_counts_nothing(self, tmp_path: Path) -> None:
|
||||
log = _write_log(tmp_path / "log.json", "2026-09-12", _real_2026_09_13())
|
||||
assert today_earned_bonus_hours(log, _DAY) == 0
|
||||
assert today_credit_count(log, _DAY) == 0
|
||||
|
||||
def test_non_dict_entries_are_ignored(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log.json"
|
||||
log.write_text(
|
||||
json.dumps({_DAY: ["garbage", _manual("manual:2026-09-13T08:00")]})
|
||||
)
|
||||
assert today_earned_bonus_hours(log, _DAY) == 2
|
||||
assert today_credit_count(log, _DAY) == 1
|
||||
|
||||
|
||||
class TestResetReplaysTodaysCredit:
|
||||
|
||||
178
screen_locker/tests/test_shutdown_flat_earners.py
Normal file
178
screen_locker/tests/test_shutdown_flat_earners.py
Normal file
@ -0,0 +1,178 @@
|
||||
"""Tests for the pass over every flat earner, and for the base it projects.
|
||||
|
||||
The registry, not this repo, lists the earners: a newly registered flat
|
||||
earner must be a term of the reset and an hour of the live pass with no code
|
||||
here. Also covers one real signed ledger end to end, and the status
|
||||
projection following the 2026-10-01 reading cut.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_base import (
|
||||
apply_flat_bonuses_if_new,
|
||||
base_hour,
|
||||
reset_to_base_if_new_day,
|
||||
)
|
||||
from screen_locker._status_data import gather_status
|
||||
from screen_locker.tests._earned_fixtures import (
|
||||
EXTRA,
|
||||
answering,
|
||||
credit,
|
||||
signing_key,
|
||||
write_ledger,
|
||||
)
|
||||
from screen_locker.tests.test_status_data import _files
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _mixin() -> 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 = True
|
||||
return mixin
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def answers() -> Iterator[dict[str, bool | None]]:
|
||||
"""Every flat earner says no until a test sets it."""
|
||||
given: dict[str, bool | None] = {"leetcode": False, "reading": False}
|
||||
with answering(given):
|
||||
yield given
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def with_extra(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Register EXTRA after the existing earners, as a new gate would be."""
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
|
||||
|
||||
|
||||
class TestFlatBonuses:
|
||||
def test_both_earned_apply_both_and_keep_both_stamps(
|
||||
self, tmp_path: Path, answers: dict[str, bool | None]
|
||||
) -> None:
|
||||
"""The second save must not overwrite the first one's stamp."""
|
||||
answers["leetcode"] = True
|
||||
answers["reading"] = True
|
||||
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, answers: dict[str, bool | None]
|
||||
) -> None:
|
||||
answers["reading"] = True
|
||||
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()}
|
||||
|
||||
|
||||
@pytest.mark.usefixtures("with_extra")
|
||||
class TestANewlyRegisteredEarner:
|
||||
def test_reset_includes_and_stamps_it(
|
||||
self, tmp_path: Path, answers: dict[str, bool | None]
|
||||
) -> None:
|
||||
answers["extra"] = True
|
||||
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(),
|
||||
"extra_bonus_date": today_str(),
|
||||
}
|
||||
|
||||
def test_live_pass_applies_it_once(
|
||||
self, tmp_path: Path, answers: dict[str, bool | None]
|
||||
) -> None:
|
||||
answers["extra"] = True
|
||||
state = tmp_path / "state.json"
|
||||
mixin = _mixin()
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
apply_flat_bonuses_if_new(state, mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(1)
|
||||
assert json.loads(state.read_text()) == {"extra_bonus_date": today_str()}
|
||||
|
||||
def test_its_ledger_is_read_like_any_other(self, tmp_path: Path) -> None:
|
||||
write_ledger(EXTRA, [credit(EXTRA)])
|
||||
mixin = _mixin()
|
||||
with signing_key(tmp_path):
|
||||
apply_flat_bonuses_if_new(tmp_path / "state.json", mixin)
|
||||
mixin._adjust_shutdown_time_by.assert_called_once_with(1)
|
||||
|
||||
def test_a_penalised_earner_lowers_the_base_from_its_day(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The base is read from the registry at call time, never a constant."""
|
||||
eve, cut = date(2026, 10, 2), date(2026, 10, 3)
|
||||
penalised = earned_time.Earner(
|
||||
name="cut",
|
||||
label="cut",
|
||||
gaming_minutes=60,
|
||||
shutdown_minutes=60,
|
||||
penalty_from=cut,
|
||||
)
|
||||
before = (base_hour(eve), base_hour(cut))
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, penalised))
|
||||
assert (base_hour(eve), base_hour(cut)) == (before[0], before[1] - 1)
|
||||
|
||||
|
||||
class TestRealLedgers:
|
||||
def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None:
|
||||
"""No mock between the ledgers and the written hour."""
|
||||
write_ledger(earned_time.LEETCODE, [credit(earned_time.LEETCODE)])
|
||||
write_ledger(earned_time.READING, [credit(earned_time.READING)])
|
||||
state = tmp_path / "state.json"
|
||||
mixin = _mixin()
|
||||
with signing_key(tmp_path):
|
||||
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(),
|
||||
}
|
||||
|
||||
|
||||
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
|
||||
@ -9,22 +9,23 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _shutdown_base
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._shutdown_base import (
|
||||
apply_leetcode_bonus_if_new,
|
||||
_apply_flat_bonus,
|
||||
base_hour,
|
||||
reset_to_base_if_new_day,
|
||||
)
|
||||
from screen_locker.tests._earned_fixtures import answering
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def _mixin(*, adjust_ok: bool = True) -> MagicMock:
|
||||
mixin = MagicMock()
|
||||
@ -34,22 +35,21 @@ def _mixin(*, adjust_ok: bool = True) -> MagicMock:
|
||||
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
|
||||
def apply_leetcode_bonus_if_new(state: Path, mixin: MagicMock) -> bool:
|
||||
"""The live pass for the LeetCode earner alone."""
|
||||
return _apply_flat_bonus(state, mixin, earned_time.LEETCODE)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def solved() -> Iterator[MagicMock]:
|
||||
with patch.object(_shutdown_base, "leetcode_bonus_hours", return_value=1) as m:
|
||||
"""LeetCode solved today; reading not, so the stamps here are LeetCode's."""
|
||||
with answering({"leetcode": True, "reading": False}) as m:
|
||||
yield m
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unsolved() -> Iterator[MagicMock]:
|
||||
with patch.object(_shutdown_base, "leetcode_bonus_hours", return_value=0) as m:
|
||||
with answering({"leetcode": False, "reading": False}) as m:
|
||||
yield m
|
||||
|
||||
|
||||
|
||||
@ -2,29 +2,29 @@
|
||||
|
||||
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.
|
||||
reading credit landing after the reset. Mirrors test_shutdown_leetcode.py; the
|
||||
pass over every flat earner is in test_shutdown_flat_earners.py.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, date, datetime
|
||||
from datetime import date, datetime
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import earned_time
|
||||
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_bonus,
|
||||
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
|
||||
from screen_locker.tests._earned_fixtures import answering
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -39,14 +39,17 @@ def _mixin(*, adjust_ok: bool = True) -> MagicMock:
|
||||
return mixin
|
||||
|
||||
|
||||
def apply_reading_bonus_if_new(state: Path, mixin: MagicMock) -> bool:
|
||||
"""The live pass for the reading earner alone."""
|
||||
return _apply_flat_bonus(state, mixin, earned_time.READING)
|
||||
|
||||
|
||||
@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
|
||||
def answers() -> Iterator[tuple[dict[str, bool | None], MagicMock]]:
|
||||
"""(answers, earned_today mock): every flat earner says no until set."""
|
||||
given: dict[str, bool | None] = {"leetcode": False, "reading": False}
|
||||
with answering(given) as mock:
|
||||
yield given, mock
|
||||
|
||||
|
||||
class TestBaseHour:
|
||||
@ -54,7 +57,7 @@ class TestBaseHour:
|
||||
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 earned_time.READING.penalty_from == date(2026, 10, 1)
|
||||
assert base_hour(date(2026, 10, 1)) == 19
|
||||
|
||||
def test_long_after_the_cut_stays_nineteen(self) -> None:
|
||||
@ -66,9 +69,9 @@ class TestBaseHour:
|
||||
|
||||
class TestResetIncludesReading:
|
||||
def test_reset_writes_base_plus_reading_and_stamps_it(
|
||||
self, tmp_path: Path, hours: tuple[MagicMock, MagicMock]
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
hours[1].return_value = 1
|
||||
answers[0]["reading"] = True
|
||||
state = tmp_path / "state.json"
|
||||
mixin = _mixin()
|
||||
assert reset_to_base_if_new_day(state, mixin) is True
|
||||
@ -81,10 +84,10 @@ class TestResetIncludesReading:
|
||||
}
|
||||
|
||||
def test_reset_with_both_flat_hours_stamps_both(
|
||||
self, tmp_path: Path, hours: tuple[MagicMock, MagicMock]
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
hours[0].return_value = 1
|
||||
hours[1].return_value = 1
|
||||
answers[0]["leetcode"] = True
|
||||
answers[0]["reading"] = True
|
||||
state = tmp_path / "state.json"
|
||||
mixin = _mixin()
|
||||
assert reset_to_base_if_new_day(state, mixin) is True
|
||||
@ -100,23 +103,57 @@ class TestResetIncludesReading:
|
||||
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]
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
hours[0].return_value = 1
|
||||
hours[1].return_value = 1
|
||||
answers[0]["leetcode"] = True
|
||||
answers[0]["reading"] = True
|
||||
mixin = _mixin()
|
||||
with patch.object(_shutdown_base, "today_earned_bonus_hours", return_value=9):
|
||||
with patch.object(_shutdown_base, "today_credit_count", 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)
|
||||
|
||||
def test_unknown_reading_earns_nothing_warns_and_is_not_stamped(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
answers: tuple[dict[str, bool | None], MagicMock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The live pass must ask again: a stamp would forfeit the day's hour."""
|
||||
answers[0]["reading"] = None
|
||||
state = tmp_path / "state.json"
|
||||
mixin = _mixin()
|
||||
with caplog.at_level("WARNING"):
|
||||
assert reset_to_base_if_new_day(state, mixin) is True
|
||||
assert "reading state could not be checked" in caplog.text
|
||||
base = base_hour()
|
||||
mixin._write_shutdown_config.assert_called_once_with(
|
||||
base, base, 5, restore=True
|
||||
)
|
||||
assert json.loads(state.read_text()) == {"last_reset_date": today_str()}
|
||||
|
||||
def test_reset_logs_every_term(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
answers: tuple[dict[str, bool | None], MagicMock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
answers[0]["reading"] = True
|
||||
with caplog.at_level("INFO"):
|
||||
reset_to_base_if_new_day(tmp_path / "state.json", _mixin())
|
||||
base = base_hour()
|
||||
assert (
|
||||
f"Daily base reset: {base + 1:02d}:00 (base {base} + 0h workout + "
|
||||
"0h LeetCode + 1h reading already earned today)."
|
||||
) in caplog.text
|
||||
|
||||
|
||||
class TestReadingLivePass:
|
||||
def test_applies_once_and_stamps(
|
||||
self, tmp_path: Path, hours: tuple[MagicMock, MagicMock]
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
hours[1].return_value = 1
|
||||
answers[0]["reading"] = True
|
||||
state = tmp_path / "state.json"
|
||||
state.write_text(json.dumps({"last_reset_date": today_str()}))
|
||||
mixin = _mixin()
|
||||
@ -129,15 +166,15 @@ class TestReadingLivePass:
|
||||
}
|
||||
|
||||
def test_stamped_today_never_reads_the_ledger(
|
||||
self, tmp_path: Path, hours: tuple[MagicMock, MagicMock]
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], 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()
|
||||
answers[1].assert_not_called()
|
||||
|
||||
def test_no_reading_no_write(
|
||||
self, tmp_path: Path, hours: tuple[MagicMock, MagicMock]
|
||||
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
|
||||
) -> None:
|
||||
mixin = _mixin()
|
||||
assert apply_reading_bonus_if_new(tmp_path / "state.json", mixin) is False
|
||||
@ -146,62 +183,41 @@ class TestReadingLivePass:
|
||||
def test_failed_write_leaves_no_stamp_and_warns(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
hours: tuple[MagicMock, MagicMock],
|
||||
answers: tuple[dict[str, bool | None], MagicMock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""A stamp without a write would silently forfeit the hour for the day."""
|
||||
hours[1].return_value = 1
|
||||
answers[0]["reading"] = True
|
||||
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 "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]
|
||||
def test_cannot_check_warns_and_leaves_no_stamp(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
answers: tuple[dict[str, bool | None], MagicMock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""The second save must not overwrite the first one's stamp."""
|
||||
hours[0].return_value = 1
|
||||
hours[1].return_value = 1
|
||||
"""Unknown is not "no": logged, no hour, and re-asked on the next tick."""
|
||||
answers[0]["reading"] = None
|
||||
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
|
||||
with caplog.at_level("WARNING"):
|
||||
assert apply_reading_bonus_if_new(state, mixin) is False
|
||||
assert "reading state could not be checked" in caplog.text
|
||||
mixin._adjust_shutdown_time_by.assert_not_called()
|
||||
assert not state.exists()
|
||||
|
||||
def test_only_reading_earned_applies_only_reading(
|
||||
self, tmp_path: Path, hours: tuple[MagicMock, MagicMock]
|
||||
def test_applied_hour_is_logged(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
answers: tuple[dict[str, bool | None], MagicMock],
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> 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
|
||||
answers[0]["reading"] = True
|
||||
with caplog.at_level("INFO"):
|
||||
assert apply_reading_bonus_if_new(tmp_path / "state.json", _mixin())
|
||||
assert "reading bonus: +1h shutdown time today." in caplog.text
|
||||
|
||||
Loading…
Reference in New Issue
Block a user