mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
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
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:
parent
ed5fa133d0
commit
275a0862fc
10
AGENTS.md
10
AGENTS.md
@ -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
|
||||
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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`.
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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 · "
|
||||
|
||||
@ -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}",
|
||||
|
||||
@ -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, ...]
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
121
screen_locker/tests/shell/_adjust_harness.sh
Normal file
121
screen_locker/tests/shell/_adjust_harness.sh
Normal 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 ]]
|
||||
}
|
||||
@ -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
|
||||
|
||||
89
screen_locker/tests/shell/test_adjust_shutdown_schedule_write.sh
Executable file
89
screen_locker/tests/shell/test_adjust_shutdown_schedule_write.sh
Executable 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
|
||||
@ -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:
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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,
|
||||
)
|
||||
|
||||
@ -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",
|
||||
),
|
||||
|
||||
@ -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 == {}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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))
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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."""
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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()
|
||||
|
||||
190
screen_locker/tests/test_shutdown_minutes.py
Normal file
190
screen_locker/tests/test_shutdown_minutes.py
Normal 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
|
||||
@ -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,
|
||||
|
||||
@ -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",
|
||||
]
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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))
|
||||
|
||||
|
||||
@ -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."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user