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