mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-11 15:54:05 +01:00
drop the weekly shutdown bonus entirely; every bonus stops at the shutdown ceiling
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
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
87c50825f9
commit
fc5453495c
@ -53,7 +53,7 @@ screen-locker computes a real workout streak and celebratory reward text, then
|
||||
throws almost all of it away. Verified 2026-08-16:
|
||||
|
||||
- `StatusSnapshot` (`screen_locker/_status_types.py` ~:88-101) carries populated
|
||||
`streak`, `bonus_hours_this_week` and `early_bird_extended` fields, filled by
|
||||
`streak` and `early_bird_extended` fields, filled by
|
||||
`_status_data.py` ~:210-214. **Grep confirms no renderer reads them.**
|
||||
- `_extra_benefits.py` `process_week_transition()` (~:82, reward strings built at
|
||||
~:130-139) produces fully-formed celebratory sentences — and
|
||||
@ -81,8 +81,7 @@ Primary:
|
||||
- `screen_locker/_status_types.py` — `StatusSnapshot` (~:88-101), the fields you
|
||||
are rendering. Read-only for this task.
|
||||
- `screen_locker/_extra_benefits.py` (179 lines) — `process_week_transition()`
|
||||
and its reward strings; `current_streak()` ~:154,
|
||||
`weekly_shutdown_bonus_hours()` ~:159, `has_extended_early_bird()` ~:171.
|
||||
and its reward strings; `current_streak()`, `has_extended_early_bird()`.
|
||||
- `screen_locker/_startup_checks.py` ~:57-58 — where the reward strings are
|
||||
currently discarded into a log call.
|
||||
- `screen_locker/_unlock_view.py` (72 lines, lots of headroom) — the existing
|
||||
@ -91,8 +90,8 @@ Primary:
|
||||
## must
|
||||
|
||||
- **Render the already-populated fields.** Add `_section_streak` to
|
||||
`_status_sections.py` showing the weekly streak, this week's bonus hours, and
|
||||
the extended-early-bird state, and call it from `status_view.py`'s `render()`.
|
||||
`_status_sections.py` showing the weekly streak and the extended-early-bird
|
||||
state, and call it from `status_view.py`'s `render()`.
|
||||
Zero new state, zero conftest change, zero new dependency.
|
||||
- **Stop discarding the reward strings.** `process_week_transition()` returns
|
||||
human-readable celebratory sentences that currently only reach the log. Surface
|
||||
@ -106,17 +105,20 @@ Primary:
|
||||
{phone_verified, runnerup_verified, manual_workout}`). Do not round, pad, or
|
||||
"encourage" the number.
|
||||
|
||||
- must not: **make any reward reduce enforcement.** This is recorded twice in the
|
||||
- must not: **make any reward reduce enforcement.** This is recorded in the
|
||||
repo as a deliberate decision:
|
||||
> `_startup_checks.py` ~:88-93 — "there is no banked 'skip a workout' credit —
|
||||
> `_startup_checks.py` — "there is no banked 'skip a workout' credit —
|
||||
> that mechanic works against the goal of maximizing weekly workouts, so it was
|
||||
> removed in favor of a shutdown-time-only reward"
|
||||
> `_extra_benefits.py` ~:5-8 — "This never reduces enforcement … it only grants
|
||||
> extra comfort time on top of a floor you still have to earn each day."
|
||||
> removed."
|
||||
|
||||
Every reward here is **display or comfort-time only**. Do not reintroduce a
|
||||
Every reward here is **display only**. Do not reintroduce a
|
||||
banked skip, a streak freeze, a grace day, or any mechanic that lowers the
|
||||
floor. A dopamine loop that lets you skip the workout defeats the app.
|
||||
- must not: **grant shutdown time for a streak, a week or a milestone.** The
|
||||
weekly banked shutdown bonus (and the 4-week `_MILESTONE_INTERVAL` +1h) was
|
||||
removed on purpose (2026-10-10): every shutdown minute comes from the
|
||||
`earned_time` ladder and stops at its daily ceiling. Rewards here are display
|
||||
only — no "+Nh" weekly reward, no bonus-hours field, no new shutdown source.
|
||||
- must not: add **sound** to any enforcement or lock path. screen-locker is a
|
||||
self-restriction tool; a chime on lock is punishment audio, and a chime on
|
||||
unlock trains the wrong association. This repo stays silent. (Sound is
|
||||
@ -141,7 +143,7 @@ Primary:
|
||||
## done
|
||||
|
||||
1. Opening the status window (tray icon → click, or `screen-locker-status`) shows
|
||||
the current streak, bonus hours and early-bird state — with the real values
|
||||
the current streak and early-bird state — with the real values
|
||||
from `extra_benefits_state.json`, not placeholders.
|
||||
2. A week-transition milestone reaches the UI instead of only `_logger.info`.
|
||||
3. `cd ~/src/screen-locker && python -m pytest` passes.
|
||||
@ -160,8 +162,8 @@ python -m screen_locker --status # CLI: confirm the values it prints
|
||||
screen-locker-status # the Tk window: confirm they render
|
||||
```
|
||||
|
||||
Paste both outputs. The CLI already prints `streak`/`bonus_hours` (`_status.py`
|
||||
~:138-141), so it is your ground truth: the window must show the **same numbers**.
|
||||
Paste both outputs. The CLI already prints the streak and early-bird state
|
||||
(`_status.py`), so it is your ground truth: the window must show the **same numbers**.
|
||||
If they disagree, the renderer is wrong, not the data.
|
||||
|
||||
Current real state for comparison: `screen_locker/extra_benefits_state.json` and
|
||||
@ -188,10 +190,11 @@ Current real state for comparison: `screen_locker/extra_benefits_state.json` and
|
||||
- All persistence is **JSON files, no sqlite**; paths declared in
|
||||
`screen_locker/_constants.py`. The streak store is
|
||||
`screen_locker/extra_benefits_state.json` (`consecutive_5plus_weeks`,
|
||||
`last_processed_iso_week`, `weekly_shutdown_bonus_hours`,
|
||||
`extended_early_bird_iso_weeks`).
|
||||
- Thresholds live in `_extra_benefits.py`: `_MILESTONE_INTERVAL = 4` (4-week
|
||||
milestone), `_BONUS_THRESHOLD = 5` (workouts per week).
|
||||
`last_processed_iso_week`, `extended_early_bird_iso_weeks`; a legacy
|
||||
`weekly_shutdown_bonus_hours` map is ignored and dropped on the next write).
|
||||
- The threshold lives in `_extra_benefits.py`: `_BONUS_THRESHOLD = 5` (workouts
|
||||
per week, for the streak and the early-bird extension). There is no milestone
|
||||
constant any more.
|
||||
- **There is no config system** — no argparse, no env vars, no config file. Every
|
||||
tunable is a module-level constant. An opt-out toggle has no existing home;
|
||||
for a self-restriction tool, requiring a code edit to disable is arguably
|
||||
|
||||
@ -4,12 +4,11 @@ Tracks:
|
||||
- Consecutive weeks with 5+ workouts (streak counter).
|
||||
- ISO weeks in which the early-bird window is extended to 09:00.
|
||||
|
||||
A 5+ workout week no longer banks shutdown-time hours for the following week
|
||||
(dropped 2026-10-10; 2026-W41 was the last week to receive any). The
|
||||
``weekly_shutdown_bonus_hours`` map is still *read* by
|
||||
:func:`weekly_shutdown_bonus_hours`, so hours already banked for the current
|
||||
week keep applying, and ``scripts/restore_and_bonus.py`` can still bank a
|
||||
one-off compensation into it — but the week transition never writes it.
|
||||
There is no weekly shutdown-time bonus (dropped entirely 2026-10-10, hours
|
||||
already banked for 2026-W41 included): every shutdown minute comes from the
|
||||
``earned_time`` ladder and stops at its daily ceiling. A legacy
|
||||
``weekly_shutdown_bonus_hours`` map in an old state file is ignored, and
|
||||
dropped at the next week transition's write.
|
||||
|
||||
State is persisted in ``extra_benefits_state.json`` next to this file.
|
||||
"""
|
||||
@ -29,6 +28,8 @@ if TYPE_CHECKING:
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
_BONUS_THRESHOLD = 5 # workouts/week required to extend the streak + early-bird
|
||||
# The removed weekly shutdown bonus's map; ignored on read, dropped on write.
|
||||
_LEGACY_BONUS_KEY = "weekly_shutdown_bonus_hours"
|
||||
|
||||
|
||||
def _load_state(state_file: Path) -> dict[str, Any]:
|
||||
@ -116,13 +117,12 @@ def process_week_transition(log_file: Path, state_file: Path) -> list[str]:
|
||||
streak = 0
|
||||
|
||||
# Merge onto the loaded state rather than rebuilding it from these three
|
||||
# keys: the legacy ``weekly_shutdown_bonus_hours`` map (still read, and
|
||||
# still written by restore_and_bonus.py) and sibling fields written by the
|
||||
# recovery scripts (e.g. ``shutdown_bonus_granted_for``, the guard that
|
||||
# stops a compensation bonus being granted twice) live in this file too,
|
||||
# and rebuilding would drop them at the next weekly rollover — losing this
|
||||
# week's banked hours and silently re-arming a double-grant.
|
||||
# keys: sibling fields written by the recovery scripts (e.g.
|
||||
# ``shutdown_bonus_granted_for``) live in this file too, and rebuilding
|
||||
# would drop them at the next weekly rollover. Only the dead weekly-bonus
|
||||
# map is dropped, so no reader can mistake it for live state.
|
||||
updated = dict(state)
|
||||
updated.pop(_LEGACY_BONUS_KEY, None)
|
||||
updated.update(
|
||||
{
|
||||
"consecutive_5plus_weeks": streak,
|
||||
@ -139,23 +139,6 @@ def current_streak(state_file: Path) -> int:
|
||||
return int(_load_state(state_file).get("consecutive_5plus_weeks", 0))
|
||||
|
||||
|
||||
def weekly_shutdown_bonus_hours(
|
||||
state_file: Path, *, today: datetime | None = None
|
||||
) -> int:
|
||||
"""Return the banked shutdown-time bonus (hours) for the current ISO week.
|
||||
|
||||
The week transition stopped banking these after 2026-W41; what is left is
|
||||
any hours already banked for this week, or a manual compensation written
|
||||
by ``scripts/restore_and_bonus.py``.
|
||||
"""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
current_week_str = _current_iso_week(now)
|
||||
bonus_hours: dict[str, int] = _load_state(state_file).get(
|
||||
"weekly_shutdown_bonus_hours", {}
|
||||
)
|
||||
return int(bonus_hours.get(current_week_str, 0))
|
||||
|
||||
|
||||
def has_extended_early_bird(state_file: Path, *, today: datetime | None = None) -> bool:
|
||||
"""Return True if the current ISO week has an extended early-bird window (09:00)."""
|
||||
now = today if today is not None else datetime.now(tz=UTC).astimezone()
|
||||
|
||||
@ -80,21 +80,17 @@ class PokeLocker(ScreenLocker):
|
||||
"""Run the new-day reset the locker's startup runs, in the same order.
|
||||
|
||||
Mirrors ``StartupChecksMixin._check_non_verify_exits``, not
|
||||
``sync_now`` (which never resets): the week transition must be
|
||||
processed BEFORE the reset, and any banked weekly bonus layered on only
|
||||
when this call did the reset. If the poke stamped the day without them,
|
||||
the locker's own reset later would be a no-op and that day's banked
|
||||
bonus would be lost.
|
||||
``sync_now`` (which never resets): the week transition (streak,
|
||||
early-bird window) first, then the daily reset.
|
||||
"""
|
||||
for reward in process_week_transition(self.log_file, EXTRA_BENEFITS_FILE):
|
||||
_logger.info("Weekly reward: %s", reward)
|
||||
if reset_to_base_if_new_day(
|
||||
reset_to_base_if_new_day(
|
||||
SHUTDOWN_BASE_FILE,
|
||||
self,
|
||||
sick_day_state_file=SICK_DAY_STATE_FILE,
|
||||
log_file=self.log_file,
|
||||
):
|
||||
self._apply_weekly_shutdown_bonus()
|
||||
)
|
||||
|
||||
def credit_written(
|
||||
self, entry: dict[str, str], prior_entries: list[dict[str, object]]
|
||||
|
||||
@ -105,19 +105,16 @@ class StartupChecksMixin(SyncMixin):
|
||||
self._record_skip("scheduled_skip_day", "Today is a scheduled skip day.")
|
||||
return
|
||||
# Award streak / EB-extension rewards from last week before the daily
|
||||
# reset. The transition no longer banks shutdown hours; any already
|
||||
# banked for this week are layered on by _apply_weekly_shutdown_bonus.
|
||||
# reset. No week carries a shutdown bonus any more (2026-10-10).
|
||||
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 (19:00) at the start of each new day,
|
||||
# then layer this week's banked bonus (if any) back on the fresh base.
|
||||
if reset_to_base_if_new_day(
|
||||
# Reset shutdown config to the day's base plus what today already earned.
|
||||
reset_to_base_if_new_day(
|
||||
SHUTDOWN_BASE_FILE,
|
||||
self,
|
||||
sick_day_state_file=SICK_DAY_STATE_FILE,
|
||||
log_file=self.log_file,
|
||||
):
|
||||
self._apply_weekly_shutdown_bonus()
|
||||
)
|
||||
# A LeetCode solve or a reading credit usually lands hours after the
|
||||
# reset; this 5-minute tick turns it into the extra hour the same day.
|
||||
apply_flat_bonuses_if_new(SHUTDOWN_BASE_FILE, self)
|
||||
@ -160,8 +157,7 @@ class StartupChecksMixin(SyncMixin):
|
||||
# Only remaining same-day skip: genuine extreme heat. Sick days go
|
||||
# through the justification flow instead; there is no banked
|
||||
# "skip a workout" credit — that mechanic works against the goal of
|
||||
# maximizing weekly workouts, so it was removed in favor of a
|
||||
# shutdown-time-only reward (see _apply_weekly_shutdown_bonus).
|
||||
# maximizing weekly workouts, so it was removed.
|
||||
# A logged workout outranks every rung above, whatever their order.
|
||||
if refuse_to_lock_over_logged_workout(self.has_logged_today, self._record_skip):
|
||||
return
|
||||
|
||||
@ -14,7 +14,6 @@ from screen_locker._earned import hhmm
|
||||
from screen_locker._extra_benefits import (
|
||||
current_streak,
|
||||
has_extended_early_bird,
|
||||
weekly_shutdown_bonus_hours,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._sick_tracker import load_history
|
||||
@ -41,7 +40,7 @@ def _load_extra_benefits() -> dict:
|
||||
except (OSError, ValueError) as exc:
|
||||
_logger.warning(
|
||||
"Could not read extra-benefits state from %s: %s — the status view "
|
||||
"will show a 0 streak and no bonus hours, which may be wrong",
|
||||
"will show a 0 streak, which may be wrong",
|
||||
EXTRA_BENEFITS_FILE,
|
||||
exc,
|
||||
)
|
||||
@ -116,7 +115,6 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
print()
|
||||
|
||||
# Extra benefits summary
|
||||
bonus_hours = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
|
||||
streak = current_streak(EXTRA_BENEFITS_FILE)
|
||||
eb_ext = has_extended_early_bird(EXTRA_BENEFITS_FILE)
|
||||
eb_str = "Yes — until 09:00" if eb_ext else "No"
|
||||
@ -138,7 +136,6 @@ def run_status(locker: ScreenLocker) -> None:
|
||||
else:
|
||||
heat_str = "0"
|
||||
|
||||
print(f" Shutdown bonus (this wk): {bonus_hours}h")
|
||||
print(f" Streak (5+ wks) : {streak}")
|
||||
print(f" Early-bird extended : {eb_str}")
|
||||
print(f" Heat skips (month) : {heat_str}")
|
||||
|
||||
@ -54,7 +54,6 @@ from screen_locker._constants import (
|
||||
from screen_locker._extra_benefits import (
|
||||
current_streak,
|
||||
has_extended_early_bird,
|
||||
weekly_shutdown_bonus_hours,
|
||||
)
|
||||
from screen_locker._log_io import load_workout_log
|
||||
from screen_locker._morning_session import has_workout_skip_today
|
||||
@ -203,14 +202,10 @@ def gather_status(
|
||||
return StatusSnapshot(
|
||||
today=days[-1],
|
||||
week=week,
|
||||
bonus_hours_this_week=weekly_shutdown_bonus_hours(
|
||||
extra_benefits_file, today=today_local
|
||||
),
|
||||
streak=current_streak(extra_benefits_file),
|
||||
early_bird_extended=early_bird_extended,
|
||||
shutdown=_shutdown_projection(
|
||||
shutdown_config_file=shutdown_config_file,
|
||||
extra_benefits_file=extra_benefits_file,
|
||||
today_local=today_local,
|
||||
),
|
||||
lock_explanation=lock_explanation,
|
||||
|
||||
@ -14,7 +14,6 @@ from datetime import date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
|
||||
from screen_locker._shutdown import read_shutdown_config
|
||||
from screen_locker._shutdown_base import base_minutes
|
||||
from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay
|
||||
@ -31,14 +30,9 @@ _RELAXED_DAY_EXPLANATION = (
|
||||
)
|
||||
_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
|
||||
_MON_WED_WEEKDAYS = frozenset({0, 1, 2})
|
||||
# A banked weekly bonus (legacy, or a restore_and_bonus.py compensation) is in
|
||||
# whole hours.
|
||||
_MINUTES_PER_HOUR = 60
|
||||
|
||||
|
||||
def _week_rows(
|
||||
monday: date, bonus: int, *, speculative: bool
|
||||
) -> tuple[ShutdownProjectionDay, ...]:
|
||||
def _week_rows(monday: date, *, speculative: bool) -> tuple[ShutdownProjectionDay, ...]:
|
||||
"""Build 7 labeled rows, Mon-Sun from ``monday``, each on its own day's base.
|
||||
|
||||
Each row asks ``base_minutes`` for its own date, so a registry cut dated
|
||||
@ -47,7 +41,7 @@ def _week_rows(
|
||||
return tuple(
|
||||
ShutdownProjectionDay(
|
||||
label=label,
|
||||
minutes=base_minutes(monday + timedelta(days=weekday)) + bonus,
|
||||
minutes=base_minutes(monday + timedelta(days=weekday)),
|
||||
speculative=speculative,
|
||||
)
|
||||
for weekday, label in enumerate(_WEEKDAY_LABELS)
|
||||
@ -57,23 +51,17 @@ def _week_rows(
|
||||
def _shutdown_projection(
|
||||
*,
|
||||
shutdown_config_file: Path,
|
||||
extra_benefits_file: Path,
|
||||
today_local: datetime,
|
||||
) -> ShutdownProjection:
|
||||
"""Build the shutdown-time projection: tonight, rest of week, next week.
|
||||
|
||||
Next week is the plain per-day base: a 5+ workout week no longer banks
|
||||
shutdown hours for the week after (only the early-bird extension).
|
||||
Every row is the plain per-day base: no week carries a banked shutdown
|
||||
bonus (dropped 2026-10-10).
|
||||
"""
|
||||
tonight = read_shutdown_config(shutdown_config_file)
|
||||
bonus = (
|
||||
weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local)
|
||||
* _MINUTES_PER_HOUR
|
||||
)
|
||||
monday = today_local.date() - timedelta(days=today_local.weekday())
|
||||
rest_of_week = _week_rows(monday, bonus, speculative=False)
|
||||
|
||||
next_week_preview = _week_rows(monday + timedelta(weeks=1), 0, speculative=True)
|
||||
rest_of_week = _week_rows(monday, speculative=False)
|
||||
next_week_preview = _week_rows(monday + timedelta(weeks=1), speculative=True)
|
||||
|
||||
return ShutdownProjection(
|
||||
tonight=tonight,
|
||||
|
||||
@ -94,7 +94,6 @@ class StatusSnapshot:
|
||||
|
||||
today: DayStatus
|
||||
week: WeeklySummary
|
||||
bonus_hours_this_week: int
|
||||
streak: int
|
||||
early_bird_extended: bool
|
||||
shutdown: ShutdownProjection
|
||||
|
||||
@ -10,10 +10,9 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_BASE_FILE
|
||||
from screen_locker._constants import SHUTDOWN_BASE_FILE
|
||||
from screen_locker._earned import span
|
||||
from screen_locker._earner_units import sync_path_unit
|
||||
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
|
||||
@ -27,9 +26,6 @@ __all__ = ["SyncMixin"]
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# A banked weekly bonus is in whole hours; the schedule takes minutes.
|
||||
_MINUTES_PER_HOUR = 60
|
||||
|
||||
|
||||
class SyncMixin:
|
||||
"""Publish, ingest and credit manual workouts synced between devices."""
|
||||
@ -136,14 +132,3 @@ class SyncMixin:
|
||||
"Auto-filled %d RunnerUp workout(s) from TCX exports.", n_filled
|
||||
)
|
||||
self.workout_data = {}
|
||||
|
||||
def _apply_weekly_shutdown_bonus(self) -> None:
|
||||
"""Layer this week's banked shutdown bonus (if any) on the fresh base.
|
||||
|
||||
Week transitions stopped banking hours after 2026-W41; only hours
|
||||
already banked for this week or a restore_and_bonus.py compensation
|
||||
remain to apply.
|
||||
"""
|
||||
bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
|
||||
if bonus > 0 and self._adjust_shutdown_time_by(bonus * _MINUTES_PER_HOUR):
|
||||
_logger.info("Weekly bonus: +%dh shutdown time this week.", bonus)
|
||||
|
||||
@ -196,7 +196,7 @@ def count_weekly_workouts(
|
||||
recorded twice is credited once. Manual workouts are rate-limited
|
||||
separately by the manual-workout budget
|
||||
(:mod:`screen_locker._manual_workout`), not by a collapse here. Feeds both
|
||||
the weekly lock minimum (:func:`has_weekly_minimum`) and the banked bonus.
|
||||
the weekly lock minimum (:func:`has_weekly_minimum`) and the weekly streak.
|
||||
|
||||
Args:
|
||||
log_file: Path to ``log.json``.
|
||||
|
||||
@ -27,7 +27,7 @@ ISOLATED_STATE: tuple[tuple[str, tuple[str, ...]], ...] = (
|
||||
(
|
||||
"_constants.EXTRA_BENEFITS_FILE",
|
||||
"_startup_checks.EXTRA_BENEFITS_FILE",
|
||||
"_sync_mixin.EXTRA_BENEFITS_FILE",
|
||||
"_poke_credit.EXTRA_BENEFITS_FILE",
|
||||
"_status.EXTRA_BENEFITS_FILE",
|
||||
),
|
||||
),
|
||||
|
||||
@ -134,7 +134,6 @@ def _snapshot(**overrides: object) -> StatusSnapshot:
|
||||
default = StatusSnapshot(
|
||||
today=_day(),
|
||||
week=_week(),
|
||||
bonus_hours_this_week=0,
|
||||
streak=0,
|
||||
early_bird_extended=False,
|
||||
shutdown=_shutdown(),
|
||||
|
||||
@ -29,8 +29,8 @@ class TestLoadState:
|
||||
def test_returns_parsed_state_when_file_valid(self, tmp_path: Path) -> None:
|
||||
"""Valid JSON file returns the parsed dict."""
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2026-W01": 3}}))
|
||||
assert _load_state(f) == {"weekly_shutdown_bonus_hours": {"2026-W01": 3}}
|
||||
f.write_text(json.dumps({"extended_early_bird_iso_weeks": ["2026-W01"]}))
|
||||
assert _load_state(f) == {"extended_early_bird_iso_weeks": ["2026-W01"]}
|
||||
|
||||
def test_returns_empty_on_oserror(self) -> None:
|
||||
"""OSError during read is caught and returns empty dict (lines 33-34)."""
|
||||
@ -52,10 +52,10 @@ class TestSaveState:
|
||||
def test_saves_state_to_file(self, tmp_path: Path) -> None:
|
||||
"""Valid path writes JSON content (lines 39-41)."""
|
||||
f = tmp_path / "state.json"
|
||||
_save_state(f, {"weekly_shutdown_bonus_hours": {"2026-W01": 2}})
|
||||
assert json.loads(f.read_text())["weekly_shutdown_bonus_hours"] == {
|
||||
"2026-W01": 2
|
||||
}
|
||||
_save_state(f, {"extended_early_bird_iso_weeks": ["2026-W02"]})
|
||||
assert json.loads(f.read_text())["extended_early_bird_iso_weeks"] == [
|
||||
"2026-W02"
|
||||
]
|
||||
|
||||
def test_logs_warning_on_oserror(self) -> None:
|
||||
"""OSError during write is caught as warning, does not raise (lines 42-43)."""
|
||||
@ -95,7 +95,6 @@ class TestProcessWeekTransition:
|
||||
{
|
||||
"last_processed_iso_week": self._PAST_WEEK,
|
||||
"consecutive_5plus_weeks": 0,
|
||||
"weekly_shutdown_bonus_hours": {},
|
||||
"extended_early_bird_iso_weeks": [],
|
||||
}
|
||||
)
|
||||
@ -113,10 +112,10 @@ class TestProcessWeekTransition:
|
||||
assert not any("shutdown bonus" in r for r in rewards)
|
||||
state = json.loads(f.read_text())
|
||||
assert state["consecutive_5plus_weeks"] == 1
|
||||
assert state["weekly_shutdown_bonus_hours"] == {}
|
||||
assert "weekly_shutdown_bonus_hours" not in state
|
||||
|
||||
def test_4_week_streak_banks_no_milestone_hour(self, tmp_path: Path) -> None:
|
||||
"""Streak reaching 4 adds no +1h; a legacy bonus map is left untouched."""
|
||||
"""Streak reaching 4 adds no +1h; a legacy bonus map is dropped on write."""
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(
|
||||
json.dumps(
|
||||
@ -137,7 +136,7 @@ class TestProcessWeekTransition:
|
||||
assert "4-week streak" in rewards[0]
|
||||
state = json.loads(f.read_text())
|
||||
assert state["consecutive_5plus_weeks"] == 4
|
||||
assert state["weekly_shutdown_bonus_hours"] == {"2020-W52": 2}
|
||||
assert "weekly_shutdown_bonus_hours" not in state
|
||||
|
||||
def test_marks_current_week_as_extended_early_bird(self, tmp_path: Path) -> None:
|
||||
"""5+ workouts mark current ISO week as extended EB (line 91-92)."""
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
"""Tests for _extra_benefits (streak, legacy banked bonus, EB extension)."""
|
||||
"""Tests for _extra_benefits (streak, legacy bonus key dropped, EB extension)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@ -10,37 +10,12 @@ from unittest.mock import patch
|
||||
from screen_locker._extra_benefits import (
|
||||
has_extended_early_bird,
|
||||
process_week_transition,
|
||||
weekly_shutdown_bonus_hours,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestWeeklyShutdownBonusHours:
|
||||
"""Tests for weekly_shutdown_bonus_hours."""
|
||||
|
||||
def test_returns_zero_when_missing(self, tmp_path: Path) -> None:
|
||||
"""No state file → 0."""
|
||||
f = tmp_path / "state.json"
|
||||
assert weekly_shutdown_bonus_hours(f) == 0
|
||||
|
||||
def test_returns_current_week_bonus(self, tmp_path: Path) -> None:
|
||||
"""Returns the banked bonus for the current ISO week."""
|
||||
now = datetime.now(tz=UTC).astimezone()
|
||||
year, week, _ = now.isocalendar()
|
||||
current_week = f"{year}-W{week:02d}"
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {current_week: 3}}))
|
||||
assert weekly_shutdown_bonus_hours(f) == 3
|
||||
|
||||
def test_ignores_other_weeks(self, tmp_path: Path) -> None:
|
||||
"""A bonus banked for a different ISO week is not returned."""
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps({"weekly_shutdown_bonus_hours": {"2020-W01": 5}}))
|
||||
assert weekly_shutdown_bonus_hours(f) == 0
|
||||
|
||||
|
||||
class _FrozenDatetime(datetime):
|
||||
"""``datetime`` whose ``now`` is pinned to Monday 2026-10-12 12:00 UTC."""
|
||||
|
||||
@ -52,7 +27,7 @@ class _FrozenDatetime(datetime):
|
||||
|
||||
|
||||
class TestBonusDroppedAtW42:
|
||||
"""The 2026-W41 -> W42 rollover banks nothing; W41's hours stay readable."""
|
||||
"""The 2026-W41 -> W42 rollover banks nothing and drops the legacy bonus map."""
|
||||
|
||||
_STATE: ClassVar[dict[str, object]] = {
|
||||
"consecutive_5plus_weeks": 3,
|
||||
@ -81,23 +56,16 @@ class TestBonusDroppedAtW42:
|
||||
)
|
||||
]
|
||||
state = json.loads(f.read_text())
|
||||
# Sibling keys (the compensation guard) survive; only the dead map goes.
|
||||
assert state == {
|
||||
**self._STATE,
|
||||
"consecutive_5plus_weeks": 4,
|
||||
"last_processed_iso_week": "2026-W42",
|
||||
"extended_early_bird_iso_weeks": ["2026-W41", "2026-W42"],
|
||||
"shutdown_bonus_granted_for": ["2026-08-24"],
|
||||
}
|
||||
w42 = datetime(2026, 10, 12, 12, 0, tzinfo=UTC)
|
||||
assert weekly_shutdown_bonus_hours(f, today=w42) == 0
|
||||
assert has_extended_early_bird(f, today=w42) is True
|
||||
|
||||
def test_w41_banked_hours_still_apply_through_sunday(self, tmp_path: Path) -> None:
|
||||
"""Hours banked before the drop keep applying for the rest of W41."""
|
||||
f = tmp_path / "state.json"
|
||||
f.write_text(json.dumps(self._STATE))
|
||||
sunday = datetime(2026, 10, 11, 21, 0, tzinfo=UTC)
|
||||
assert weekly_shutdown_bonus_hours(f, today=sunday) == 2
|
||||
|
||||
|
||||
class TestHasExtendedEarlyBird:
|
||||
"""Tests for has_extended_early_bird."""
|
||||
|
||||
@ -58,7 +58,6 @@ def _snapshot() -> StatusSnapshot:
|
||||
week=WeeklySummary(
|
||||
days=(day,), counted_count=2, minimum=4, remaining=2, extra=0
|
||||
),
|
||||
bonus_hours_this_week=0,
|
||||
streak=1,
|
||||
early_bird_extended=False,
|
||||
shutdown=ShutdownProjection(
|
||||
|
||||
@ -50,7 +50,7 @@ def credit(monkeypatch: pytest.MonkeyPatch) -> MagicMock:
|
||||
|
||||
|
||||
class TestStartTheDay:
|
||||
def test_new_day_applies_the_weekly_bonus_after_the_reset(
|
||||
def test_new_day_runs_the_week_transition_then_the_reset(
|
||||
self, log_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
calls: list[str] = []
|
||||
@ -63,22 +63,10 @@ class TestStartTheDay:
|
||||
calls.append("reset")
|
||||
return True
|
||||
|
||||
def bonus(_self: PokeLocker) -> None:
|
||||
calls.append("bonus")
|
||||
|
||||
monkeypatch.setattr(_poke_credit, "process_week_transition", week)
|
||||
monkeypatch.setattr(_poke_credit, "reset_to_base_if_new_day", reset)
|
||||
monkeypatch.setattr(PokeLocker, "_apply_weekly_shutdown_bonus", bonus)
|
||||
_poke_credit.fresh_locker().start_the_day()
|
||||
assert calls == ["week", "reset", "bonus"]
|
||||
|
||||
def test_same_day_does_not_apply_the_weekly_bonus(
|
||||
self, log_file: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
bonus = MagicMock()
|
||||
monkeypatch.setattr(PokeLocker, "_apply_weekly_shutdown_bonus", bonus)
|
||||
_poke_credit.fresh_locker().start_the_day()
|
||||
bonus.assert_not_called()
|
||||
assert calls == ["week", "reset"]
|
||||
|
||||
def test_credit_written_sets_the_entry_and_returns_the_result(
|
||||
self, log_file: Path, credit: MagicMock
|
||||
|
||||
@ -86,13 +86,13 @@ class TestCheckNonVerifyExitsExtras:
|
||||
):
|
||||
locker._check_non_verify_exits()
|
||||
|
||||
def test_applies_weekly_bonus_on_fresh_day_reset(
|
||||
def test_fresh_day_reset_layers_no_weekly_bonus(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""reset_to_base_if_new_day True → weekly shutdown bonus is applied once."""
|
||||
"""A fresh-day reset is the whole shutdown: no weekly bonus on top (2026-10-10)."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
@ -113,50 +113,6 @@ class TestCheckNonVerifyExitsExtras:
|
||||
"screen_locker._startup_checks.process_week_transition",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"screen_locker._sync_mixin.weekly_shutdown_bonus_hours",
|
||||
return_value=2,
|
||||
),
|
||||
patch("screen_locker._startup_checks.is_relaxed_day", return_value=False),
|
||||
patch(
|
||||
"screen_locker._startup_checks.has_weekly_minimum", return_value=True
|
||||
),
|
||||
patch("screen_locker.screen_lock.sys.exit"),
|
||||
):
|
||||
locker._check_non_verify_exits()
|
||||
locker._adjust_shutdown_time_by.assert_called_once_with(120)
|
||||
|
||||
def test_no_weekly_bonus_applied_when_not_a_fresh_day(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""reset_to_base_if_new_day False (same-day restart) → bonus not re-applied."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_scan_and_fill_week_runnerup",
|
||||
MagicMock(return_value=0),
|
||||
)
|
||||
object.__setattr__(
|
||||
locker,
|
||||
"_adjust_shutdown_time_by",
|
||||
MagicMock(return_value=True),
|
||||
)
|
||||
with (
|
||||
patch(
|
||||
"screen_locker._startup_checks.reset_to_base_if_new_day",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"screen_locker._startup_checks.process_week_transition",
|
||||
return_value=[],
|
||||
),
|
||||
patch(
|
||||
"screen_locker._sync_mixin.weekly_shutdown_bonus_hours",
|
||||
return_value=2,
|
||||
),
|
||||
patch("screen_locker._startup_checks.is_relaxed_day", return_value=False),
|
||||
patch(
|
||||
"screen_locker._startup_checks.has_weekly_minimum", return_value=True
|
||||
|
||||
206
screen_locker/tests/test_shutdown_ceiling_invariant.py
Normal file
206
screen_locker/tests/test_shutdown_ceiling_invariant.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""Doing every task reaches the shutdown ceiling, on every day, in every state.
|
||||
|
||||
The invariant behind dropping the weekly banked bonus (2026-10-10): the
|
||||
ladder's floor is the ceiling minus every rung, so a day on which every earner
|
||||
in ``earned_time.earners_for(day)`` is done to its full units (the tutor's
|
||||
four blocks included) must shut down exactly at ``shutdown_ceiling_for(day)``
|
||||
-- never short of it, and never past it. Checked through screen-locker's own
|
||||
paths, not a re-implementation: the daily reset's :func:`derive`, and the live
|
||||
pass (first workout, further workouts, flat gates, counted gate blocks, grace
|
||||
floor) over :class:`ShutdownMixin` with an in-memory config.
|
||||
|
||||
Every gate maturity state is covered: ``first_credits`` omitted (0.5.0
|
||||
``penalty_from``), empty (every earner on its ``confirmed_on``), never paid
|
||||
out / missing ledger (``None``), first paid today (penalty not started),
|
||||
maturing (paid yesterday), mature, and a new tutor beside mature gates.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time, timedelta
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _gate_bonus, _grace_floor, _shutdown
|
||||
from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate
|
||||
from screen_locker._earned import registry as registry_for
|
||||
from screen_locker._gate_bonus import apply_counted_bonus
|
||||
from screen_locker._shutdown import ShutdownMixin
|
||||
from screen_locker._shutdown_target import DayInputs, derive
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Mapping
|
||||
|
||||
_FIRST = date(2026, 10, 10)
|
||||
_LAST = date(2026, 12, 31)
|
||||
_DAYS = tuple(_FIRST + timedelta(days=n) for n in range((_LAST - _FIRST).days + 1))
|
||||
# adjust_shutdown_schedule.sh's RESTORE_CEILING: the helper clamps every
|
||||
# --restore write to 23:00, so the double does too.
|
||||
_HELPER_CEILING = 23 * 60
|
||||
_MORNING = 5 * 60
|
||||
|
||||
Credits = dict[str, date | None] | None
|
||||
|
||||
|
||||
def _credits(fill: Callable[[date], date | None] | None) -> Callable[[date], Credits]:
|
||||
"""A per-day ``first_credits`` map giving every earner ``fill(day)``."""
|
||||
|
||||
def build(day: date) -> Credits:
|
||||
if fill is None:
|
||||
return None
|
||||
return {e.name: fill(day) for e in registry_for(day)}
|
||||
|
||||
return build
|
||||
|
||||
|
||||
def _new_tutor_beside_mature(day: date) -> Credits:
|
||||
mature = {e.name: date(2026, 8, 1) for e in registry_for(day)}
|
||||
return {**mature, "automation": None}
|
||||
|
||||
|
||||
_MATURITY: Mapping[str, Callable[[date], Credits]] = {
|
||||
"penalty_from": _credits(None),
|
||||
"confirmed_on": lambda _day: {},
|
||||
"never_paid": _credits(lambda _day: None),
|
||||
"first_paid_today": _credits(lambda day: day),
|
||||
"maturing": _credits(lambda day: day - timedelta(days=1)),
|
||||
"mature": _credits(lambda _day: date(2026, 8, 1)),
|
||||
"new_tutor": _new_tutor_beside_mature,
|
||||
}
|
||||
_CASES = [
|
||||
pytest.param(
|
||||
name, rest, workouts, first, id=f"{name}-rest{rest}-w{workouts}-{first}"
|
||||
)
|
||||
for name in _MATURITY
|
||||
for rest in (False, True)
|
||||
for workouts in (1, 2)
|
||||
for first in ("none", "09:00", "22:30")
|
||||
]
|
||||
|
||||
|
||||
def _at(day: date, hhmm: str) -> float | None:
|
||||
if hhmm == "none":
|
||||
return None
|
||||
hours, minutes = map(int, hhmm.split(":"))
|
||||
return datetime.combine(day, time(hours, minutes)).astimezone().timestamp()
|
||||
|
||||
|
||||
def _gates(day: date, *, done: bool) -> dict[str, int | bool | None]:
|
||||
"""Every gate's answer: all done (full units) or none."""
|
||||
return {
|
||||
e.name: (e.max_units or True) if done else 0
|
||||
for e in registry_for(day)
|
||||
if is_gate(e)
|
||||
}
|
||||
|
||||
|
||||
def _inputs(day: date, case: tuple[str, bool, int, str], *, done: bool) -> DayInputs:
|
||||
name, rest, workouts, first = case
|
||||
return DayInputs(
|
||||
day=day,
|
||||
flat=_gates(day, done=done),
|
||||
workout_credits=workouts if done else 0,
|
||||
rest_day=rest,
|
||||
first_done=_at(day, first) if done else None,
|
||||
first_credits=_MATURITY[name](day),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("maturity", "rest", "workouts", "first"), _CASES)
|
||||
def test_the_reset_lands_every_all_done_day_on_the_ceiling(
|
||||
maturity: str, rest: bool, workouts: int, first: str
|
||||
) -> None:
|
||||
"""``derive`` (the daily reset's formula) == ceiling on every day."""
|
||||
case = (maturity, rest, workouts, first)
|
||||
misses = [
|
||||
(day.isoformat(), got, ceiling(day))
|
||||
for day in _DAYS
|
||||
if (got := derive(_inputs(day, case, done=True)).minutes) != ceiling(day)
|
||||
]
|
||||
assert misses == []
|
||||
|
||||
|
||||
class _Host(ShutdownMixin):
|
||||
"""The real add / absorb / floor paths over an in-memory config."""
|
||||
|
||||
def __init__(self, minutes: int) -> None:
|
||||
self.config = (minutes, minutes, _MORNING)
|
||||
|
||||
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
|
||||
return self.config
|
||||
|
||||
def _write_shutdown_config(
|
||||
self, mon_wed: int, thu_sun: int, morning_end: int, *, restore: bool = False
|
||||
) -> bool:
|
||||
assert restore
|
||||
self.config = (
|
||||
min(_HELPER_CEILING, mon_wed),
|
||||
min(_HELPER_CEILING, thu_sun),
|
||||
morning_end,
|
||||
)
|
||||
return True
|
||||
|
||||
|
||||
def _live_pass(day: date, case: tuple[str, bool, int, str]) -> int:
|
||||
"""Reset with nothing done, then every credit as the live pass applies it."""
|
||||
_, rest, workouts, first = case
|
||||
host = _Host(derive(_inputs(day, case, done=False)).minutes)
|
||||
first_done = _at(day, first)
|
||||
if first_done is not None:
|
||||
host._apply_grace_floor(None, first_done=first_done)
|
||||
# A rest day's first unit is already in the reset; a real workout on it
|
||||
# is a further unit (_workout_credit's rest_paid rule).
|
||||
further = workouts if rest else workouts - 1
|
||||
if not rest:
|
||||
host._adjust_shutdown_time_later()
|
||||
extra = extra_minutes(earned_time.WORKOUT, day)
|
||||
for _ in range(further if extra else 0):
|
||||
host._adjust_shutdown_time_by(extra)
|
||||
state: dict[str, Any] = {}
|
||||
for gate in (e for e in registry_for(day) if is_gate(e)):
|
||||
if gate.kind == "flat":
|
||||
host._adjust_shutdown_time_by(first_minutes(gate, day))
|
||||
continue
|
||||
for units in range(1, (gate.max_units or 1) + 1):
|
||||
with patch.object(_gate_bonus, "earned_units", return_value=units):
|
||||
apply_counted_bonus(
|
||||
state, host._adjust_shutdown_time_by, gate, day.isoformat()
|
||||
)
|
||||
return host.config[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def frozen_day() -> Iterator[Callable[[date], Any]]:
|
||||
"""Point every live path's ``today`` at the simulated day."""
|
||||
|
||||
def at_day(day: date) -> Any:
|
||||
iso = day.isoformat()
|
||||
return patch.multiple(_shutdown, today_str=lambda: iso), patch.multiple(
|
||||
_grace_floor, today_str=lambda: iso, sick_on=lambda _d: False
|
||||
)
|
||||
|
||||
return at_day
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("maturity", "rest", "workouts", "first"), _CASES)
|
||||
def test_the_live_pass_lands_every_all_done_day_on_the_ceiling(
|
||||
maturity: str,
|
||||
rest: bool,
|
||||
workouts: int,
|
||||
first: str,
|
||||
frozen_day: Callable[[date], Any],
|
||||
) -> None:
|
||||
"""Reset + every live credit, in registry order == ceiling on every day."""
|
||||
case = (maturity, rest, workouts, first)
|
||||
misses = []
|
||||
for day in _DAYS:
|
||||
shutdown_patch, grace_patch = frozen_day(day)
|
||||
with shutdown_patch, grace_patch:
|
||||
got = _live_pass(day, case)
|
||||
_grace_floor.GRACE_STATE_FILE.unlink(missing_ok=True)
|
||||
if got != ceiling(day):
|
||||
misses.append((day.isoformat(), got, ceiling(day)))
|
||||
assert misses == []
|
||||
@ -161,15 +161,14 @@ class TestRunStatusNormal:
|
||||
out = capsys.readouterr().out
|
||||
assert "Shutdown tonight" not in out
|
||||
|
||||
def test_shutdown_bonus_and_streak_shown(
|
||||
def test_streak_shown_and_no_weekly_bonus_line(
|
||||
self, tmp_path: Path, capsys: pytest.CaptureFixture
|
||||
) -> None:
|
||||
"""bonus_hours=3, streak=2, eb_ext=True → shown in output."""
|
||||
"""streak=2, eb_ext=True → shown; the dropped weekly bonus is not."""
|
||||
eb_file = tmp_path / "eb.json"
|
||||
locker = _make_locker(tmp_path / "log.json", n_filled=0)
|
||||
with (
|
||||
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
|
||||
patch("screen_locker._status.weekly_shutdown_bonus_hours", return_value=3),
|
||||
patch("screen_locker._status.current_streak", return_value=2),
|
||||
patch("screen_locker._status.has_extended_early_bird", return_value=True),
|
||||
patch("screen_locker._status.count_weekly_workouts", return_value=0),
|
||||
@ -177,7 +176,7 @@ class TestRunStatusNormal:
|
||||
):
|
||||
run_status(locker)
|
||||
out = capsys.readouterr().out
|
||||
assert "Shutdown bonus (this wk): 3h" in out
|
||||
assert "Shutdown bonus" not in out
|
||||
assert "Streak (5+ wks) : 2" in out
|
||||
assert "Yes — until 09:00" in out
|
||||
|
||||
|
||||
@ -102,10 +102,10 @@ class TestGatherStatus:
|
||||
|
||||
assert snap.shutdown.tonight == (1320, 1380, 300)
|
||||
|
||||
def test_bonus_streak_and_extended_early_bird_reflected(
|
||||
def test_streak_and_early_bird_reflected_legacy_bonus_ignored(
|
||||
self, tmp_path: Path
|
||||
) -> None:
|
||||
"""Bonus streak and extended early bird reflected."""
|
||||
"""Streak + early bird reflected; a legacy banked weekly bonus adds nothing."""
|
||||
files = _files(tmp_path)
|
||||
files["extra_benefits_file"].write_text(
|
||||
json.dumps(
|
||||
@ -121,10 +121,9 @@ class TestGatherStatus:
|
||||
):
|
||||
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
|
||||
|
||||
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].minutes == 23 * 60 # 20h base + 3h bonus
|
||||
assert snap.shutdown.rest_of_week[0].minutes == 20 * 60 # base, no bonus
|
||||
|
||||
def test_next_week_preview_is_speculative_rest_of_week_is_not(
|
||||
self, tmp_path: Path
|
||||
|
||||
@ -51,32 +51,29 @@ main() {
|
||||
validate_requirements
|
||||
cd "$SCRIPT_DIR"
|
||||
|
||||
step "1/7 Test suite (must be green before touching live state)"
|
||||
step "1/6 Test suite (must be green before touching live state)"
|
||||
"$PY" -m pytest screen_locker/tests/ -q --no-cov
|
||||
|
||||
step "2/7 Lint + type gate"
|
||||
step "2/6 Lint + type gate"
|
||||
"$PY" -m pre_commit run --all-files
|
||||
|
||||
step "3/7 Sync: recover Firebase, then ingest today's workout"
|
||||
step "3/6 Sync: recover Firebase, then ingest today's workout"
|
||||
# --sync-only never locks, so this is safe to run while disarmed.
|
||||
./run.sh --sync-only
|
||||
|
||||
step "4/7 Did $TARGET_DATE actually land in the log?"
|
||||
step "4/6 Did $TARGET_DATE actually land in the log?"
|
||||
# Gate, don't warn: if the credit is not on disk there is nothing to
|
||||
# revoke a sick day against, and re-arming here would lock you out again.
|
||||
"$PY" scripts/verify_workout_credited.py --date "$TARGET_DATE"
|
||||
|
||||
step "5/7 Restore shutdown hours + grant the bonus"
|
||||
"$PY" scripts/restore_and_bonus.py --date "$TARGET_DATE"
|
||||
|
||||
step "6/7 Revoke the sick days that this workout disproves"
|
||||
step "5/6 Revoke the sick days that this workout disproves"
|
||||
"$PY" scripts/revoke_sick_day.py --date "$TARGET_DATE" --date 2026-06-12
|
||||
|
||||
if ((ARM)); then
|
||||
step "7/7 Re-arm enforcement"
|
||||
step "6/6 Re-arm enforcement"
|
||||
./arm.sh --rearm
|
||||
else
|
||||
step "7/7 Skipped re-arm (--no-arm); enforcement stays OFF"
|
||||
step "6/6 Skipped re-arm (--no-arm); enforcement stays OFF"
|
||||
echo "Re-arm later with: ./arm.sh --rearm"
|
||||
fi
|
||||
|
||||
|
||||
@ -30,10 +30,9 @@ import tempfile
|
||||
import earned_time
|
||||
|
||||
from screen_locker import _earned, _grace_floor, _shutdown
|
||||
from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_CONFIG_FILE
|
||||
from screen_locker._constants import SHUTDOWN_CONFIG_FILE
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._extra_benefits import weekly_shutdown_bonus_hours
|
||||
from screen_locker._rest_day import declare, is_rest_day
|
||||
from screen_locker._shutdown import read_shutdown_config
|
||||
from screen_locker._shutdown_target import (
|
||||
@ -44,7 +43,6 @@ from screen_locker._shutdown_target import (
|
||||
gather,
|
||||
)
|
||||
from scripts._preview_sim import (
|
||||
HELPER_CEILING,
|
||||
SimRunnerUp,
|
||||
SimShutdown,
|
||||
sick_history,
|
||||
@ -166,12 +164,7 @@ def main() -> int:
|
||||
)
|
||||
target = gather(today, _LOG)
|
||||
_report("today, real ledgers", target)
|
||||
weekly = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE) * 60
|
||||
_say(
|
||||
f" + banked weekly bonus {weekly} min (layered after the reset, "
|
||||
f"absorbs the grace lift) -> expected config "
|
||||
f"{hhmm(min(HELPER_CEILING, max(target.earned + weekly, target.minutes)))}"
|
||||
)
|
||||
_say(f" expected config after the next reset: {hhmm(target.minutes)}")
|
||||
return 0
|
||||
day = date.fromisoformat(args.day)
|
||||
_say(f" ladder in force on {day}: {ladder(day) if ladder else False}")
|
||||
|
||||
@ -1,147 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Undo a sick day's shutdown penalty and grant the agreed bonus hours.
|
||||
|
||||
Two separate corrections, in this order:
|
||||
|
||||
1. **Restore the baseline.** Taking a sick day pulled the shutdown hours down
|
||||
to 21/21 and stashed the real values in ``sick_day_state.json`` as
|
||||
``original_mon_wed_hour`` / ``original_thu_sun_hour`` (both 22). Restoring
|
||||
comes first, because otherwise a "bonus" merely returns you to baseline and
|
||||
the compensation is silently swallowed.
|
||||
|
||||
2. **Bank the bonus.** Written into ``extra_benefits_state.json`` under
|
||||
``weekly_shutdown_bonus_hours[<ISO week>]`` -- the same key
|
||||
``_extra_benefits.weekly_shutdown_bonus_hours()`` reads and
|
||||
``_apply_weekly_shutdown_bonus`` applies every day of the week. The locker
|
||||
used this map for streak rewards until 2026-W41; since then this script is
|
||||
its only writer.
|
||||
|
||||
Two bonus hours by default: one for each day a completed workout was not
|
||||
counted (2026-06-12 and 2026-08-24).
|
||||
|
||||
Usage:
|
||||
python3 scripts/restore_and_bonus.py --date 2026-08-24
|
||||
python3 scripts/restore_and_bonus.py --date 2026-08-24 --bonus-hours 3
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SICK_DAY_STATE_FILE = REPO_ROOT / "screen_locker" / "sick_day_state.json"
|
||||
EXTRA_BENEFITS_FILE = REPO_ROOT / "screen_locker" / "extra_benefits_state.json"
|
||||
|
||||
DEFAULT_BONUS_HOURS = 2
|
||||
|
||||
# Failures go to the log (stderr) as well as stdout: this script runs as one
|
||||
# step of finish_2026_08_24.sh, where a bare stdout line scrolls past in a wall
|
||||
# of other output, and it mutates benefit state — the exact case where a quiet
|
||||
# failure looks like success.
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _report(message: str) -> None:
|
||||
"""Write one line to stdout (matches the other scripts in this directory)."""
|
||||
sys.stdout.write(f"{message}\n")
|
||||
|
||||
|
||||
def _iso_week(date_str: str) -> str:
|
||||
"""Return ``YYYY-Www`` for ``date_str``, matching ``_current_iso_week``."""
|
||||
parsed = datetime.strptime(date_str, "%Y-%m-%d").astimezone()
|
||||
year, week, _ = parsed.isocalendar()
|
||||
return f"{year}-W{week:02d}"
|
||||
|
||||
|
||||
def _load(path: Path) -> dict:
|
||||
"""Return ``path``'s JSON object, or an empty dict when absent."""
|
||||
if not path.is_file():
|
||||
return {}
|
||||
try:
|
||||
return json.loads(path.read_text())
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
_logger.warning("Could not read %s: %s — treating as empty", path, exc)
|
||||
_report(f"WARN: could not read {path}: {exc} — treating as empty")
|
||||
return {}
|
||||
|
||||
|
||||
def restore_hours(state: dict) -> tuple[int, int] | None:
|
||||
"""Return the (mon_wed, thu_sun) hours the sick day displaced, if any."""
|
||||
mon_wed = state.get("original_mon_wed_hour")
|
||||
thu_sun = state.get("original_thu_sun_hour")
|
||||
if mon_wed is None or thu_sun is None:
|
||||
return None
|
||||
return int(mon_wed), int(thu_sun)
|
||||
|
||||
|
||||
def main() -> int:
|
||||
"""Restore the pre-sick-day shutdown hours and bank the bonus."""
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("--date", required=True, help="the revoked sick day")
|
||||
parser.add_argument("--bonus-hours", type=int, default=DEFAULT_BONUS_HOURS)
|
||||
parser.add_argument("--sick-day-state", default=str(SICK_DAY_STATE_FILE))
|
||||
parser.add_argument("--benefits-file", default=str(EXTRA_BENEFITS_FILE))
|
||||
args = parser.parse_args()
|
||||
|
||||
sick_state = _load(Path(args.sick_day_state))
|
||||
restored = restore_hours(sick_state)
|
||||
if restored is None:
|
||||
_report(
|
||||
"No original shutdown hours recorded — the baseline is already "
|
||||
"whatever the daily reset produces, so nothing to restore."
|
||||
)
|
||||
else:
|
||||
mon_wed, thu_sun = restored
|
||||
_report(
|
||||
f"Baseline to restore: Mon-Wed={mon_wed}:00, Thu-Sun={thu_sun}:00 "
|
||||
"(the daily reset re-applies this on the next run)"
|
||||
)
|
||||
|
||||
benefits = _load(Path(args.benefits_file))
|
||||
week = _iso_week(args.date)
|
||||
bonus_map = dict(benefits.get("weekly_shutdown_bonus_hours", {}))
|
||||
existing = int(bonus_map.get(week, 0))
|
||||
|
||||
# Idempotence guard. Banking is `existing + bonus`, so without a durable
|
||||
# record of which dates were already compensated a re-run silently grants
|
||||
# the hours a second time. The bonus map alone cannot answer that question:
|
||||
# a week's total mixes earned streak hours with these corrections, so no
|
||||
# arithmetic on it can tell "already applied" from "earned that much".
|
||||
granted = list(benefits.get("shutdown_bonus_granted_for", []))
|
||||
if args.date in granted:
|
||||
_report(
|
||||
f"Bonus for {args.date} was already banked into {week} "
|
||||
f"(currently {existing}h) — not granting it twice."
|
||||
)
|
||||
return 0
|
||||
|
||||
granted.append(args.date)
|
||||
benefits["shutdown_bonus_granted_for"] = granted
|
||||
bonus_map[week] = existing + args.bonus_hours
|
||||
benefits["weekly_shutdown_bonus_hours"] = bonus_map
|
||||
|
||||
try:
|
||||
Path(args.benefits_file).write_text(json.dumps(benefits, indent=2) + "\n")
|
||||
except OSError as exc:
|
||||
_logger.exception(
|
||||
"Could not write %s — the bonus was NOT banked",
|
||||
args.benefits_file,
|
||||
)
|
||||
_report(f"FAIL: could not write {args.benefits_file}: {exc}")
|
||||
return 1
|
||||
|
||||
_report(
|
||||
f"Banked +{args.bonus_hours}h shutdown bonus for {week} "
|
||||
f"(was {existing}h, now {bonus_map[week]}h) — applied every day of "
|
||||
"that week on top of the restored baseline."
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Loading…
Reference in New Issue
Block a user