shutdown: minutes end to end (*_MINUTES schedule keys, HH:MM helper args, to_hours removed)
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 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-04 19:48:24 +02:00
parent ed5fa133d0
commit 275a0862fc
47 changed files with 970 additions and 535 deletions

View File

@ -57,9 +57,13 @@ rule (LeetCode's `submitted_at`, book-guard's `detail.bonus == "1"` +
`ended_at`), and *fail closed* -- an unreadable ledger or key earns nothing,
logged. The gates stay read-only; none writes the config.
The schedule stores whole hours; `_earned.to_hours` raises on a remainder
instead of flooring, so a 30-minute earner needs the schedule to learn
minutes first.
The schedule is in minutes after midnight end to end. `/etc/shutdown-schedule.conf`
holds authoritative `MON_WED_MINUTES` / `THU_SUN_MINUTES` / `MORNING_END_MINUTES`
plus strict-rounded legacy `*_HOUR` copies (evening floor, morning ceil) for old
readers; `read_shutdown_config` prefers `*_MINUTES` and falls back to `*_HOUR` x 60.
`adjust_shutdown_schedule.sh` takes `HH:MM` (or a legacy bare hour) and the
helpers `_earned.hhmm` / `_earned.span` render times and durations, so a
30-minute earner is applied exactly.
## The morning session is the carrot — and the early-bird window is it

View File

@ -6,7 +6,7 @@ until a RunnerUp-verified (or explicitly justified) workout is logged.
## Shutdown time
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_hour()` in
Every day starts at **19:00** from 2026-10-01, 20:00 before it (`base_minutes()` in
`screen_locker/_shutdown_base.py`)
and bonuses push it later, capped at 23:00: the first counted workout +2h,
each further one +1h, an accepted LeetCode submission that day +1h (flat,

View File

@ -3,16 +3,15 @@
Every gate's reward -- the workout, the LeetCode hour, the reading hour and
whatever is registered next -- is defined once in ``earned_time``, which
steam-backlog-enforcer reads for gaming time too. This module is the
screen-locker side of it: where the ledgers live, and the hour conversion.
screen-locker side of it: where the ledgers live, and how minutes are shown.
The gates publish facts only; none writes the shutdown config. Their ledgers
are HMAC-verified against :data:`HMAC_KEY_FILE` and *fail closed* -- an
unreadable ledger or key earns nothing, and says so.
**Whole hours only, for now.** ``/etc/shutdown-schedule.conf`` stores hours,
while the registry is in minutes. :func:`to_hours` refuses a remainder rather
than flooring it, so a 30-minute earner cannot silently become 0 here before
the schedule learns minutes.
Minutes end to end: the registry, ``/etc/shutdown-schedule.conf`` (its
``*_MINUTES`` keys) and every shutdown time in this package are minutes after
midnight, so a 30-minute earner is applied exactly.
"""
from __future__ import annotations
@ -37,21 +36,20 @@ LEDGER_HOME: Final = Path.home()
_MINUTES_PER_HOUR: Final = 60
def to_hours(minutes: int) -> int:
"""Convert a registry amount to the schedule's whole hours.
def hhmm(minutes: int) -> str:
"""A time of day, given in minutes after midnight, as ``HH:MM``."""
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
return f"{hours:02d}:{rest:02d}"
Raises:
ValueError: ``minutes`` is not a whole number of hours. The shutdown
schedule cannot express it, and flooring would quietly drop it.
"""
hours, remainder = divmod(minutes, _MINUTES_PER_HOUR)
if remainder:
msg = (
f"{minutes} minutes is not a whole hour; /etc/shutdown-schedule.conf "
"stores hours only"
)
raise ValueError(msg)
return hours
def span(minutes: int) -> str:
"""A duration in minutes as ``2h``, ``30m`` or ``1h30m``."""
hours, rest = divmod(minutes, _MINUTES_PER_HOUR)
if not rest:
return f"{hours}h"
if not hours:
return f"{rest}m"
return f"{hours}h{rest:02d}m"
def ledger_file(earner: earned_time.Earner) -> Path:

View File

@ -11,19 +11,43 @@ from screen_locker._constants import (
SHUTDOWN_CONFIG_FILE,
)
from screen_locker._day import today_str
from screen_locker._earned import hhmm, span
from screen_locker._shutdown_sick_state import SickDayStateMixin
from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_HOURS
from screen_locker._workout_credit import FIRST_WORKOUT_BONUS_MINUTES
if TYPE_CHECKING:
from pathlib import Path
_logger = logging.getLogger(__name__)
_SHUTDOWN_CONFIG_KEYS = ("MON_WED_HOUR", "THU_SUN_HOUR", "MORNING_END_HOUR")
# Every time below is minutes after midnight. The config's *_MINUTES keys are
# authoritative; a file written before the minutes migration has only the
# whole-hour *_HOUR keys, read as HOUR * 60.
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
_MINUTES_PER_HOUR = 60
# A sick day moves shutdown an hour earlier, but never before 18:00.
_SICK_DAY_STEP = 60
_SICK_DAY_FLOOR = 18 * _MINUTES_PER_HOUR
# The workout reward stops at 23:00 (adjust_shutdown_schedule.sh's ceiling).
_WORKOUT_CEILING = 23 * _MINUTES_PER_HOUR
# Extra bonuses may name midnight; the helper still clamps them to 23:00.
_MIDNIGHT = 24 * _MINUTES_PER_HOUR
def _parse_config(path: Path) -> dict[str, int]:
"""Every integer ``KEY=value`` line of *path*."""
parsed: dict[str, int] = {}
with path.open() as f:
for line in f:
key, sep, value = line.strip().partition("=")
if sep and value.strip().isdigit():
parsed[key] = int(value)
return parsed
def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
"""Read shutdown config from *path*. Returns (mw_hour, ts_hour, me_hour) or None.
"""Read shutdown config from *path* as (mon_wed, thu_sun, morning_end) minutes.
Reading needs no privilege (only writing does, via
``adjust_shutdown_schedule.sh``) — safe to call from a read-only status view.
@ -31,45 +55,40 @@ def read_shutdown_config(path: Path) -> tuple[int, int, int] | None:
if not path.exists():
_logger.warning("Config not found: %s", path)
return None
parsed: dict[str, int] = {}
with path.open() as f:
for line in f:
stripped = line.strip()
for key in _SHUTDOWN_CONFIG_KEYS:
if stripped.startswith(f"{key}="):
parsed[key] = int(stripped.split("=")[1])
if len(parsed) < len(_SHUTDOWN_CONFIG_KEYS):
_logger.warning("Shutdown config missing required values")
return None
return (
parsed["MON_WED_HOUR"],
parsed["THU_SUN_HOUR"],
parsed["MORNING_END_HOUR"],
)
parsed = _parse_config(path)
values: list[int] = []
for name in _SHUTDOWN_CONFIG_NAMES:
if f"{name}_MINUTES" in parsed:
values.append(parsed[f"{name}_MINUTES"])
elif f"{name}_HOUR" in parsed:
values.append(parsed[f"{name}_HOUR"] * _MINUTES_PER_HOUR)
else:
_logger.warning("Shutdown config missing required values")
return None
mon_wed, thu_sun, morning_end = values
return mon_wed, thu_sun, morning_end
class ShutdownMixin(SickDayStateMixin):
"""Mixin providing shutdown schedule adjustment functionality."""
def _apply_earlier_shutdown(self, today: str) -> bool:
"""Read config, save state, and write earlier shutdown hours."""
"""Read config, save state, and write an earlier shutdown time."""
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
if not self._save_sick_day_state(today, mon_wed_hour, thu_sun_hour):
mon_wed, thu_sun, morning_end = config_values
if not self._save_sick_day_state(today, mon_wed, thu_sun):
_logger.error("Failed to save state - aborting adjustment")
return False
new_mon_wed = max(18, mon_wed_hour - 1)
new_thu_sun = max(18, thu_sun_hour - 1)
return self._write_shutdown_config(
new_mon_wed,
new_thu_sun,
morning_end_hour,
max(_SICK_DAY_FLOOR, mon_wed - _SICK_DAY_STEP),
max(_SICK_DAY_FLOOR, thu_sun - _SICK_DAY_STEP),
morning_end,
)
def _adjust_shutdown_time_earlier(self) -> bool:
"""Adjust shutdown schedule 1.5 hours earlier (stricter).
"""Adjust shutdown schedule an hour earlier (stricter).
This can only be used once per day. Original values are saved and
automatically restored when checked the next day.
@ -88,7 +107,7 @@ class ShutdownMixin(SickDayStateMixin):
return False
def _adjust_shutdown_time_later(self) -> bool:
"""Adjust shutdown schedule 2 hours later as workout reward.
"""Push shutdown later by the first workout's reward, capped at 23:00.
Returns True if successful, False otherwise.
"""
@ -96,25 +115,23 @@ class ShutdownMixin(SickDayStateMixin):
config_values = self._read_shutdown_config()
if config_values is None:
return False
mon_wed_hour, thu_sun_hour, morning_end_hour = config_values
new_mon_wed = min(23, mon_wed_hour + FIRST_WORKOUT_BONUS_HOURS)
new_thu_sun = min(23, thu_sun_hour + FIRST_WORKOUT_BONUS_HOURS)
mon_wed, thu_sun, morning_end = config_values
return self._write_shutdown_config(
new_mon_wed,
new_thu_sun,
morning_end_hour,
min(_WORKOUT_CEILING, mon_wed + FIRST_WORKOUT_BONUS_MINUTES),
min(_WORKOUT_CEILING, thu_sun + FIRST_WORKOUT_BONUS_MINUTES),
morning_end,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning("Failed to adjust shutdown time for workout: %s", e)
return False
def _adjust_shutdown_time_by(self, extra_hours: int) -> bool:
"""Adjust shutdown hours by *extra_hours*, capped at 24 (midnight).
def _adjust_shutdown_time_by(self, extra_minutes: int) -> bool:
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
Used for extra-workout bonuses beyond the weekly minimum. A cap of 24
works because ``day-specific-shutdown-check.sh`` fires at 00:00 and
catches it via the morning-window condition (0 <= 300 minutes).
Used for earner and extra-workout bonuses. A cap of midnight works
because ``day-specific-shutdown-check.sh`` fires at 00:00 and catches it
via the morning-window condition; the helper clamps it to 23:00 anyway.
Returns True if successful, False otherwise.
"""
@ -124,19 +141,19 @@ class ShutdownMixin(SickDayStateMixin):
return False
mw, ts, morning = config_values
return self._write_shutdown_config(
min(24, mw + extra_hours),
min(24, ts + extra_hours),
min(_MIDNIGHT, mw + extra_minutes),
min(_MIDNIGHT, ts + extra_minutes),
morning,
restore=True,
)
except (OSError, ValueError) as e:
_logger.warning(
"Failed to adjust shutdown time by %d h: %s", extra_hours, e
"Failed to adjust shutdown time by %s: %s", span(extra_minutes), e
)
return False
def _read_shutdown_config(self) -> tuple[int, int, int] | None:
"""Read shutdown config. Returns (mw_hour, ts_hour, me_hour) or None."""
"""Read shutdown config as (mon_wed, thu_sun, morning_end) minutes, or None."""
return read_shutdown_config(SHUTDOWN_CONFIG_FILE)
def _build_shutdown_cmd(
@ -151,23 +168,23 @@ class ShutdownMixin(SickDayStateMixin):
cmd = ["/usr/bin/sudo", str(ADJUST_SHUTDOWN_SCRIPT)]
if restore:
cmd.append("--restore")
cmd.extend([str(mon_wed), str(thu_sun), str(morning)])
cmd.extend([hhmm(mon_wed), hhmm(thu_sun), hhmm(morning)])
return cmd
def _write_shutdown_config(
self,
mon_wed_hour: int,
thu_sun_hour: int,
morning_end_hour: int,
mon_wed: int,
thu_sun: int,
morning_end: int,
*,
restore: bool = False,
) -> bool:
"""Write new shutdown config values using helper script.
Args:
mon_wed_hour: Shutdown hour for Monday-Wednesday.
thu_sun_hour: Shutdown hour for Thursday-Sunday.
morning_end_hour: Morning end hour.
mon_wed: Monday-Wednesday shutdown, minutes after midnight.
thu_sun: Thursday-Sunday shutdown, minutes after midnight.
morning_end: End of the morning window, minutes after midnight.
restore: If True, allows restoring to later times.
Returns True if successful, False otherwise.
@ -179,18 +196,18 @@ class ShutdownMixin(SickDayStateMixin):
)
return False
cmd = self._build_shutdown_cmd(
mon_wed_hour,
thu_sun_hour,
morning_end_hour,
mon_wed,
thu_sun,
morning_end,
restore=restore,
)
return self._run_shutdown_cmd(cmd, mon_wed_hour, thu_sun_hour)
return self._run_shutdown_cmd(cmd, mon_wed, thu_sun)
def _run_shutdown_cmd(
self,
cmd: list[str],
mon_wed_hour: int,
thu_sun_hour: int,
mon_wed: int,
thu_sun: int,
) -> bool:
"""Execute the shutdown adjustment command."""
try:
@ -204,9 +221,9 @@ class ShutdownMixin(SickDayStateMixin):
_logger.warning("Failed to adjust shutdown config: %s", e)
return False
_logger.info(
"Adjusted shutdown: Mon-Wed=%d, Thu-Sun=%d. %s",
mon_wed_hour,
thu_sun_hour,
"Adjusted shutdown: Mon-Wed=%s, Thu-Sun=%s. %s",
hhmm(mon_wed),
hhmm(thu_sun),
result.stdout.strip(),
)
return True

View File

@ -34,7 +34,13 @@ from typing import TYPE_CHECKING, Any
import earned_time
from screen_locker._day import today_str
from screen_locker._earned import earned_today, flat_answers, flat_earners, to_hours
from screen_locker._earned import (
earned_today,
flat_answers,
flat_earners,
hhmm,
span,
)
from screen_locker._log_io import load_workout_log
from screen_locker._weekly_check import count_day_credits
@ -43,11 +49,17 @@ if TYPE_CHECKING:
_logger = logging.getLogger(__name__)
# Morning end assumed when the live config cannot be read: 05:00.
_DEFAULT_MORNING_END = 5 * 60
def base_hour(day: date | None = None) -> int:
"""The base for ``day`` (default today), every penalty in force taken off."""
def base_minutes(day: date | None = None) -> int:
"""The base shutdown for ``day`` (default today), in minutes after midnight.
Every penalty in force is already taken off.
"""
target = day or datetime.now().astimezone().date()
return to_hours(earned_time.base_for(target, earned_time.EARNERS).shutdown_minutes)
return earned_time.base_for(target, earned_time.EARNERS).shutdown_minutes
def _stamp(earner: earned_time.Earner) -> str:
@ -114,11 +126,11 @@ 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 the base hour if a new calendar day began.
"""Reset the shutdown config to the base if a new calendar day began.
Writes the base plus whatever today has already earned -- the workout
hours in *log_file* (see :func:`today_credit_count`) and every flat
earner's hour -- via *mixin*._write_shutdown_config (with restore=True so
time in *log_file* (see :func:`today_credit_count`) and every flat
earner's time -- 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.
@ -138,11 +150,11 @@ def reset_to_base_if_new_day(
day = earned_time.resolve(
answers, datetime.now().astimezone().date(), earned_time.EARNERS
)
target = to_hours(day.shutdown_minutes)
target = day.shutdown_minutes
# Preserve the morning-end hour from the live config.
# Preserve the morning end from the live config.
config = mixin._read_shutdown_config()
morning_end = config[2] if config else 5
morning_end = config[2] if config else _DEFAULT_MORNING_END
ok: bool = mixin._write_shutdown_config(target, target, morning_end, restore=True)
if not ok:
@ -151,7 +163,7 @@ def reset_to_base_if_new_day(
_clear_sick_day_state(sick_day_state_file)
# The flat hours are stamped here too: the reset already included them,
# The flat bonuses are stamped here too: the reset already included them,
# so the live pass below must not add them a second time today.
new_state: dict[str, Any] = {"last_reset_date": today}
for term in day.terms:
@ -160,12 +172,12 @@ def reset_to_base_if_new_day(
_save_state(state_file, new_state)
earned = " + ".join(
f"{to_hours(t.shutdown_minutes)}h {t.earner.label}" for t in day.terms
f"{span(t.shutdown_minutes)} {t.earner.label}" for t in day.terms
)
_logger.info(
"Daily base reset: %02d:00 (base %d + %s already earned today).",
target,
to_hours(day.base.shutdown_minutes),
"Daily base reset: %s (base %s + %s already earned today).",
hhmm(target),
hhmm(day.base.shutdown_minutes),
earned,
)
return True
@ -176,15 +188,15 @@ def _apply_flat_bonus(
mixin: object,
earner: earned_time.Earner,
) -> bool:
"""Push shutdown later by one earner's once-per-day hour, if it is earned.
"""Push shutdown later by one earner's once-per-day time, if it is earned.
The daily reset only sees a bonus earned before it ran; this is the live
counterpart for the usual case, where the solve or the reading lands
hours later and is picked up by the next timer tick. Idempotent through
the earner's stamp in *state_file*, which the reset sets as well when it
already included the hour.
already included the bonus.
Returns True if the hour was applied on this call.
Returns True if the bonus was applied on this call.
"""
today = today_str()
state = _load_state(state_file)
@ -199,18 +211,18 @@ def _apply_flat_bonus(
return False
if not answer:
return False
hours = to_hours(earner.shutdown_minutes)
if not mixin._adjust_shutdown_time_by(hours):
minutes = earner.shutdown_minutes
if not mixin._adjust_shutdown_time_by(minutes):
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
return False
state[stamp] = today
_save_state(state_file, state)
_logger.info("%s bonus: +%dh shutdown time today.", earner.label, hours)
_logger.info("%s bonus: +%s shutdown time today.", earner.label, span(minutes))
return True
def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
"""Every once-per-day flat hour, in registry order (LeetCode, reading, ...).
"""Every once-per-day flat bonus, in registry order (LeetCode, reading, ...).
The workout is not among them: it is counted, and its live credit is
applied by :class:`~screen_locker._workout_credit.WorkoutCreditMixin`.

View File

@ -4,8 +4,10 @@ Split out of :mod:`screen_locker._shutdown` to keep every file under the
250-line cap. Composed back into ``ShutdownMixin`` there, so callers see no
change.
Tracks the shutdown hours that were in force before a sick day moved them
earlier, so the following day can put them back.
Tracks the shutdown times (minutes after midnight) that were in force before
a sick day moved them earlier, so the following day can put them back. A state
file written before the minutes migration holds whole hours under
``original_*_hour`` and is still read.
"""
from __future__ import annotations
@ -18,6 +20,19 @@ from screen_locker._day import today_str
_logger = logging.getLogger(__name__)
_MINUTES_PER_HOUR = 60
def _original_minutes(state: dict[str, object], band: str) -> int | None:
"""The saved time for *band* (``mon_wed``/``thu_sun``) in minutes, if any."""
minutes = state.get(f"original_{band}_minutes")
if minutes is not None:
return int(str(minutes))
hours = state.get(f"original_{band}_hour")
if hours is not None:
return int(str(hours)) * _MINUTES_PER_HOUR
return None
class SickDayStateMixin:
"""Persists and restores the pre-sick-day shutdown configuration."""
@ -47,14 +62,14 @@ class SickDayStateMixin:
orig_mon_wed: int,
orig_thu_sun: int,
) -> bool:
"""Save sick day state with original config values.
"""Save sick day state with the original times, in minutes.
Returns True if saved successfully, False otherwise.
"""
state = {
"date": date,
"original_mon_wed_hour": orig_mon_wed,
"original_thu_sun_hour": orig_thu_sun,
"original_mon_wed_minutes": orig_mon_wed,
"original_thu_sun_minutes": orig_thu_sun,
}
try:
with SICK_DAY_STATE_FILE.open("w") as f:
@ -69,16 +84,16 @@ class SickDayStateMixin:
def _load_sick_day_state(self) -> tuple[str, int, int] | None:
"""Load sick day state file.
Returns (date, orig_mon_wed_hour, orig_thu_sun_hour) or None.
Returns (date, orig_mon_wed, orig_thu_sun) in minutes, or None.
"""
with SICK_DAY_STATE_FILE.open() as f:
state = json.load(f)
date = state.get("date")
orig_mw = state.get("original_mon_wed_hour")
orig_ts = state.get("original_thu_sun_hour")
orig_mw = _original_minutes(state, "mon_wed")
orig_ts = _original_minutes(state, "thu_sun")
if date is None or orig_mw is None or orig_ts is None:
return None
return (str(date), int(orig_mw), int(orig_ts))
return (str(date), orig_mw, orig_ts)
def _write_restored_config(
self,

View File

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING
from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._decision_log import record_no_decision
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import (
current_streak,
has_extended_early_bird,
@ -105,9 +106,9 @@ def run_status(locker: ScreenLocker) -> None:
if n_filled > 0:
print(f" Auto-filled {n_filled} workout(s) from RunnerUp exports.")
after_count = count_weekly_workouts(log_file)
_, mw_hour, _ = locker._read_shutdown_config() or (None, None, None)
if mw_hour is not None:
print(f" Shutdown tonight: {mw_hour:02d}:00.")
_, thu_sun, _ = locker._read_shutdown_config() or (None, None, None)
if thu_sun is not None:
print(f" Shutdown tonight: {hhmm(thu_sun)}.")
else:
print(" No new workouts found via RunnerUp scan.")
after_count = before_count
@ -162,7 +163,7 @@ def run_status(locker: ScreenLocker) -> None:
cfg = locker._read_shutdown_config()
if cfg:
_mw, _ts, _morning = cfg
print(f" Shutdown tonight : {_mw:02d}:00")
print(f" Shutdown tonight : {hhmm(_mw)}")
# --status is a read-out, not an enforcement run; make that explicit so the
# journal never reads as though the locker evaluated and declined to lock.

View File

@ -13,13 +13,14 @@ from __future__ import annotations
from datetime import datetime
from typing import TYPE_CHECKING
from screen_locker._earned import hhmm
from screen_locker._extra_benefits import (
current_streak,
preview_bonus_if_week_ended_now,
weekly_shutdown_bonus_hours,
)
from screen_locker._shutdown import read_shutdown_config
from screen_locker._shutdown_base import base_hour
from screen_locker._shutdown_base import base_minutes
from screen_locker._status_types import ShutdownProjection, ShutdownProjectionDay
from screen_locker._weekly_check import count_weekly_workouts
@ -35,16 +36,18 @@ _RELAXED_DAY_EXPLANATION = (
)
_WEEKDAY_LABELS = ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun")
_MON_WED_WEEKDAYS = frozenset({0, 1, 2})
# The weekly streak bonus is banked in whole hours.
_MINUTES_PER_HOUR = 60
def _week_rows(
mon_wed_hour: int, thu_sun_hour: int, *, speculative: bool
mon_wed: int, thu_sun: int, *, speculative: bool
) -> tuple[ShutdownProjectionDay, ...]:
"""Build 7 labeled rows, one per weekday, from a Mon-Wed/Thu-Sun band pair."""
return tuple(
ShutdownProjectionDay(
label=label,
hour=mon_wed_hour if weekday in _MON_WED_WEEKDAYS else thu_sun_hour,
minutes=mon_wed if weekday in _MON_WED_WEEKDAYS else thu_sun,
speculative=speculative,
)
for weekday, label in enumerate(_WEEKDAY_LABELS)
@ -60,8 +63,11 @@ def _shutdown_projection(
) -> ShutdownProjection:
"""Build the shutdown-time projection: tonight, rest of week, next week."""
tonight = read_shutdown_config(shutdown_config_file)
bonus = weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local)
base = base_hour(today_local.date())
bonus = (
weekly_shutdown_bonus_hours(extra_benefits_file, today=today_local)
* _MINUTES_PER_HOUR
)
base = base_minutes(today_local.date())
rest_of_week = _week_rows(base + bonus, base + bonus, speculative=False)
this_week_count = count_weekly_workouts(log_file, today=today_local)
@ -69,9 +75,8 @@ def _shutdown_projection(
_would_be_streak, would_be_bonus = preview_bonus_if_week_ended_now(
this_week_count, streak
)
next_week_preview = _week_rows(
base + would_be_bonus, base + would_be_bonus, speculative=True
)
would_be = base + would_be_bonus * _MINUTES_PER_HOUR
next_week_preview = _week_rows(would_be, would_be, speculative=True)
return ShutdownProjection(
tonight=tonight,
@ -81,27 +86,27 @@ def _shutdown_projection(
)
def _tonight_hour(snapshot: StatusSnapshot) -> int | None:
"""Pick the shutdown hour band (Mon-Wed vs Thu-Sun) matching today.
def _tonight_minutes(snapshot: StatusSnapshot) -> int | None:
"""Pick the shutdown band (Mon-Wed vs Thu-Sun) matching today, in minutes.
Uses ``snapshot.generated_at`` (not a fresh ``datetime.now()``) so the
summary line is a pure function of the snapshot it was handed.
"""
if snapshot.shutdown.tonight is None:
return None
mon_wed_hour, thu_sun_hour, _morning = snapshot.shutdown.tonight
mon_wed, thu_sun, _morning = snapshot.shutdown.tonight
generated_weekday = (
datetime.fromisoformat(snapshot.generated_at).astimezone().weekday()
)
return mon_wed_hour if generated_weekday in _MON_WED_WEEKDAYS else thu_sun_hour
return mon_wed if generated_weekday in _MON_WED_WEEKDAYS else thu_sun
def format_summary_line(snapshot: StatusSnapshot) -> str:
"""Cheap one-liner for the i3blocks summary (e.g. before/after a click)."""
week = snapshot.week
mark = "✓" if week.remaining == 0 else "…"
tonight_hour = _tonight_hour(snapshot)
tonight_str = f"{tonight_hour:02d}:00" if tonight_hour is not None else "?"
tonight = _tonight_minutes(snapshot)
tonight_str = hhmm(tonight) if tonight is not None else "?"
sick = snapshot.sick_budget
return (
f"{mark} {week.counted_count}/{week.minimum} workouts · "

View File

@ -12,6 +12,7 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from screen_locker._earned import hhmm
from screen_locker._sync_status import format_sync_line, gather_sync_status
if TYPE_CHECKING:
@ -156,10 +157,9 @@ class StatusSectionsMixin:
del parent
self._label("Shutdown Time", role="body", pad="sm")
if shutdown.tonight is not None:
mon_wed_hour, thu_sun_hour, _morning = shutdown.tonight
mon_wed, thu_sun, _morning = shutdown.tonight
self._text(
f"Live config — Mon-Wed {mon_wed_hour:02d}:00, "
f"Thu-Sun {thu_sun_hour:02d}:00",
f"Live config — Mon-Wed {hhmm(mon_wed)}, Thu-Sun {hhmm(thu_sun)}",
role="body",
)
else:
@ -173,7 +173,7 @@ class StatusSectionsMixin:
# exception to the 16px floor, all sharing one muted tone instead of
# three unrelated ad hoc grays.
rest_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.rest_of_week
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.rest_of_week
)
self._text(
f"Rest of week: {rest_line}",
@ -181,7 +181,7 @@ class StatusSectionsMixin:
color=self._colors.palette.muted,
)
next_line = ", ".join(
f"{d.label} {d.hour:02d}:00" for d in shutdown.next_week_preview
f"{d.label} {hhmm(d.minutes)}" for d in shutdown.next_week_preview
)
self._text(
f"Next week (speculative): {next_line}",

View File

@ -68,16 +68,19 @@ class ManualWorkoutBudgetStatus:
@dataclass(frozen=True)
class ShutdownProjectionDay:
"""One labeled row in a shutdown-time projection."""
"""One labeled row in a shutdown-time projection (minutes after midnight)."""
label: str
hour: int
minutes: int
speculative: bool
@dataclass(frozen=True)
class ShutdownProjection:
"""Tonight's live config plus deterministic/speculative week projections."""
"""Tonight's live config plus deterministic/speculative week projections.
``tonight`` is (mon_wed, thu_sun, morning_end), minutes after midnight.
"""
tonight: tuple[int, int, int] | None
rest_of_week: tuple[ShutdownProjectionDay, ...]

View File

@ -12,8 +12,10 @@ from concurrent.futures import ThreadPoolExecutor # pylint: disable=no-name-in-
from dataclasses import dataclass
from typing import TYPE_CHECKING
from screen_locker._earned import span
from screen_locker._status_data import gather_status
from screen_locker._weekly_check import WEEKLY_WORKOUT_MINIMUM, count_weekly_workouts
from screen_locker._workout_credit import EXTRA_WORKOUT_BONUS_MINUTES
if TYPE_CHECKING:
from collections.abc import Callable
@ -81,7 +83,8 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
bonus = 0
if sl_filled:
new_count = count_weekly_workouts(verifier.log_file)
bonus = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count + filled))
extra = max(0, new_count - max(WEEKLY_WORKOUT_MINIMUM, prev_count + filled))
bonus = extra * EXTRA_WORKOUT_BONUS_MINUTES
if bonus > 0:
verifier._adjust_shutdown_time_by(bonus)
filled += sl_filled
@ -90,7 +93,7 @@ def _backfill_week_and_apply_bonus(verifier: ScreenLocker) -> str | None:
plural = "workout" if filled == 1 else "workouts"
message = f"Auto-filled {filled} {plural} from earlier this week."
if bonus > 0:
message += f" +{bonus}h shutdown time."
message += f" +{span(bonus)} shutdown time."
return message
@ -220,7 +223,9 @@ class PhoneCheckMixin:
if credit.shutdown_adjusted:
lines.append("Shutdown time +2h later!")
if credit.extra_bonus_delta > 0:
lines.append(f"Extra workout today! +{credit.extra_bonus_delta}h tonight")
lines.append(
f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight"
)
if credit.new_debt is not None:
lines.append(f"Workout debt: {credit.new_debt}")
return lines

View File

@ -11,6 +11,7 @@ from __future__ import annotations
import logging
from screen_locker._constants import EXTRA_BENEFITS_FILE, SHUTDOWN_BASE_FILE
from screen_locker._earned import span
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
@ -25,6 +26,9 @@ __all__ = ["SyncMixin"]
_logger = logging.getLogger(__name__)
# The weekly streak bonus is banked in whole hours; the schedule takes minutes.
_MINUTES_PER_HOUR = 60
class SyncMixin:
"""Publish, ingest and credit manual workouts synced between devices."""
@ -103,13 +107,13 @@ class SyncMixin:
credit = self._apply_credit_for_written_entry(prior_entries)
if credit.shutdown_adjusted:
_logger.info(
"Ingested workout pushed shutdown time +2h: %s",
"Ingested workout pushed shutdown time later: %s",
entry.get("source", ""),
)
elif credit.extra_bonus_delta:
_logger.info(
"Ingested workout added +%dh shutdown time: %s",
credit.extra_bonus_delta,
"Ingested workout added +%s shutdown time: %s",
span(credit.extra_bonus_delta),
entry.get("source", ""),
)
@ -133,5 +137,5 @@ class SyncMixin:
def _apply_weekly_shutdown_bonus(self) -> None:
"""Layer this week's earned shutdown bonus back on top of the fresh base."""
bonus = weekly_shutdown_bonus_hours(EXTRA_BENEFITS_FILE)
if bonus > 0 and self._adjust_shutdown_time_by(bonus):
if bonus > 0 and self._adjust_shutdown_time_by(bonus * _MINUTES_PER_HOUR):
_logger.info("Weekly bonus: +%dh shutdown time this week.", bonus)

View File

@ -9,6 +9,7 @@ commitment prompt or the close), which is exactly the kind of thing the other
from __future__ import annotations
from screen_locker._constants import EXTRA_BENEFITS_FILE
from screen_locker._earned import span
from screen_locker._extra_benefits import current_streak
_UNLOCK_DELAY_MS = 1500
@ -42,7 +43,7 @@ class UnlockViewMixin:
)
if credit.extra_bonus_delta > 0:
self._text(
f"Extra workout today! +{credit.extra_bonus_delta}h tonight",
f"Extra workout today! +{span(credit.extra_bonus_delta)} tonight",
role="subtitle",
color=self._colors.palette.warning,
)

View File

@ -16,7 +16,6 @@ from typing import TYPE_CHECKING
import earned_time
from screen_locker import _sick_tracker
from screen_locker._earned import to_hours
from screen_locker._weekly_check import (
COUNTED_WORKOUT_TYPES,
PC_WORKOUT_TYPE,
@ -30,17 +29,17 @@ _logger = logging.getLogger(__name__)
# The shutdown reward for a day's workouts, from the shared registry
# (``earned_time.WORKOUT``): the first counted workout pushes shutdown later by
# FIRST_WORKOUT_BONUS_HOURS, every further one by EXTRA_WORKOUT_BONUS_HOURS.
# FIRST_WORKOUT_BONUS_MINUTES, every further one by EXTRA_WORKOUT_BONUS_MINUTES.
# The live credit paths below and the daily base reset (``_shutdown_base``)
# both derive from the registry, so a day's earned hours can be recomputed
# both derive from the registry, so a day's earned time can be recomputed
# from the log instead of being lost when the credit landed before the reset.
FIRST_WORKOUT_BONUS_HOURS = to_hours(earned_time.WORKOUT.shutdown_minutes)
EXTRA_WORKOUT_BONUS_HOURS = to_hours(earned_time.WORKOUT.extra_shutdown_minutes)
FIRST_WORKOUT_BONUS_MINUTES = earned_time.WORKOUT.shutdown_minutes
EXTRA_WORKOUT_BONUS_MINUTES = earned_time.WORKOUT.extra_shutdown_minutes
def earned_shutdown_bonus_hours(credit_count: int) -> int:
"""Return the shutdown hours a day with *credit_count* distinct workouts earned."""
return to_hours(earned_time.WORKOUT.shutdown_for(credit_count))
def earned_shutdown_bonus_minutes(credit_count: int) -> int:
"""Return the shutdown minutes a day with *credit_count* workouts earned."""
return earned_time.WORKOUT.shutdown_for(credit_count)
@dataclass(frozen=True)
@ -71,7 +70,7 @@ class WorkoutCreditMixin:
return False
adjusted = self._adjust_shutdown_time_later()
if adjusted:
_logger.info("Shutdown time moved 2 hours later as workout reward")
_logger.info("Shutdown time moved later as workout reward")
return adjusted
def _clear_debt_on_verified_workout(self) -> int | None:
@ -129,7 +128,7 @@ class WorkoutCreditMixin:
shutdown_adjusted = self._try_adjust_shutdown_for_workout()
elif self.workout_data.get("type") in COUNTED_WORKOUT_TYPES:
old_cfg = self._read_shutdown_config()
if old_cfg and self._adjust_shutdown_time_by(EXTRA_WORKOUT_BONUS_HOURS):
if old_cfg and self._adjust_shutdown_time_by(EXTRA_WORKOUT_BONUS_MINUTES):
new_cfg = self._read_shutdown_config()
if new_cfg:
extra_bonus_delta = new_cfg[1] - old_cfg[1]

View File

@ -3,64 +3,92 @@
#
# Usage: sudo adjust_shutdown_schedule.sh [--restore] <mon_wed> <thu_sun> <morning_end>
#
# Each time is HH:MM (00:00-24:00) or a legacy bare whole hour (0-24). The config
# stores minutes after midnight in *_MINUTES plus a strict-rounded *_HOUR copy
# for readers that predate minutes: evening times round DOWN, the morning end
# UP, so an old reader can only ever shut down earlier than intended.
#
# Without --restore only STRICTER schedules are accepted. "Stricter" is not the
# same direction for all three values, which is the subtlety this script got
# wrong for months:
#
# MON_WED_HOUR / THU_SUN_HOUR -> smaller is stricter (shut down earlier)
# MORNING_END_HOUR -> LARGER is stricter
# MON_WED / THU_SUN -> smaller is stricter (shut down earlier)
# MORNING_END -> LARGER is stricter
#
# day-specific-shutdown-check.sh shuts down while the clock is *below*
# morning_end_minutes, so raising MORNING_END_HOUR lengthens the morning
# morning_end_minutes, so raising the morning end lengthens the morning
# shutdown window. Comparing it with `-gt`, as the two evening hours do, would
# ratchet it backwards and quietly allow the window to be shortened.
#
# With --restore the schedule may be loosened -- that is how the workout reward
# pushes shutdown later -- but never past RESTORE_CEILING, and every restore is
# recorded. Without the ceiling, `--restore 24 24 0` is an off switch for the
# whole ratchet.
# recorded. Without the ceiling, `--restore 24:00 24:00 00:00` is an off switch
# for the whole ratchet.
#
# Add to /etc/sudoers.d/workout-locker:
# <username> ALL=(root) NOPASSWD: /home/kuhy/src/screen-locker/screen_locker/adjust_shutdown_schedule.sh
set -euo pipefail
# Overridable so the test harness can point at a fixture tree. Production never
# sets these; a test that forgets to is writing to the real /etc, which is why
# the harness sets both explicitly rather than relying on a default.
# Overridable so the test harness can point at a fixture tree; production never
# sets these, and the harness sets them explicitly rather than trust a default.
CONFIG_FILE="${SHUTDOWN_CONFIG_FILE:-/etc/shutdown-schedule.conf}"
GUARD_NAME="${SHUTDOWN_GUARD_NAME:-shutdown-schedule}"
readonly CONFIG_FILE GUARD_NAME
# 24 is a real, documented value here: _shutdown.py's extra-workout bonus uses
# it to mean midnight, and day-specific-shutdown-check.sh catches it via the
# morning-window condition. The old error message claimed 0-23 while the code
# allowed 24; the message was the thing that was wrong.
readonly MAX_HOUR=24
# 24:00 (1440) is a real value: _shutdown.py's bonus uses it for midnight, which
# day-specific-shutdown-check.sh catches via the morning-window condition.
readonly MAX_MINUTES=1440
readonly MINUTES_PER_HOUR=60
# The latest the machine may ever shut down, whatever has been earned. --restore
# exists so the workout reward can loosen the schedule, and without a ceiling it
# is simply an off switch for the ratchet: `--restore 24 24 0` from any shell
# undoes everything. Bonuses above this are clamped, not refused, so the reward
# path keeps working.
readonly RESTORE_CEILING=23
# is simply an off switch for the ratchet. Bonuses above this are clamped, not
# refused, so the reward path keeps working.
readonly RESTORE_CEILING=1380 # 23:00
readonly RESTORE_LOG="${SHUTDOWN_RESTORE_LOG:-/var/log/shutdown-restore.log}"
usage() {
echo "Usage: $0 [--restore] <mon_wed_hour> <thu_sun_hour> <morning_end_hour>" >&2
echo "Usage: $0 [--restore] <mon_wed> <thu_sun> <morning_end>" >&2
echo " each time HH:MM (00:00-24:00) or a whole hour (0-24)" >&2
exit 1
}
# Validate that every argument is an hour in [0, 24]; 24 means midnight.
validate_hours() {
local hour
for hour in "$@"; do
if ! [[ "$hour" =~ ^[0-9]+$ ]] || [[ "$hour" -gt $MAX_HOUR ]]; then
echo "Error: Hours must be integers between 0 and ${MAX_HOUR}" \
"(24 means midnight)" >&2
return 1
fi
done
# Print a time argument as minutes after midnight. Accepts HH:MM or a bare
# whole hour (the pre-minutes form, still sent by older callers).
to_minutes() {
local value="$1" minutes
if [[ "$value" =~ ^([0-9]{1,2}):([0-5][0-9])$ ]]; then
minutes=$((10#${BASH_REMATCH[1]} * MINUTES_PER_HOUR + 10#${BASH_REMATCH[2]}))
elif [[ "$value" =~ ^[0-9]{1,2}$ ]]; then
minutes=$((10#$value * MINUTES_PER_HOUR))
else
minutes=-1
fi
if [[ $minutes -lt 0 ]] || [[ $minutes -gt $MAX_MINUTES ]]; then
echo "Error: '${value}' is not a time between 00:00 and 24:00" \
"(HH:MM, or a whole hour 0-24; 24:00 means midnight)" >&2
return 1
fi
printf '%d' "$minutes"
}
# HH:MM for a count of minutes after midnight.
hhmm() {
printf '%02d:%02d' "$(($1 / MINUTES_PER_HOUR))" "$(($1 % MINUTES_PER_HOUR))"
}
# A config value in minutes: the *_MINUTES key when present, else the legacy
# *_HOUR key times 60, else the given default.
config_minutes() {
local minutes="$1" hour="$2" default="$3"
if [[ "$minutes" =~ ^[0-9]+$ ]]; then
printf '%d' "$((10#$minutes))"
elif [[ "$hour" =~ ^[0-9]+$ ]]; then
printf '%d' "$((10#$hour * MINUTES_PER_HOUR))"
else
printf '%d' "$default"
fi
}
# Reject a schedule that is looser than the one currently on disk.
@ -79,14 +107,15 @@ check_stricter_only() {
# defaults below instead of silently inheriting whatever these names last
# held. In production the script is a fresh process and they are unset
# anyway; this makes the function honest when called twice in one shell.
unset MON_WED_HOUR THU_SUN_HOUR MORNING_END_HOUR
unset MON_WED_HOUR THU_SUN_HOUR MORNING_END_HOUR \
MON_WED_MINUTES THU_SUN_MINUTES MORNING_END_MINUTES
# shellcheck source=/dev/null
source "$CONFIG_FILE" 2>/dev/null || true
# Defaults are the LOOSEST value in each direction, so an unreadable config
# can never reject a legitimate first write.
old_mon_wed="${MON_WED_HOUR:-$MAX_HOUR}"
old_thu_sun="${THU_SUN_HOUR:-$MAX_HOUR}"
old_morning_end="${MORNING_END_HOUR:-0}"
old_mon_wed="$(config_minutes "${MON_WED_MINUTES:-}" "${MON_WED_HOUR:-}" "$MAX_MINUTES")"
old_thu_sun="$(config_minutes "${THU_SUN_MINUTES:-}" "${THU_SUN_HOUR:-}" "$MAX_MINUTES")"
old_morning_end="$(config_minutes "${MORNING_END_MINUTES:-}" "${MORNING_END_HOUR:-}" 0)"
if [[ "$new_mon_wed" -gt "$old_mon_wed" ]] ||
[[ "$new_thu_sun" -gt "$old_thu_sun" ]]; then
@ -98,7 +127,7 @@ check_stricter_only() {
# Note the inverted comparison: a SMALLER morning end is looser.
if [[ "$new_morning_end" -lt "$old_morning_end" ]]; then
echo "Error: Can only make schedule stricter (a later morning end)" >&2
echo " ${old_morning_end}:00 -> ${new_morning_end}:00 shortens the" \
echo " $(hhmm "$old_morning_end") -> $(hhmm "$new_morning_end") shortens the" \
"morning shutdown window" >&2
echo "Use --restore flag to restore original times" >&2
return 1
@ -112,13 +141,14 @@ check_stricter_only() {
# line matching the current time makes it exit 0 and skip the shutdown outright.
# An "audit trail" written there would suppress the very curfew it documents.
clamp_restore() {
local hour="$1" label="$2"
if [[ "$hour" -gt $RESTORE_CEILING ]]; then
echo "Note: ${label} ${hour}:00 clamped to the ${RESTORE_CEILING}:00 ceiling" >&2
local minutes="$1" label="$2"
if [[ "$minutes" -gt $RESTORE_CEILING ]]; then
echo "Note: ${label} $(hhmm "$minutes") clamped to the" \
"$(hhmm "$RESTORE_CEILING") ceiling" >&2
printf '%s' "$RESTORE_CEILING"
return 0
fi
printf '%s' "$hour"
printf '%s' "$minutes"
}
# A restore that is not recorded is a loosening nobody can audit, so a failure
@ -127,7 +157,7 @@ clamp_restore() {
# than the missing line.
log_restore() {
local line
line="$(date -Is) | RESTORE | mon_wed=$1 thu_sun=$2 morning_end=$3 | by=${SUDO_USER:-${USER:-unknown}}"
line="$(date -Is) | RESTORE | mon_wed=$(hhmm "$1") thu_sun=$(hhmm "$2") morning_end=$(hhmm "$3") | by=${SUDO_USER:-${USER:-unknown}}"
if ! printf '%s\n' "$line" >>"$RESTORE_LOG" 2>/dev/null; then
echo "Warning: could not record this restore in ${RESTORE_LOG};" \
"the schedule was still loosened" >&2
@ -166,6 +196,23 @@ write_schedule() {
chattr +i "$CONFIG_FILE" || echo "Warning: Could not set immutable on $CONFIG_FILE" >&2
}
# The config body. *_MINUTES is authoritative; *_HOUR is the strict-rounded
# copy for pre-minutes readers (evening down, morning end up).
render_schedule() {
local mon_wed="$1" thu_sun="$2" morning_end="$3"
printf '%s\n' \
"# Shutdown schedule configuration" \
"# Modified by screen_locker sick day feature at $(date)" \
"# *_MINUTES = minutes after midnight (authoritative); *_HOUR = the same" \
"# time rounded the strict way, for readers that predate minutes." \
"MON_WED_MINUTES=${mon_wed}" \
"THU_SUN_MINUTES=${thu_sun}" \
"MORNING_END_MINUTES=${morning_end}" \
"MON_WED_HOUR=$((mon_wed / MINUTES_PER_HOUR))" \
"THU_SUN_HOUR=$((thu_sun / MINUTES_PER_HOUR))" \
"MORNING_END_HOUR=$(((morning_end + MINUTES_PER_HOUR - 1) / MINUTES_PER_HOUR))"
}
main() {
local restore_mode=false
if [[ "${1:-}" == "--restore" ]]; then
@ -174,9 +221,10 @@ main() {
fi
[[ $# -eq 3 ]] || usage
local mon_wed="$1" thu_sun="$2" morning_end="$3"
validate_hours "$mon_wed" "$thu_sun" "$morning_end" || exit 1
local mon_wed thu_sun morning_end
mon_wed="$(to_minutes "$1")" || exit 1
thu_sun="$(to_minutes "$2")" || exit 1
morning_end="$(to_minutes "$3")" || exit 1
if [[ "$restore_mode" == false ]]; then
check_stricter_only "$mon_wed" "$thu_sun" "$morning_end" || exit 1
@ -189,16 +237,11 @@ main() {
local canonical body
canonical="$(resolve_canonical)" || exit 1
body="# Shutdown schedule configuration
# Modified by screen_locker sick day feature at $(date)
MON_WED_HOUR=${mon_wed}
THU_SUN_HOUR=${thu_sun}
MORNING_END_HOUR=${morning_end}
"
body="$(render_schedule "$mon_wed" "$thu_sun" "$morning_end")"$'\n'
write_schedule "$canonical" "$body"
echo "Shutdown schedule updated: Mon-Wed=${mon_wed}:00," \
"Thu-Sun=${thu_sun}:00, Morning end=${morning_end}:00"
echo "Shutdown schedule updated: Mon-Wed=$(hhmm "$mon_wed")," \
"Thu-Sun=$(hhmm "$thu_sun"), Morning end=$(hhmm "$morning_end")"
}
# Sourced by the test harness; executed in production.

View File

@ -24,7 +24,7 @@ def _make_locker(
*,
n_filled: int = 0,
bonus_applied: bool = False,
cfg: tuple | None = (22, 22, 5),
cfg: tuple | None = (1320, 1320, 300),
):
"""Build a minimal locker-like namespace for _status.run_status()."""
locker = SimpleNamespace(

View File

@ -112,14 +112,14 @@ def _manual_workout_budget(
def _shutdown(
*, tonight: tuple[int, int, int] | None = (21, 21, 5)
*, tonight: tuple[int, int, int] | None = (1260, 1260, 300)
) -> ShutdownProjection:
rest = tuple(
ShutdownProjectionDay(label=lbl, hour=21, speculative=False)
ShutdownProjectionDay(label=lbl, minutes=1260, speculative=False)
for lbl in _WEEKDAY_LABELS
)
preview = tuple(
ShutdownProjectionDay(label=lbl, hour=21, speculative=True)
ShutdownProjectionDay(label=lbl, minutes=1260, speculative=True)
for lbl in _WEEKDAY_LABELS
)
return ShutdownProjection(

View File

@ -68,6 +68,6 @@ def setup_unlock(
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(return_value=(22, 22, 5)),
MagicMock(return_value=(1320, 1320, 300)),
)
return locker

View File

@ -0,0 +1,121 @@
#!/usr/bin/env bash
# Shared harness for the adjust_shutdown_schedule.sh tests (sourced, not run).
#
# This ratchet had no test at all, which is how the morning end went unchecked
# for months while the two evening times were guarded. The direction cases
# below are the point of the file: "stricter" means SMALLER for the evening
# times and LARGER for the morning end. Times are minutes after midnight; the
# config also carries strict-rounded *_HOUR copies for pre-minutes readers.
#
# Nothing here touches /etc: SHUTDOWN_CONFIG_FILE points at a tmpdir, and
# chattr/guardctl are shimmed on PATH because chattr cannot mark a file
# immutable on tmpfs and would abort the write.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly TARGET="${SCRIPT_DIR}/../../adjust_shutdown_schedule.sh"
PASS=0
FAIL=0
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
# The ratchet returns 0 to allow a write and 1 to block it. Which way it fails
# is the entire point, so assert on the exit status explicitly.
_t_allows() {
local what="$1"
shift
if "$@" >/dev/null 2>&1; then
_t_pass "$what"
else
_t_fail "$what (blocked when it should have allowed)"
fi
}
_t_blocks() {
local what="$1"
shift
if "$@" >/dev/null 2>&1; then
_t_fail "$what (allowed when it should have blocked)"
else
_t_pass "$what"
fi
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
_t_has() {
local haystack="$1" needle="$2" what="$3"
if [[ "$haystack" == *"$needle"* ]]; then
_t_pass "$what"
else
_t_fail "$what (want substring '${needle}')"
fi
}
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# Shim the two commands that need root or a real filesystem.
mkdir -p "$TMP/bin"
cat >"$TMP/bin/chattr" <<'EOF'
#!/bin/bash
exit 0
EOF
cat >"$TMP/bin/guardctl" <<'EOF'
#!/bin/bash
printf '%s' "${FAKE_CANONICAL:-}"
EOF
chmod +x "$TMP/bin/chattr" "$TMP/bin/guardctl"
export PATH="$TMP/bin:$PATH"
export SHUTDOWN_CONFIG_FILE="$TMP/shutdown-schedule.conf"
export SHUTDOWN_RESTORE_LOG="$TMP/restore.log"
export FAKE_CANONICAL="$TMP/canonical"
# A config as written since the minutes migration: authoritative *_MINUTES.
write_config() {
cat >"$SHUTDOWN_CONFIG_FILE" <<EOF
MON_WED_MINUTES=$1
THU_SUN_MINUTES=$2
MORNING_END_MINUTES=$3
EOF
}
# A config as written before it: whole-hour keys only.
write_legacy_config() {
cat >"$SHUTDOWN_CONFIG_FILE" <<EOF
MON_WED_HOUR=$1
THU_SUN_HOUR=$2
MORNING_END_HOUR=$3
EOF
}
# shellcheck source=/dev/null
source "$TARGET"
# Run main in a subshell; the allow/block helpers only need its exit status.
run_main() { (main "$@"); }
_t_summary() {
echo
printf 'passed: %d, failed: %d\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
}

View File

@ -1,200 +1,82 @@
#!/usr/bin/env bash
# Tests for the shutdown-schedule ratchet.
# Tests for the shutdown-schedule ratchet: time parsing and the stricter-only
# check. The write path (dual keys, rounding, --restore) is in
# test_adjust_shutdown_schedule_write.sh; both share _adjust_harness.sh.
#
# This ratchet had no test at all, which is how MORNING_END_HOUR went
# unchecked for months while the two evening hours were guarded. The direction
# cases below are the point of the file: "stricter" means SMALLER for the
# evening hours and LARGER for the morning end.
#
# Nothing here touches /etc: SHUTDOWN_CONFIG_FILE points at a tmpdir, and
# chattr/guardctl are shimmed on PATH because chattr cannot mark a file
# immutable on tmpfs and would abort the write.
# This ratchet had no test at all, which is how the morning end went unchecked
# for months while the two evening times were guarded. The direction cases
# below are the point of the file: "stricter" means SMALLER for the evening
# times and LARGER for the morning end. Times are minutes after midnight.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
readonly SCRIPT_DIR
readonly TARGET="${SCRIPT_DIR}/../../adjust_shutdown_schedule.sh"
# shellcheck source=_adjust_harness.sh
source "$(dirname "${BASH_SOURCE[0]}")/_adjust_harness.sh"
PASS=0
FAIL=0
echo "to_minutes"
_t_eq "1260" "$(to_minutes 21:00)" "HH:MM becomes minutes"
_t_eq "1110" "$(to_minutes 18:30)" "keeps the minutes of 18:30"
_t_eq "1260" "$(to_minutes 21)" "a legacy bare hour is hours x 60"
_t_eq "1440" "$(to_minutes 24:00)" "24:00 means midnight"
_t_eq "300" "$(to_minutes 5)" "a one-digit hour works"
_t_eq "300" "$(to_minutes 05:00)" "a zero-padded hour is not read as octal"
_t_eq "540" "$(to_minutes 09:00)" "09:00 is not read as octal"
_t_blocks "rejects a non-time" to_minutes abc
_t_blocks "rejects 24:30 (past midnight)" to_minutes 24:30
_t_blocks "rejects 25 (the old out-of-range hour)" to_minutes 25
_t_blocks "rejects minutes of 60" to_minutes 12:60
_t_blocks "rejects a negative (the minus makes it non-numeric)" to_minutes -1
_t_eq "21:05" "$(hhmm 1265)" "hhmm renders minutes zero-padded"
_t_pass() {
PASS=$((PASS + 1))
printf ' OK: %s\n' "$1"
}
echo "check_stricter_only: evening times (smaller is stricter)"
write_config 1380 1380 300
_t_allows "allows an earlier Mon-Wed" check_stricter_only 1260 1380 300
_t_allows "allows an unchanged schedule" check_stricter_only 1380 1380 300
_t_blocks "blocks a later Mon-Wed" check_stricter_only 1440 1380 300
_t_blocks "blocks a later Thu-Sun" check_stricter_only 1380 1440 300
_t_fail() {
FAIL=$((FAIL + 1))
printf ' FAIL: %s\n' "$1"
}
# The ratchet returns 0 to allow a write and 1 to block it. Which way it fails
# is the entire point, so assert on the exit status explicitly.
_t_allows() {
local what="$1"
shift
if "$@" >/dev/null 2>&1; then
_t_pass "$what"
else
_t_fail "$what (blocked when it should have allowed)"
fi
}
_t_blocks() {
local what="$1"
shift
if "$@" >/dev/null 2>&1; then
_t_fail "$what (allowed when it should have blocked)"
else
_t_pass "$what"
fi
}
_t_eq() {
local want="$1" got="$2" what="$3"
if [[ "$got" == "$want" ]]; then
_t_pass "$what"
else
_t_fail "$what (want '${want}', got '${got}')"
fi
}
_t_has() {
local haystack="$1" needle="$2" what="$3"
if [[ "$haystack" == *"$needle"* ]]; then
_t_pass "$what"
else
_t_fail "$what (want substring '${needle}')"
fi
}
TMP="$(mktemp -d)"
trap 'rm -rf "$TMP"' EXIT
# Shim the two commands that need root or a real filesystem.
mkdir -p "$TMP/bin"
cat >"$TMP/bin/chattr" <<'EOF'
#!/bin/bash
exit 0
EOF
cat >"$TMP/bin/guardctl" <<'EOF'
#!/bin/bash
printf '%s' "${FAKE_CANONICAL:-}"
EOF
chmod +x "$TMP/bin/chattr" "$TMP/bin/guardctl"
export PATH="$TMP/bin:$PATH"
export SHUTDOWN_CONFIG_FILE="$TMP/shutdown-schedule.conf"
export SHUTDOWN_RESTORE_LOG="$TMP/restore.log"
export FAKE_CANONICAL="$TMP/canonical"
write_config() {
cat >"$SHUTDOWN_CONFIG_FILE" <<EOF
MON_WED_HOUR=$1
THU_SUN_HOUR=$2
MORNING_END_HOUR=$3
EOF
}
# shellcheck source=/dev/null
source "$TARGET"
echo "validate_hours"
_t_allows "accepts 0 and 23" validate_hours 0 23 5
_t_blocks "rejects a non-integer" validate_hours 21 abc 5
_t_blocks "rejects a negative (the minus makes it non-numeric)" \
validate_hours -1 21 5
echo "check_stricter_only: evening hours (smaller is stricter)"
write_config 23 23 5
_t_allows "allows an earlier Mon-Wed" check_stricter_only 21 23 5
_t_allows "allows an unchanged schedule" check_stricter_only 23 23 5
_t_blocks "blocks a later Mon-Wed" check_stricter_only 24 23 5
_t_blocks "blocks a later Thu-Sun" check_stricter_only 23 24 5
echo "check_stricter_only: minute precision"
write_config 1260 1260 300
_t_blocks "blocks 21:01 when 21:00 is live" check_stricter_only 1261 1260 300
_t_allows "allows 20:59 when 21:00 is live" check_stricter_only 1259 1260 300
_t_allows "allows an 18:30 Thu-Sun when 21:00 is live" \
check_stricter_only 1260 1110 300
echo "check_stricter_only: morning end (LARGER is stricter -- inverted)"
write_config 23 23 5
write_config 1380 1380 300
_t_allows "allows a later morning end, which lengthens the window" \
check_stricter_only 23 23 7
check_stricter_only 1380 1380 420
_t_blocks "blocks an earlier morning end, which shortens the window" \
check_stricter_only 23 23 3
out="$(check_stricter_only 23 23 3 2>&1 || true)"
check_stricter_only 1380 1380 180
_t_blocks "blocks a morning end only 1 minute earlier" \
check_stricter_only 1380 1380 299
out="$(check_stricter_only 1380 1380 180 2>&1 || true)"
_t_has "$out" "shortens the" "explains WHY the morning end was rejected"
echo "check_stricter_only: the source-clobber trap"
# `source` overwrites MON_WED_HOUR et al with the OLD values. If the comparison
# read those globals instead of its arguments it would compare a value with
# itself and pass everything.
write_config 21 21 5
# `source` overwrites the config variables with the OLD values. If the
# comparison read those globals instead of its arguments it would compare a
# value with itself and pass everything.
write_config 1260 1260 300
_t_blocks "still blocks a loosening after source has clobbered the globals" \
check_stricter_only 23 23 5
check_stricter_only 1380 1380 300
echo "check_stricter_only: a legacy hour-only config reads as the old values"
write_legacy_config 21 21 5
_t_blocks "blocks 21:01 against a legacy 21" check_stricter_only 1261 1260 300
_t_allows "allows 21:00 against a legacy 21" check_stricter_only 1260 1260 300
_t_blocks "blocks a morning end below a legacy 5" \
check_stricter_only 1260 1260 299
write_config 1110 1110 300
{ printf 'MON_WED_HOUR=23\n'; } >>"$SHUTDOWN_CONFIG_FILE"
_t_blocks "the *_MINUTES key wins over a stale *_HOUR key" \
check_stricter_only 1200 1110 300
echo "check_stricter_only: missing or unreadable config"
rm -f "$SHUTDOWN_CONFIG_FILE"
_t_allows "allows any write when no config exists yet" check_stricter_only 23 23 0
_t_allows "allows any write when no config exists yet" \
check_stricter_only 1380 1380 0
printf 'garbage not a config\n' >"$SHUTDOWN_CONFIG_FILE"
_t_allows "falls back to the loosest defaults rather than rejecting" \
check_stricter_only 23 23 0
check_stricter_only 1380 1380 0
echo "end to end"
write_config 23 23 5
if (main 21 21 5 >/dev/null 2>&1); then
_t_pass "a stricter write succeeds"
else
_t_fail "a stricter write succeeds"
fi
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_HOUR=21" "config was rewritten"
_t_has "$(cat "$FAKE_CANONICAL")" "MON_WED_HOUR=21" "canonical copy was written too"
write_config 21 21 5
if (main 23 23 5 >/dev/null 2>&1); then
_t_fail "a looser write is refused"
else
_t_pass "a looser write is refused"
fi
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_HOUR=21" "config was left untouched"
# --restore still bypasses the ratchet on purpose: the workout reward pushes the
# schedule later than shutdown_base.json, so bounding it here would break a
# daily flow. Bounding it is a separate, open decision.
write_config 21 21 5
if (main --restore 23 23 5 >/dev/null 2>&1); then
_t_pass "--restore still loosens (deliberately unchanged)"
else
_t_fail "--restore still loosens (deliberately unchanged)"
fi
echo "validate_hours: 24 means midnight"
# _shutdown.py's extra-workout bonus caps at 24 deliberately, and
# day-specific-shutdown-check.sh catches it via the morning window. Rejecting
# 24 would break that path; the old 0-23 error message was what was wrong.
_t_allows "accepts 24 (midnight), which the bonus path relies on" \
validate_hours 24 24 5
_t_blocks "still rejects 25" validate_hours 25 21 5
echo "--restore ceiling"
_t_eq "23" "$(clamp_restore 24 Mon-Wed)" "clamps 24 down to the ceiling"
_t_eq "23" "$(clamp_restore 23 Mon-Wed)" "leaves the ceiling itself alone"
_t_eq "21" "$(clamp_restore 21 Mon-Wed)" "leaves an earlier hour alone"
out="$(clamp_restore 24 Mon-Wed 2>&1 >/dev/null)"
_t_has "$out" "clamped" "says so rather than clamping silently"
write_config 21 21 5
if (main --restore 24 24 5 >/dev/null 2>&1); then
_t_pass "a restore above the ceiling still succeeds"
else
_t_fail "a restore above the ceiling still succeeds"
fi
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_HOUR=23" \
"the written value is the ceiling, not 24"
_t_has "$(cat "$SHUTDOWN_RESTORE_LOG")" "RESTORE" "the restore was recorded"
# The overrides CONF is parsed by day-specific-shutdown-check.sh as
# start|end|created|reason, and a matching line makes it exit 0 and skip the
# shutdown. Audit lines must never go there.
_t_eq "0" "$(grep -c . "$TMP/shutdown-schedule-overrides.conf" 2>/dev/null || echo 0)" \
"nothing was written to the overrides conf"
echo
printf 'passed: %d, failed: %d\n' "$PASS" "$FAIL"
[[ $FAIL -eq 0 ]]
_t_summary

View File

@ -0,0 +1,89 @@
#!/usr/bin/env bash
# Tests for what adjust_shutdown_schedule.sh WRITES: HH:MM and legacy bare-hour
# arguments, the dual-key config (authoritative *_MINUTES plus strict-rounded
# *_HOUR: evening floor, morning ceil), and the --restore ceiling. The parsing
# and stricter-only checks are in test_adjust_shutdown_schedule.sh.
set -uo pipefail
# shellcheck source=_adjust_harness.sh
source "$(dirname "${BASH_SOURCE[0]}")/_adjust_harness.sh"
echo "end to end: HH:MM arguments, dual-key output"
write_config 1380 1380 300
_t_allows "a stricter HH:MM write succeeds" run_main 21:00 21:00 05:00
body="$(cat "$SHUTDOWN_CONFIG_FILE")"
_t_has "$body" "MON_WED_MINUTES=1260" "authoritative minutes key written"
_t_has "$body" "MON_WED_HOUR=21" "legacy hour key written alongside it"
_t_has "$(cat "$FAKE_CANONICAL")" "MON_WED_MINUTES=1260" "canonical copy written too"
write_config 1380 1380 300
_t_blocks "a looser write is refused" run_main 24:00 24:00 05:00
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_MINUTES=1380" \
"a refused write leaves the config untouched"
echo "end to end: strict rounding of the legacy hour keys"
write_config 1380 1380 300
run_main 18:30 18:59 05:30 >/dev/null 2>&1
body="$(cat "$SHUTDOWN_CONFIG_FILE")"
_t_has "$body" "MON_WED_MINUTES=1110" "18:30 stored exactly"
_t_has "$body" "MON_WED_HOUR=18" "an evening time rounds DOWN (18:30 -> 18)"
_t_has "$body" "THU_SUN_HOUR=18" "18:59 rounds DOWN to 18"
_t_has "$body" "MORNING_END_MINUTES=330" "05:30 stored exactly"
_t_has "$body" "MORNING_END_HOUR=6" "the morning end rounds UP (05:30 -> 6)"
write_config 1380 1380 300
run_main 21:00 21:00 05:00 >/dev/null 2>&1
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MORNING_END_HOUR=5" \
"a whole-hour morning end is unchanged by the ceiling"
echo "end to end: legacy bare-hour arguments"
write_config 1380 1380 300
run_main 21 20 5 >/dev/null 2>&1
body="$(cat "$SHUTDOWN_CONFIG_FILE")"
_t_has "$body" "MON_WED_MINUTES=1260" "bare 21 is stored as 1260 minutes"
_t_has "$body" "THU_SUN_MINUTES=1200" "bare 20 is stored as 1200 minutes"
_t_has "$body" "MORNING_END_MINUTES=300" "bare 5 is stored as 300 minutes"
echo "end to end: a legacy-only config is the baseline for the next write"
write_legacy_config 21 21 5
_t_blocks "a looser write against a legacy file is refused" \
run_main 22:00 21:00 05:00
_t_allows "a stricter write against a legacy file succeeds" \
run_main 20:30 20:30 05:00
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_MINUTES=1230" \
"the legacy file is upgraded to minutes keys"
# --restore bypasses the stricter-only check on purpose: the workout reward
# pushes the schedule later than the base, so bounding it there would break a
# daily flow. The ceiling below is what bounds it.
write_config 1260 1260 300
_t_allows "--restore still loosens (deliberately unchanged)" \
run_main --restore 23:00 23:00 05:00
echo "--restore ceiling (1380 = 23:00)"
_t_eq "1380" "$(clamp_restore 1440 Mon-Wed)" "clamps 24:00 down to the ceiling"
_t_eq "1380" "$(clamp_restore 1381 Mon-Wed)" "clamps even one minute past 23:00"
_t_eq "1380" "$(clamp_restore 1380 Mon-Wed)" "leaves the ceiling itself alone"
_t_eq "1110" "$(clamp_restore 1110 Mon-Wed)" "leaves an earlier time alone"
out="$(clamp_restore 1440 Mon-Wed 2>&1 >/dev/null)"
_t_has "$out" "clamped" "says so rather than clamping silently"
write_config 1260 1260 300
_t_allows "a restore above the ceiling still succeeds" \
run_main --restore 24:00 24:00 05:00
body="$(cat "$SHUTDOWN_CONFIG_FILE")"
_t_has "$body" "MON_WED_MINUTES=1380" "the written value is the ceiling, not 1440"
_t_has "$body" "MON_WED_HOUR=23" "and its legacy hour copy is 23"
_t_has "$(cat "$SHUTDOWN_RESTORE_LOG")" "RESTORE" "the restore was recorded"
_t_has "$(cat "$SHUTDOWN_RESTORE_LOG")" "mon_wed=23:00" "in HH:MM"
write_config 1260 1260 300
run_main --restore 22:30 22:30 05:00 >/dev/null 2>&1
_t_has "$(cat "$SHUTDOWN_CONFIG_FILE")" "MON_WED_MINUTES=1350" \
"a restore to 22:30 keeps its half hour"
# The overrides CONF is parsed by day-specific-shutdown-check.sh as
# start|end|created|reason, and a matching line makes it exit 0 and skip the
# shutdown. Audit lines must never go there.
_t_eq "0" "$(grep -c . "$TMP/shutdown-schedule-overrides.conf" 2>/dev/null || echo 0)" \
"nothing was written to the overrides conf"
_t_summary

View File

@ -4,7 +4,7 @@ Which rows count -- forged rows, yesterday's solve, an unparsable stamp -- is
``earned_time``'s rule and is tested there. Pinned here is screen-locker's
side of it: the ledger lives under ``LEDGER_HOME``, the key is
``HMAC_KEY_FILE``, an unreadable one fails closed to ``None`` and is logged,
and the schedule's hours are whole.
and the schedule's times are shown exactly (``hhmm`` / ``span``).
"""
from __future__ import annotations
@ -22,8 +22,9 @@ from screen_locker._earned import (
earned_today,
flat_answers,
flat_earners,
hhmm,
ledger_file,
to_hours,
span,
)
from screen_locker.tests._earned_fixtures import (
EXTRA,
@ -48,15 +49,33 @@ def key(tmp_path: Path) -> Iterator[Path]:
yield path
class TestToHours:
def test_whole_hours_convert(self) -> None:
assert to_hours(0) == 0
assert to_hours(120) == 2
class TestHhmm:
def test_whole_hours_are_zero_padded(self) -> None:
assert hhmm(0) == "00:00"
assert hhmm(9 * 60) == "09:00"
assert hhmm(21 * 60) == "21:00"
def test_a_remainder_raises_instead_of_flooring(self) -> None:
"""A 30-minute earner must not silently become 0 hours here."""
with pytest.raises(ValueError, match="not a whole hour"):
to_hours(90)
def test_minutes_are_kept_exactly(self) -> None:
"""A 30-minute earner shows as :30, never floored to the hour."""
assert hhmm(18 * 60 + 30) == "18:30"
assert hhmm(5) == "00:05"
def test_midnight_cap_is_24_00(self) -> None:
assert hhmm(24 * 60) == "24:00"
class TestSpan:
def test_whole_hours(self) -> None:
assert span(0) == "0h"
assert span(60) == "1h"
assert span(120) == "2h"
def test_minutes_only(self) -> None:
assert span(30) == "30m"
def test_hours_and_minutes_combine(self) -> None:
assert span(90) == "1h30m"
assert span(125) == "2h05m"
class TestLedgerFile:

View File

@ -166,7 +166,7 @@ class TestAdjustShutdownTimeLater:
"""Test _adjust_shutdown_time_later adds hours successfully."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 22, 8))
locker, "_read_shutdown_config", MagicMock(return_value=(1260, 1320, 480))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
@ -175,7 +175,9 @@ class TestAdjustShutdownTimeLater:
result = locker._adjust_shutdown_time_later()
assert result is True
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
locker._write_shutdown_config.assert_called_once_with(
1380, 1380, 480, restore=True
)
def test_adjust_shutdown_time_later_caps_at_23(
self,
@ -186,7 +188,7 @@ class TestAdjustShutdownTimeLater:
"""Test _adjust_shutdown_time_later caps hours at 23."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(22, 23, 8))
locker, "_read_shutdown_config", MagicMock(return_value=(1320, 1380, 480))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
@ -196,7 +198,9 @@ class TestAdjustShutdownTimeLater:
assert result is True
# 22+2=24 capped to 23, 23+2=25 capped to 23
locker._write_shutdown_config.assert_called_once_with(23, 23, 8, restore=True)
locker._write_shutdown_config.assert_called_once_with(
1380, 1380, 480, restore=True
)
def test_adjust_shutdown_time_later_no_config(
self,

View File

@ -111,7 +111,7 @@ class TestApplyWorkoutCredit:
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
MagicMock(side_effect=[(1260, 1260, 300), (1320, 1320, 300)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
@ -126,9 +126,9 @@ class TestApplyWorkoutCredit:
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
assert result.extra_bonus_delta == 60
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
locker._adjust_shutdown_time_by.assert_called_once_with(60)
def test_additional_verified_workout_earns_plus_one_hour(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
@ -137,7 +137,7 @@ class TestApplyWorkoutCredit:
prior_entries already holds a counted workout, so this isn't the first
of the day; because the new type is verified it takes the +1h branch,
and extra_bonus_delta reflects the shutdown-config hour delta.
and extra_bonus_delta reflects the shutdown-config minute delta.
"""
locker = create_locker(mock_tk, tmp_path)
locker.workout_data = {"type": "phone_verified"}
@ -153,7 +153,7 @@ class TestApplyWorkoutCredit:
object.__setattr__(
locker,
"_read_shutdown_config",
MagicMock(side_effect=[(21, 21, 5), (22, 22, 5)]),
MagicMock(side_effect=[(1260, 1260, 300), (1320, 1320, 300)]),
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)
@ -168,6 +168,6 @@ class TestApplyWorkoutCredit:
assert result.weekly_count == 5
assert result.shutdown_adjusted is False
assert result.extra_bonus_delta == 1
assert result.extra_bonus_delta == 60
locker._try_adjust_shutdown_for_workout.assert_not_called()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
locker._adjust_shutdown_time_by.assert_called_once_with(60)

View File

@ -79,7 +79,7 @@ class TestApplyWorkoutCredit:
locker, "_try_adjust_shutdown_for_workout", MagicMock(return_value=True)
)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
locker, "_read_shutdown_config", MagicMock(return_value=(1260, 1260, 300))
)
object.__setattr__(
locker, "_adjust_shutdown_time_by", MagicMock(return_value=True)

View File

@ -106,7 +106,7 @@ class TestOnManualWorkoutSaved:
credit = WorkoutCreditResult(
shutdown_adjusted=False,
new_debt=1,
extra_bonus_delta=1,
extra_bonus_delta=60,
weekly_count=5,
already_counted_today=False,
)

View File

@ -62,12 +62,12 @@ def _snapshot() -> StatusSnapshot:
streak=1,
early_bird_extended=False,
shutdown=ShutdownProjection(
tonight=(22, 22, 5),
tonight=(1320, 1320, 300),
rest_of_week=(
ShutdownProjectionDay(label="Mon", hour=21, speculative=False),
ShutdownProjectionDay(label="Mon", minutes=1260, speculative=False),
),
next_week_preview=(
ShutdownProjectionDay(label="Mon", hour=21, speculative=True),
ShutdownProjectionDay(label="Mon", minutes=1260, speculative=True),
),
explanation="explanation text",
),

View File

@ -130,7 +130,7 @@ class TestSyncPassCreditsRunnerUp:
"2026-09-16", log, on_ingested
),
)
config = {"hours": (20, 20, 5)}
config = {"hours": (1200, 1200, 300)}
def write(mw: int, ts: int, me: int, restore: bool = False) -> bool:
config["hours"] = (mw, ts, me)
@ -153,5 +153,5 @@ class TestSyncPassCreditsRunnerUp:
locker._auto_fill_week_runnerup_bonus()
locker._auto_fill_week_runnerup_bonus()
assert config["hours"] == (22, 22, 5)
assert config["hours"] == (1320, 1320, 300)
assert locker.workout_data == {}

View File

@ -124,7 +124,7 @@ class TestCheckNonVerifyExitsExtras:
patch("screen_locker.screen_lock.sys.exit"),
):
locker._check_non_verify_exits()
locker._adjust_shutdown_time_by.assert_called_once_with(2)
locker._adjust_shutdown_time_by.assert_called_once_with(120)
def test_no_weekly_bonus_applied_when_not_a_fresh_day(
self,

View File

@ -38,7 +38,7 @@ class TestUnlockScreenExtras:
):
locker.unlock_screen()
locker._adjust_shutdown_time_by.assert_called_once_with(1)
locker._adjust_shutdown_time_by.assert_called_once_with(60)
def test_extra_bonus_delta_displayed(
self,
@ -49,9 +49,9 @@ class TestUnlockScreenExtras:
"""extra_bonus_delta > 0 → _text called with 'Extra workout'."""
locker = setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
# Simulate before=22, after=23 → delta=1
old_cfg = (22, 22, 5)
new_cfg = (23, 23, 5)
# Simulate before=22:00, after=23:00 → delta=60
old_cfg = (1320, 1320, 300)
new_cfg = (1380, 1380, 300)
locker._read_shutdown_config.side_effect = [old_cfg, new_cfg]
text_calls: list[str] = []
@ -114,7 +114,7 @@ class TestUnlockScreenExtras:
"""Re-reading the shutdown config fails after the +1h → delta stays 0."""
locker = setup_unlock(mock_tk, tmp_path, seed_today_type="manual_workout")
# old_cfg readable, new_cfg unreadable → no delta can be computed.
locker._read_shutdown_config.side_effect = [(22, 22, 5), None]
locker._read_shutdown_config.side_effect = [(1320, 1320, 300), None]
text_calls: list[str] = []
object.__setattr__(locker, "_text", lambda msg, **kw: text_calls.append(msg))

View File

@ -84,7 +84,7 @@ class TestUnlockScreenRewards:
"""new_cfg is None → branch 363->366: delta stays 0 even after adjust."""
locker = setup_unlock(mock_tk, tmp_path)
# First call (old_cfg): valid; second call (new_cfg after adjust): None
locker._read_shutdown_config.side_effect = [(22, 22, 5), None]
locker._read_shutdown_config.side_effect = [(1320, 1320, 300), None]
with (
patch(

View File

@ -108,7 +108,7 @@ class TestIngestSyncedManualWorkouts:
with patch.object(
locker,
"_apply_credit_for_written_entry",
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=1),
return_value=MagicMock(shutdown_adjusted=False, extra_bonus_delta=60),
) as apply_credit:
locker._credit_ingested_workout(entry, prior)
assert locker.workout_data == entry

View File

@ -9,7 +9,7 @@ from unittest.mock import MagicMock
from screen_locker._shutdown_base import (
_load_state,
base_hour,
base_minutes,
reset_to_base_if_new_day,
)
@ -56,11 +56,11 @@ class TestLoadState:
)
)
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (21, 21, 5)
mixin._read_shutdown_config.return_value = (21 * 60, 21 * 60, 300)
mixin._write_shutdown_config.return_value = True
assert reset_to_base_if_new_day(f, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour(), base_hour(), 5, restore=True
base_minutes(), base_minutes(), 300, restore=True
)
assert "base_mon_wed_hour" not in json.loads(f.read_text())
@ -71,7 +71,7 @@ class TestResetToBaseIfNewDay:
def _make_mixin(self, write_ok: bool = True) -> MagicMock:
"""Build a minimal mixin mock."""
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (21, 21, 5)
mixin._read_shutdown_config.return_value = (21 * 60, 21 * 60, 300)
mixin._write_shutdown_config.return_value = write_ok
return mixin
@ -88,9 +88,9 @@ class TestResetToBaseIfNewDay:
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
mixin = self._make_mixin()
assert reset_to_base_if_new_day(f, mixin) is True
base = base_hour()
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base, base, 5, restore=True
base, base, 300, restore=True
)
def test_resets_when_no_state_file(self, tmp_path: Path) -> None:
@ -108,16 +108,16 @@ class TestResetToBaseIfNewDay:
assert reset_to_base_if_new_day(f, mixin) is False
def test_uses_default_morning_end_when_config_is_none(self, tmp_path: Path) -> None:
"""_read_shutdown_config returns None → morning_end defaults to 5 (line 71 else)."""
"""_read_shutdown_config returns None → morning_end defaults to 05:00 (300 minutes)."""
f = tmp_path / "state.json"
f.write_text(json.dumps({"last_reset_date": "2000-01-01"}))
mixin = MagicMock()
mixin._read_shutdown_config.return_value = None
mixin._write_shutdown_config.return_value = True
reset_to_base_if_new_day(f, mixin)
base = base_hour()
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base, base, 5, restore=True
base, base, 300, restore=True
)
def test_clears_sick_day_state_file_on_reset(self, tmp_path: Path) -> None:

View File

@ -15,7 +15,7 @@ from unittest.mock import MagicMock
from screen_locker._day import today_str
from screen_locker._shutdown_base import (
base_hour,
base_minutes,
reset_to_base_if_new_day,
today_credit_count,
)
@ -24,7 +24,7 @@ from screen_locker._weekly_check import (
credit_key,
day_workout_index,
)
from screen_locker._workout_credit import earned_shutdown_bonus_hours
from screen_locker._workout_credit import earned_shutdown_bonus_minutes
if TYPE_CHECKING:
from pathlib import Path
@ -57,20 +57,20 @@ def _write_log(log_file: Path, day: str, entries: list[dict[str, Any]]) -> Path:
return log_file
class TestEarnedShutdownBonusHours:
class TestEarnedShutdownBonusMinutes:
"""First credit is worth 2h, every further one 1h."""
def test_no_credits_earn_nothing(self) -> None:
assert earned_shutdown_bonus_hours(0) == 0
assert earned_shutdown_bonus_minutes(0) == 0
def test_negative_is_treated_as_nothing(self) -> None:
assert earned_shutdown_bonus_hours(-1) == 0
assert earned_shutdown_bonus_minutes(-1) == 0
def test_first_credit_is_two_hours(self) -> None:
assert earned_shutdown_bonus_hours(1) == 2
assert earned_shutdown_bonus_minutes(1) == 120
def test_further_credits_add_one_hour_each(self) -> None:
assert earned_shutdown_bonus_hours(3) == 4
assert earned_shutdown_bonus_minutes(3) == 240
class TestSyncedCopySharesCredit:
@ -130,7 +130,7 @@ class TestTodayCreditCount:
def test_real_day_counts_one_workout_worth_two_hours(self, tmp_path: Path) -> None:
log = _write_log(tmp_path / "log.json", _DAY, _real_2026_09_13())
assert today_credit_count(log, _DAY) == 1
assert earned_shutdown_bonus_hours(today_credit_count(log, _DAY)) == 2
assert earned_shutdown_bonus_minutes(today_credit_count(log, _DAY)) == 120
def test_day_absent_from_log_counts_nothing(self, tmp_path: Path) -> None:
log = _write_log(tmp_path / "log.json", "2026-09-12", _real_2026_09_13())
@ -149,7 +149,7 @@ class TestResetReplaysTodaysCredit:
def _mixin(self) -> MagicMock:
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (20, 20, 5)
mixin._read_shutdown_config.return_value = (20, 20, 300)
mixin._write_shutdown_config.return_value = True
return mixin
@ -166,15 +166,15 @@ class TestResetReplaysTodaysCredit:
reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True
)
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 2, base_hour() + 2, 5, restore=True
base_minutes() + 120, base_minutes() + 120, 300, 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
base = base_hour()
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base, base, 5, restore=True
base, base, 300, restore=True
)
def test_empty_day_writes_plain_base(self, tmp_path: Path) -> None:
@ -183,9 +183,9 @@ class TestResetReplaysTodaysCredit:
assert (
reset_to_base_if_new_day(self._state(tmp_path), mixin, log_file=log) is True
)
base = base_hour()
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base, base, 5, restore=True
base, base, 300, restore=True
)
def test_earned_hours_are_capped_at_the_restore_ceiling(
@ -201,7 +201,9 @@ class TestResetReplaysTodaysCredit:
log = _write_log(tmp_path / "log.json", today_str(), entries)
mixin = self._mixin()
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)
mixin._write_shutdown_config.assert_called_once_with(
1380, 1380, 300, restore=True
)
def test_state_file_records_only_the_date(self, tmp_path: Path) -> None:
"""Tomorrow's reset must start from the constant, not from today's total."""

View File

@ -19,7 +19,7 @@ import pytest
from screen_locker._day import today_str
from screen_locker._shutdown_base import (
apply_flat_bonuses_if_new,
base_hour,
base_minutes,
reset_to_base_if_new_day,
)
from screen_locker._status_data import gather_status
@ -39,7 +39,7 @@ if TYPE_CHECKING:
def _mixin() -> MagicMock:
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (base_hour(), base_hour(), 5)
mixin._read_shutdown_config.return_value = (base_minutes(), base_minutes(), 300)
mixin._write_shutdown_config.return_value = True
mixin._adjust_shutdown_time_by.return_value = True
return mixin
@ -84,7 +84,7 @@ class TestFlatBonuses:
state = tmp_path / "state.json"
mixin = _mixin()
apply_flat_bonuses_if_new(state, mixin)
mixin._adjust_shutdown_time_by.assert_called_once_with(1)
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
assert json.loads(state.read_text()) == {"reading_bonus_date": today_str()}
@ -98,7 +98,7 @@ class TestANewlyRegisteredEarner:
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 1, base_hour() + 1, 5, restore=True
base_minutes() + 60, base_minutes() + 60, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
@ -113,7 +113,7 @@ class TestANewlyRegisteredEarner:
mixin = _mixin()
apply_flat_bonuses_if_new(state, mixin)
apply_flat_bonuses_if_new(state, mixin)
mixin._adjust_shutdown_time_by.assert_called_once_with(1)
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
assert json.loads(state.read_text()) == {"extra_bonus_date": today_str()}
def test_its_ledger_is_read_like_any_other(self, tmp_path: Path) -> None:
@ -121,7 +121,7 @@ class TestANewlyRegisteredEarner:
mixin = _mixin()
with signing_key(tmp_path):
apply_flat_bonuses_if_new(tmp_path / "state.json", mixin)
mixin._adjust_shutdown_time_by.assert_called_once_with(1)
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
def test_a_penalised_earner_lowers_the_base_from_its_day(
self, monkeypatch: pytest.MonkeyPatch
@ -135,9 +135,9 @@ class TestANewlyRegisteredEarner:
shutdown_minutes=60,
penalty_from=cut,
)
before = (base_hour(eve), base_hour(cut))
before = (base_minutes(eve), base_minutes(cut))
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, penalised))
assert (base_hour(eve), base_hour(cut)) == (before[0], before[1] - 1)
assert (base_minutes(eve), base_minutes(cut)) == (before[0], before[1] - 60)
class TestRealLedgers:
@ -150,7 +150,7 @@ class TestRealLedgers:
with signing_key(tmp_path):
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 2, base_hour() + 2, 5, restore=True
base_minutes() + 120, base_minutes() + 120, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
@ -163,8 +163,8 @@ class TestProjectionFollowsTheCut:
@pytest.mark.parametrize(
("now", "expected"),
[
(datetime(2026, 9, 30, 12, 0, tzinfo=UTC), 20),
(datetime(2026, 10, 2, 12, 0, tzinfo=UTC), 19),
(datetime(2026, 9, 30, 12, 0, tzinfo=UTC), 20 * 60),
(datetime(2026, 10, 2, 12, 0, tzinfo=UTC), 19 * 60),
],
)
def test_rest_of_week_uses_the_base_for_that_day(
@ -174,5 +174,5 @@ class TestProjectionFollowsTheCut:
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**_files(tmp_path), now=now)
assert snap.shutdown.rest_of_week[0].hour == expected
assert snap.shutdown.next_week_preview[0].hour == expected
assert snap.shutdown.rest_of_week[0].minutes == expected
assert snap.shutdown.next_week_preview[0].minutes == expected

View File

@ -17,7 +17,7 @@ import pytest
from screen_locker._day import today_str
from screen_locker._shutdown_base import (
_apply_flat_bonus,
base_hour,
base_minutes,
reset_to_base_if_new_day,
)
from screen_locker.tests._earned_fixtures import answering
@ -29,7 +29,7 @@ if TYPE_CHECKING:
def _mixin(*, adjust_ok: bool = True) -> MagicMock:
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (base_hour(), base_hour(), 5)
mixin._read_shutdown_config.return_value = (base_minutes(), base_minutes(), 300)
mixin._write_shutdown_config.return_value = True
mixin._adjust_shutdown_time_by.return_value = adjust_ok
return mixin
@ -61,7 +61,7 @@ class TestResetIncludesTheHour:
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 1, base_hour() + 1, 5, restore=True
base_minutes() + 60, base_minutes() + 60, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
@ -93,7 +93,7 @@ class TestLivePass:
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)
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
"leetcode_bonus_date": today_str(),
@ -128,7 +128,7 @@ class TestLivePass:
assert apply_leetcode_bonus_if_new(state, mixin) is True
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 1, base_hour() + 1, 5, restore=True
base_minutes() + 60, base_minutes() + 60, 300, restore=True
)
assert apply_leetcode_bonus_if_new(state, mixin) is False
mixin._adjust_shutdown_time_by.assert_called_once()

View File

@ -0,0 +1,190 @@
"""Minute-precision shutdown schedule: legacy keys and the real command line.
The schedule moved from whole hours to minutes after midnight. Pinned here:
``read_shutdown_config`` still understands a file with only the old ``*_HOUR``
keys, the sick-day state still loads its old ``original_*_hour`` keys, and a
value that is not a whole hour (a 30-minute earner) reaches the helper script
as ``HH:MM`` rather than being floored.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._shutdown import read_shutdown_config
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
class TestReadShutdownConfigKeys:
"""``*_MINUTES`` is authoritative; ``*_HOUR`` is only a fallback."""
def _read(self, tmp_path: Path, body: str) -> tuple[int, int, int] | None:
conf = tmp_path / "shutdown.conf"
conf.write_text(body)
return read_shutdown_config(conf)
def test_minutes_keys_are_read_exactly(self, tmp_path: Path) -> None:
body = "MON_WED_MINUTES=1110\nTHU_SUN_MINUTES=1230\nMORNING_END_MINUTES=330\n"
assert self._read(tmp_path, body) == (1110, 1230, 330)
def test_legacy_hour_only_file_reads_as_hours_times_sixty(
self, tmp_path: Path
) -> None:
body = "MON_WED_HOUR=21\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=5\n"
assert self._read(tmp_path, body) == (1260, 1200, 300)
def test_minutes_win_over_the_rounded_hour_copy(self, tmp_path: Path) -> None:
"""A dual-key file's *_HOUR is strict-rounded: 18:30 -> 18, 05:30 -> 6."""
body = (
"MON_WED_MINUTES=1110\nTHU_SUN_MINUTES=1110\nMORNING_END_MINUTES=330\n"
"MON_WED_HOUR=18\nTHU_SUN_HOUR=18\nMORNING_END_HOUR=6\n"
)
assert self._read(tmp_path, body) == (1110, 1110, 330)
def test_keys_resolve_independently(self, tmp_path: Path) -> None:
"""One key in minutes, the others only in hours: each falls back alone."""
body = "MON_WED_MINUTES=1110\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=5\n"
assert self._read(tmp_path, body) == (1110, 1200, 300)
def test_a_missing_value_is_none(self, tmp_path: Path) -> None:
body = "MON_WED_MINUTES=1110\nTHU_SUN_MINUTES=1110\n"
assert self._read(tmp_path, body) is None
def test_non_numeric_values_and_comments_are_ignored(self, tmp_path: Path) -> None:
body = (
"# MON_WED_MINUTES=1\nMON_WED_MINUTES=oops\nMON_WED_HOUR=21\n"
"THU_SUN_HOUR=20\nMORNING_END_HOUR=5\nno-equals-sign\n"
)
assert self._read(tmp_path, body) == (1260, 1200, 300)
class TestLegacySickDayState:
"""A state file written before the migration still restores correctly."""
def _load(
self, tmp_path: Path, state: dict[str, object]
) -> tuple[str, int, int] | None:
locker = create_locker(MagicMock(), tmp_path)
state_file = tmp_path / "sick.json"
state_file.write_text(json.dumps(state))
with patch(
"screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE", state_file
):
return locker._load_sick_day_state()
def test_legacy_hour_keys_are_read_as_minutes(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
state = {"date": "2026-10-03", "original_mon_wed_hour": 21}
state["original_thu_sun_hour"] = 20
assert self._load(tmp_path, state) == ("2026-10-03", 1260, 1200)
def test_minutes_keys_win_over_legacy_keys(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
state = {
"date": "2026-10-03",
"original_mon_wed_minutes": 1290,
"original_mon_wed_hour": 21,
"original_thu_sun_minutes": 1200,
}
assert self._load(tmp_path, state) == ("2026-10-03", 1290, 1200)
def test_a_missing_band_is_none(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
state = {"date": "2026-10-03", "original_mon_wed_hour": 21}
assert self._load(tmp_path, state) is None
class TestMinutesReachTheHelperScript:
"""The helper receives HH:MM, so a half hour is not floored on the way."""
def _run(
self,
mock_tk: MagicMock,
tmp_path: Path,
config: tuple[int, int, int],
action: str,
*args: int,
) -> list[str]:
locker = create_locker(mock_tk, tmp_path)
script = tmp_path / "adjust.sh"
script.write_text("")
done = MagicMock(stdout="ok")
with (
patch.object(locker, "_read_shutdown_config", return_value=config),
patch("screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", script),
patch("screen_locker._shutdown.subprocess.run", return_value=done) as run,
):
assert getattr(locker, action)(*args) is True
cmd: list[str] = run.call_args.args[0]
return cmd[2:]
def test_a_30_minute_bonus_becomes_18_30(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
args = self._run(
mock_tk, tmp_path, (1080, 1080, 300), "_adjust_shutdown_time_by", 30
)
assert args == ["--restore", "18:30", "18:30", "05:00"]
def test_the_bonus_is_capped_at_24_00(
self, mock_tk: MagicMock, mock_sys_exit: MagicMock, tmp_path: Path
) -> None:
args = self._run(
mock_tk, tmp_path, (1380, 1380, 300), "_adjust_shutdown_time_by", 90
)
assert args == ["--restore", "24:00", "24:00", "05:00"]
@pytest.mark.parametrize(
("config", "expected"),
[
((1290, 1260, 300), ["--restore", "23:00", "23:00", "05:00"]),
((1080, 1080, 300), ["--restore", "20:00", "20:00", "05:00"]),
],
)
def test_workout_reward_is_clamped_to_23_00(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
config: tuple[int, int, int],
expected: list[str],
) -> None:
args = self._run(mock_tk, tmp_path, config, "_adjust_shutdown_time_later")
assert args == expected
@pytest.mark.parametrize(
("config", "expected"),
[
((1290, 1290, 300), ["20:30", "20:30", "05:00"]),
((1100, 1085, 300), ["18:00", "18:00", "05:00"]),
],
)
def test_sick_day_steps_back_an_hour_but_not_before_18_00(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
config: tuple[int, int, int],
expected: list[str],
) -> None:
locker = create_locker(mock_tk, tmp_path)
script = tmp_path / "adjust.sh"
script.write_text("")
with (
patch.object(locker, "_read_shutdown_config", return_value=config),
patch.object(locker, "_save_sick_day_state", return_value=True),
patch("screen_locker._shutdown.ADJUST_SHUTDOWN_SCRIPT", script),
patch("screen_locker._shutdown.subprocess.run") as run,
):
assert locker._apply_earlier_shutdown("2026-10-04") is True
assert run.call_args.args[0][2:] == expected

View File

@ -34,7 +34,9 @@ class TestApplyEarlierShutdown:
"""Test returns False when saving state fails."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)),
patch.object(
locker, "_read_shutdown_config", return_value=(1260, 1200, 480)
),
patch.object(locker, "_save_sick_day_state", return_value=False),
):
assert locker._apply_earlier_shutdown("2026-03-21") is False
@ -48,7 +50,9 @@ class TestApplyEarlierShutdown:
"""Test successful application of earlier shutdown hours."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(21, 20, 8)),
patch.object(
locker, "_read_shutdown_config", return_value=(1260, 1200, 480)
),
patch.object(locker, "_save_sick_day_state", return_value=True),
patch.object(
locker, "_write_shutdown_config", return_value=True
@ -56,7 +60,7 @@ class TestApplyEarlierShutdown:
):
result = locker._apply_earlier_shutdown("2026-03-21")
assert result is True
mock_write.assert_called_once_with(20, 19, 8)
mock_write.assert_called_once_with(1200, 1140, 480)
def test_clamps_to_minimum_18(
self,
@ -67,14 +71,16 @@ class TestApplyEarlierShutdown:
"""Test hours are clamped to minimum of 18."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(18, 18, 8)),
patch.object(
locker, "_read_shutdown_config", return_value=(1080, 1080, 480)
),
patch.object(locker, "_save_sick_day_state", return_value=True),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
locker._apply_earlier_shutdown("2026-03-21")
mock_write.assert_called_once_with(18, 18, 8)
mock_write.assert_called_once_with(1080, 1080, 480)
class TestAdjustShutdownTimeEarlier:
@ -171,14 +177,16 @@ class TestAdjustShutdownTimeLater:
"""Test successful later adjustment with restore flag."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)),
patch.object(
locker, "_read_shutdown_config", return_value=(1200, 1140, 480)
),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
result = locker._adjust_shutdown_time_later()
assert result is True
mock_write.assert_called_once_with(22, 21, 8, restore=True)
mock_write.assert_called_once_with(1320, 1260, 480, restore=True)
def test_clamps_to_max_23(
self,
@ -189,13 +197,15 @@ class TestAdjustShutdownTimeLater:
"""Test hours are clamped to maximum of 23."""
locker = create_locker(mock_tk, tmp_path)
with (
patch.object(locker, "_read_shutdown_config", return_value=(22, 23, 8)),
patch.object(
locker, "_read_shutdown_config", return_value=(1320, 1380, 480)
),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
):
locker._adjust_shutdown_time_later()
mock_write.assert_called_once_with(23, 23, 8, restore=True)
mock_write.assert_called_once_with(1380, 1380, 480, restore=True)
def test_handles_oserror(
self,

View File

@ -46,8 +46,8 @@ class TestRestoreOriginalConfigIfNeeded:
json.dumps(
{
"date": "2020-01-01",
"original_mon_wed_hour": 21,
"original_thu_sun_hour": 20,
"original_mon_wed_minutes": 1260,
"original_thu_sun_minutes": 1200,
}
)
)
@ -59,7 +59,7 @@ class TestRestoreOriginalConfigIfNeeded:
patch.object(locker, "_write_restored_config") as mock_restore,
):
locker._restore_original_config_if_needed()
mock_restore.assert_called_once_with(21, 20, "2020-01-01")
mock_restore.assert_called_once_with(1260, 1200, "2020-01-01")
def test_does_not_restore_when_state_from_today(
self,
@ -77,8 +77,8 @@ class TestRestoreOriginalConfigIfNeeded:
json.dumps(
{
"date": today,
"original_mon_wed_hour": 21,
"original_thu_sun_hour": 20,
"original_mon_wed_minutes": 1260,
"original_thu_sun_minutes": 1200,
}
)
)
@ -174,13 +174,15 @@ class TestReadShutdownConfig:
"""Test reads all three config values from file."""
locker = create_locker(mock_tk, tmp_path)
config_file = tmp_path / "shutdown.conf"
config_file.write_text("MON_WED_HOUR=21\nTHU_SUN_HOUR=20\nMORNING_END_HOUR=8\n")
config_file.write_text(
"MON_WED_MINUTES=1260\nTHU_SUN_MINUTES=1200\nMORNING_END_MINUTES=480\n"
)
with patch(
"screen_locker._shutdown.SHUTDOWN_CONFIG_FILE",
config_file,
):
result = locker._read_shutdown_config()
assert result == (21, 20, 8)
assert result == (1260, 1200, 480)
def test_returns_none_when_values_missing(
self,
@ -211,13 +213,13 @@ class TestBuildShutdownCmd:
) -> None:
"""Test command without restore flag."""
locker = create_locker(mock_tk, tmp_path)
cmd = locker._build_shutdown_cmd(21, 20, 8, restore=False)
cmd = locker._build_shutdown_cmd(1260, 1200, 480, restore=False)
assert cmd == [
"/usr/bin/sudo",
str(ADJUST_SHUTDOWN_SCRIPT),
"21",
"20",
"8",
"21:00",
"20:00",
"08:00",
]
def test_with_restore(
@ -228,12 +230,12 @@ class TestBuildShutdownCmd:
) -> None:
"""Test command with restore flag."""
locker = create_locker(mock_tk, tmp_path)
cmd = locker._build_shutdown_cmd(21, 20, 8, restore=True)
cmd = locker._build_shutdown_cmd(1260, 1200, 480, restore=True)
assert cmd == [
"/usr/bin/sudo",
str(ADJUST_SHUTDOWN_SCRIPT),
"--restore",
"21",
"20",
"8",
"21:00",
"20:00",
"08:00",
]

View File

@ -28,12 +28,12 @@ class TestSaveSickDayState:
"screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE",
state_file,
):
result = locker._save_sick_day_state("2026-03-21", 21, 20)
result = locker._save_sick_day_state("2026-03-21", 1260, 1200)
assert result is True
data = json.loads(state_file.read_text())
assert data["date"] == "2026-03-21"
assert data["original_mon_wed_hour"] == 21
assert data["original_thu_sun_hour"] == 20
assert data["original_mon_wed_minutes"] == 1260
assert data["original_thu_sun_minutes"] == 1200
def test_returns_false_on_oserror(
self,
@ -49,7 +49,7 @@ class TestSaveSickDayState:
"screen_locker._shutdown_sick_state.SICK_DAY_STATE_FILE",
mock_path,
):
result = locker._save_sick_day_state("2026-03-21", 21, 20)
result = locker._save_sick_day_state("2026-03-21", 1260, 1200)
assert result is False
@ -69,8 +69,8 @@ class TestLoadSickDayState:
json.dumps(
{
"date": "2026-03-20",
"original_mon_wed_hour": 21,
"original_thu_sun_hour": 20,
"original_mon_wed_minutes": 1260,
"original_thu_sun_minutes": 1200,
}
)
)
@ -79,7 +79,7 @@ class TestLoadSickDayState:
state_file,
):
result = locker._load_sick_day_state()
assert result == ("2026-03-20", 21, 20)
assert result == ("2026-03-20", 1260, 1200)
def test_returns_none_when_fields_missing(
self,
@ -113,7 +113,9 @@ class TestWriteRestoredConfig:
state_file = tmp_path / "state.json"
state_file.write_text("{}")
with (
patch.object(locker, "_read_shutdown_config", return_value=(20, 19, 8)),
patch.object(
locker, "_read_shutdown_config", return_value=(1200, 1140, 480)
),
patch.object(
locker, "_write_shutdown_config", return_value=True
) as mock_write,
@ -122,8 +124,8 @@ class TestWriteRestoredConfig:
state_file,
),
):
locker._write_restored_config(21, 20, "2026-03-20")
mock_write.assert_called_once_with(21, 20, 8, restore=True)
locker._write_restored_config(1260, 1200, "2026-03-20")
mock_write.assert_called_once_with(1260, 1200, 480, restore=True)
assert not state_file.exists()
def test_still_removes_state_when_config_read_fails(
@ -143,7 +145,7 @@ class TestWriteRestoredConfig:
state_file,
),
):
locker._write_restored_config(21, 20, "2026-03-20")
locker._write_restored_config(1260, 1200, "2026-03-20")
assert not state_file.exists()
@ -159,30 +161,34 @@ class TestAdjustShutdownTimeBy:
"""Normal path: reads config, increments both hours, writes back."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(21, 21, 5))
locker, "_read_shutdown_config", MagicMock(return_value=(1260, 1260, 300))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
assert locker._adjust_shutdown_time_by(1) is True
locker._write_shutdown_config.assert_called_once_with(22, 22, 5, restore=True)
assert locker._adjust_shutdown_time_by(60) is True
locker._write_shutdown_config.assert_called_once_with(
1320, 1320, 300, restore=True
)
def test_caps_hours_at_24(
def test_caps_at_midnight(
self,
mock_tk: MagicMock,
mock_sys_exit: MagicMock,
tmp_path: Path,
) -> None:
"""Hours are capped at 24 (midnight-safe shutdown)."""
"""Times are capped at 1440 minutes (midnight-safe shutdown)."""
locker = create_locker(mock_tk, tmp_path)
object.__setattr__(
locker, "_read_shutdown_config", MagicMock(return_value=(23, 23, 5))
locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300))
)
object.__setattr__(
locker, "_write_shutdown_config", MagicMock(return_value=True)
)
locker._adjust_shutdown_time_by(2)
locker._write_shutdown_config.assert_called_once_with(24, 24, 5, restore=True)
locker._adjust_shutdown_time_by(120)
locker._write_shutdown_config.assert_called_once_with(
1440, 1440, 300, restore=True
)
def test_returns_false_when_config_is_none(
self,

View File

@ -18,10 +18,11 @@ import pytest
from screen_locker import _shutdown_base
from screen_locker._day import today_str
from screen_locker._earned import hhmm
from screen_locker._shutdown_base import (
_apply_flat_bonus,
apply_flat_bonuses_if_new,
base_hour,
base_minutes,
reset_to_base_if_new_day,
)
from screen_locker.tests._earned_fixtures import answering
@ -33,7 +34,7 @@ if TYPE_CHECKING:
def _mixin(*, adjust_ok: bool = True) -> MagicMock:
mixin = MagicMock()
mixin._read_shutdown_config.return_value = (base_hour(), base_hour(), 5)
mixin._read_shutdown_config.return_value = (base_minutes(), base_minutes(), 300)
mixin._write_shutdown_config.return_value = True
mixin._adjust_shutdown_time_by.return_value = adjust_ok
return mixin
@ -54,17 +55,17 @@ def answers() -> Iterator[tuple[dict[str, bool | None], MagicMock]]:
class TestBaseHour:
def test_day_before_the_cut_keeps_twenty(self) -> None:
assert base_hour(date(2026, 9, 30)) == 20
assert base_minutes(date(2026, 9, 30)) == 20 * 60
def test_cut_day_is_nineteen(self) -> None:
assert earned_time.READING.penalty_from == date(2026, 10, 1)
assert base_hour(date(2026, 10, 1)) == 19
assert base_minutes(date(2026, 10, 1)) == 19 * 60
def test_long_after_the_cut_stays_nineteen(self) -> None:
assert base_hour(date(2030, 1, 1)) == 19
assert base_minutes(date(2030, 1, 1)) == 19 * 60
def test_default_is_the_local_today(self) -> None:
assert base_hour() == base_hour(datetime.now().astimezone().date())
assert base_minutes() == base_minutes(datetime.now().astimezone().date())
class TestResetIncludesReading:
@ -76,7 +77,7 @@ class TestResetIncludesReading:
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 1, base_hour() + 1, 5, restore=True
base_minutes() + 60, base_minutes() + 60, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
@ -92,7 +93,7 @@ class TestResetIncludesReading:
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
mixin._write_shutdown_config.assert_called_once_with(
base_hour() + 2, base_hour() + 2, 5, restore=True
base_minutes() + 120, base_minutes() + 120, 300, restore=True
)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
@ -112,7 +113,9 @@ class TestResetIncludesReading:
reset_to_base_if_new_day(
tmp_path / "state.json", mixin, log_file=tmp_path / "log.json"
)
mixin._write_shutdown_config.assert_called_once_with(23, 23, 5, restore=True)
mixin._write_shutdown_config.assert_called_once_with(
1380, 1380, 300, restore=True
)
def test_unknown_reading_earns_nothing_warns_and_is_not_stamped(
self,
@ -127,9 +130,9 @@ class TestResetIncludesReading:
with caplog.at_level("WARNING"):
assert reset_to_base_if_new_day(state, mixin) is True
assert "reading state could not be checked" in caplog.text
base = base_hour()
base = base_minutes()
mixin._write_shutdown_config.assert_called_once_with(
base, base, 5, restore=True
base, base, 300, restore=True
)
assert json.loads(state.read_text()) == {"last_reset_date": today_str()}
@ -142,9 +145,9 @@ class TestResetIncludesReading:
answers[0]["reading"] = True
with caplog.at_level("INFO"):
reset_to_base_if_new_day(tmp_path / "state.json", _mixin())
base = base_hour()
base = base_minutes()
assert (
f"Daily base reset: {base + 1:02d}:00 (base {base} + 0h workout + "
f"Daily base reset: {hhmm(base + 60)} (base {hhmm(base)} + 0h workout + "
"0h LeetCode + 1h reading already earned today)."
) in caplog.text
@ -159,7 +162,7 @@ class TestReadingLivePass:
mixin = _mixin()
assert apply_reading_bonus_if_new(state, mixin) is True
assert apply_reading_bonus_if_new(state, mixin) is False
mixin._adjust_shutdown_time_by.assert_called_once_with(1)
mixin._adjust_shutdown_time_by.assert_called_once_with(60)
assert json.loads(state.read_text()) == {
"last_reset_date": today_str(),
"reading_bonus_date": today_str(),

View File

@ -131,7 +131,7 @@ class TestRunStatusNormal:
) -> None:
"""Shutdown config present → shutdown time line shown."""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", cfg=(22, 22, 5))
locker = _make_locker(tmp_path / "log.json", cfg=(1320, 1320, 300))
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),

View File

@ -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 == 20
assert snap.shutdown.rest_of_week[0].minutes == 20 * 60
assert snap.shutdown.rest_of_week[0].speculative is False
assert snap.sick_budget.used_7d == 0
assert snap.sick_budget.exhausted is False
@ -93,14 +93,14 @@ class TestGatherStatus:
"""Shutdown config present reflected in tonight."""
files = _files(tmp_path)
files["shutdown_config_file"].write_text(
"MON_WED_HOUR=22\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n"
"MON_WED_MINUTES=1320\nTHU_SUN_MINUTES=1380\nMORNING_END_MINUTES=300\n"
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
):
snap = gather_status(**files, now=_FRIDAY_NOON_UTC)
assert snap.shutdown.tonight == (22, 23, 5)
assert snap.shutdown.tonight == (1320, 1380, 300)
def test_bonus_streak_and_extended_early_bird_reflected(
self, tmp_path: Path
@ -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 == 23 # 20 base + 3 bonus
assert snap.shutdown.rest_of_week[0].minutes == 23 * 60 # 20h base + 3h bonus
def test_next_week_preview_is_speculative_rest_of_week_is_not(
self, tmp_path: Path

View File

@ -73,7 +73,7 @@ class TestFormatSummaryLine:
"""Mon wed band used on monday."""
files = _files(tmp_path)
files["shutdown_config_file"].write_text(
"MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n"
"MON_WED_MINUTES=1200\nTHU_SUN_MINUTES=1380\nMORNING_END_MINUTES=300\n"
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False
@ -86,7 +86,7 @@ class TestFormatSummaryLine:
"""Thu sun band used on friday."""
files = _files(tmp_path)
files["shutdown_config_file"].write_text(
"MON_WED_HOUR=20\nTHU_SUN_HOUR=23\nMORNING_END_HOUR=5\n"
"MON_WED_MINUTES=1200\nTHU_SUN_MINUTES=1380\nMORNING_END_MINUTES=300\n"
)
with patch(
"screen_locker._status_data.has_workout_skip_today", return_value=False

View File

@ -32,7 +32,7 @@ class TestRunStatusFill:
callback; the CLI just reports the resulting shutdown hour.
"""
eb_file = tmp_path / "eb.json"
locker = _make_locker(tmp_path / "log.json", n_filled=2, cfg=(22, 22, 5))
locker = _make_locker(tmp_path / "log.json", n_filled=2, cfg=(1320, 1320, 300))
with (
patch("screen_locker._status.EXTRA_BENEFITS_FILE", eb_file),
patch("screen_locker._status.current_streak", return_value=0),

View File

@ -187,7 +187,7 @@ class TestSectionShutdown:
def test_tonight_present_shows_live_config(self, mock_tk: MagicMock) -> None:
"""Tonight present shows live config."""
snap = _snapshot(shutdown=_shutdown(tonight=(22, 23, 5)))
snap = _snapshot(shutdown=_shutdown(tonight=(1320, 1380, 300)))
_make_window(mock_tk, snap)
assert any("22:00" in t and "23:00" in t for t in _texts(mock_tk))

View File

@ -65,7 +65,7 @@ class TestBackfillWeekAndApplyBonus:
assert message == (
"Auto-filled 2 workouts from earlier this week. +2h shutdown time."
)
verifier._adjust_shutdown_time_by.assert_called_once_with(2)
verifier._adjust_shutdown_time_by.assert_called_once_with(120)
def test_stronglifts_fill_below_minimum_earns_no_surplus(self) -> None:
"""A StrongLifts fill that leaves the week under the minimum adds nothing."""