From 275a0862fc67bcd6f99db5953cf67ae6d3779e2d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Sun, 4 Oct 2026 19:48:24 +0200 Subject: [PATCH] shutdown: minutes end to end (*_MINUTES schedule keys, HH:MM helper args, to_hours removed) Co-Authored-By: Claude Opus 5 (1M context) --- AGENTS.md | 10 +- README.md | 2 +- screen_locker/_earned.py | 36 ++- screen_locker/_shutdown.py | 135 +++++----- screen_locker/_shutdown_base.py | 56 ++-- screen_locker/_shutdown_sick_state.py | 33 ++- screen_locker/_status.py | 9 +- screen_locker/_status_projection.py | 33 ++- screen_locker/_status_sections.py | 10 +- screen_locker/_status_types.py | 9 +- screen_locker/_status_view_verify.py | 11 +- screen_locker/_sync_mixin.py | 12 +- screen_locker/_unlock_view.py | 3 +- screen_locker/_workout_credit.py | 19 +- screen_locker/adjust_shutdown_schedule.sh | 141 ++++++---- screen_locker/tests/_locker_factories.py | 2 +- screen_locker/tests/_status_view_helpers.py | 6 +- screen_locker/tests/_unlock_helpers.py | 2 +- screen_locker/tests/shell/_adjust_harness.sh | 121 +++++++++ .../shell/test_adjust_shutdown_schedule.sh | 240 +++++------------- .../test_adjust_shutdown_schedule_write.sh | 89 +++++++ screen_locker/tests/test_earned.py | 39 ++- .../tests/test_init_and_log_part2.py | 12 +- .../test_manual_workout_features_part2.py | 14 +- .../test_manual_workout_features_part4.py | 2 +- .../tests/test_manual_workout_status_view.py | 2 +- screen_locker/tests/test_mcp.py | 6 +- .../tests/test_runnerup_backfill_credit.py | 4 +- .../tests/test_screen_lock_coverage_part1.py | 2 +- .../tests/test_screen_lock_coverage_part2.py | 10 +- .../tests/test_screen_lock_coverage_part4.py | 2 +- .../tests/test_screen_lock_coverage_part5.py | 2 +- screen_locker/tests/test_shutdown_base.py | 18 +- .../tests/test_shutdown_base_replay.py | 32 +-- .../tests/test_shutdown_flat_earners.py | 26 +- screen_locker/tests/test_shutdown_leetcode.py | 10 +- screen_locker/tests/test_shutdown_minutes.py | 190 ++++++++++++++ screen_locker/tests/test_shutdown_part2.py | 28 +- screen_locker/tests/test_shutdown_part3.py | 32 +-- screen_locker/tests/test_shutdown_part4.py | 44 ++-- screen_locker/tests/test_shutdown_reading.py | 31 ++- screen_locker/tests/test_status.py | 2 +- screen_locker/tests/test_status_data_part2.py | 8 +- screen_locker/tests/test_status_data_part4.py | 4 +- screen_locker/tests/test_status_part2.py | 2 +- screen_locker/tests/test_status_view.py | 2 +- screen_locker/tests/test_status_view_part4.py | 2 +- 47 files changed, 970 insertions(+), 535 deletions(-) create mode 100644 screen_locker/tests/shell/_adjust_harness.sh create mode 100755 screen_locker/tests/shell/test_adjust_shutdown_schedule_write.sh create mode 100644 screen_locker/tests/test_shutdown_minutes.py diff --git a/AGENTS.md b/AGENTS.md index d25c6fd..bae85f9 100644 --- a/AGENTS.md +++ b/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 diff --git a/README.md b/README.md index e0859fc..5d046b8 100644 --- a/README.md +++ b/README.md @@ -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, diff --git a/screen_locker/_earned.py b/screen_locker/_earned.py index 8025d60..cffcf1e 100644 --- a/screen_locker/_earned.py +++ b/screen_locker/_earned.py @@ -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: diff --git a/screen_locker/_shutdown.py b/screen_locker/_shutdown.py index 4dcbd20..ae7b625 100644 --- a/screen_locker/_shutdown.py +++ b/screen_locker/_shutdown.py @@ -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 diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index a2e18ed..ff35c5e 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -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`. diff --git a/screen_locker/_shutdown_sick_state.py b/screen_locker/_shutdown_sick_state.py index 609aaf0..0dac985 100644 --- a/screen_locker/_shutdown_sick_state.py +++ b/screen_locker/_shutdown_sick_state.py @@ -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, diff --git a/screen_locker/_status.py b/screen_locker/_status.py index 4da9d60..0cd9c60 100644 --- a/screen_locker/_status.py +++ b/screen_locker/_status.py @@ -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. diff --git a/screen_locker/_status_projection.py b/screen_locker/_status_projection.py index 8825504..2eab2e8 100644 --- a/screen_locker/_status_projection.py +++ b/screen_locker/_status_projection.py @@ -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 · " diff --git a/screen_locker/_status_sections.py b/screen_locker/_status_sections.py index e68804e..b8ca168 100644 --- a/screen_locker/_status_sections.py +++ b/screen_locker/_status_sections.py @@ -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}", diff --git a/screen_locker/_status_types.py b/screen_locker/_status_types.py index 45718bd..c3544d1 100644 --- a/screen_locker/_status_types.py +++ b/screen_locker/_status_types.py @@ -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, ...] diff --git a/screen_locker/_status_view_verify.py b/screen_locker/_status_view_verify.py index 8f5a5dd..ecd90b0 100644 --- a/screen_locker/_status_view_verify.py +++ b/screen_locker/_status_view_verify.py @@ -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 diff --git a/screen_locker/_sync_mixin.py b/screen_locker/_sync_mixin.py index 8bcf863..bf0ad4c 100644 --- a/screen_locker/_sync_mixin.py +++ b/screen_locker/_sync_mixin.py @@ -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) diff --git a/screen_locker/_unlock_view.py b/screen_locker/_unlock_view.py index 54794cc..08b5272 100644 --- a/screen_locker/_unlock_view.py +++ b/screen_locker/_unlock_view.py @@ -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, ) diff --git a/screen_locker/_workout_credit.py b/screen_locker/_workout_credit.py index 368a4d5..7309243 100644 --- a/screen_locker/_workout_credit.py +++ b/screen_locker/_workout_credit.py @@ -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] diff --git a/screen_locker/adjust_shutdown_schedule.sh b/screen_locker/adjust_shutdown_schedule.sh index 029fc77..999ddbd 100755 --- a/screen_locker/adjust_shutdown_schedule.sh +++ b/screen_locker/adjust_shutdown_schedule.sh @@ -3,64 +3,92 @@ # # Usage: sudo adjust_shutdown_schedule.sh [--restore] # +# 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: # 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] " >&2 + echo "Usage: $0 [--restore] " >&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. diff --git a/screen_locker/tests/_locker_factories.py b/screen_locker/tests/_locker_factories.py index 3f1e5df..ed0e367 100644 --- a/screen_locker/tests/_locker_factories.py +++ b/screen_locker/tests/_locker_factories.py @@ -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( diff --git a/screen_locker/tests/_status_view_helpers.py b/screen_locker/tests/_status_view_helpers.py index 514dc6b..052303d 100644 --- a/screen_locker/tests/_status_view_helpers.py +++ b/screen_locker/tests/_status_view_helpers.py @@ -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( diff --git a/screen_locker/tests/_unlock_helpers.py b/screen_locker/tests/_unlock_helpers.py index b350cd3..ee2fde6 100644 --- a/screen_locker/tests/_unlock_helpers.py +++ b/screen_locker/tests/_unlock_helpers.py @@ -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 diff --git a/screen_locker/tests/shell/_adjust_harness.sh b/screen_locker/tests/shell/_adjust_harness.sh new file mode 100644 index 0000000..d8e78d7 --- /dev/null +++ b/screen_locker/tests/shell/_adjust_harness.sh @@ -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" <"$SHUTDOWN_CONFIG_FILE" </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" <>"$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 diff --git a/screen_locker/tests/shell/test_adjust_shutdown_schedule_write.sh b/screen_locker/tests/shell/test_adjust_shutdown_schedule_write.sh new file mode 100755 index 0000000..861d1b8 --- /dev/null +++ b/screen_locker/tests/shell/test_adjust_shutdown_schedule_write.sh @@ -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 diff --git a/screen_locker/tests/test_earned.py b/screen_locker/tests/test_earned.py index 90c2994..13b6ad9 100644 --- a/screen_locker/tests/test_earned.py +++ b/screen_locker/tests/test_earned.py @@ -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: diff --git a/screen_locker/tests/test_init_and_log_part2.py b/screen_locker/tests/test_init_and_log_part2.py index 1cb5fd1..0708bcd 100644 --- a/screen_locker/tests/test_init_and_log_part2.py +++ b/screen_locker/tests/test_init_and_log_part2.py @@ -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, diff --git a/screen_locker/tests/test_manual_workout_features_part2.py b/screen_locker/tests/test_manual_workout_features_part2.py index 9223cfd..e4bc8d5 100644 --- a/screen_locker/tests/test_manual_workout_features_part2.py +++ b/screen_locker/tests/test_manual_workout_features_part2.py @@ -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) diff --git a/screen_locker/tests/test_manual_workout_features_part4.py b/screen_locker/tests/test_manual_workout_features_part4.py index c0506d8..6079823 100644 --- a/screen_locker/tests/test_manual_workout_features_part4.py +++ b/screen_locker/tests/test_manual_workout_features_part4.py @@ -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) diff --git a/screen_locker/tests/test_manual_workout_status_view.py b/screen_locker/tests/test_manual_workout_status_view.py index f004e6b..1616005 100644 --- a/screen_locker/tests/test_manual_workout_status_view.py +++ b/screen_locker/tests/test_manual_workout_status_view.py @@ -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, ) diff --git a/screen_locker/tests/test_mcp.py b/screen_locker/tests/test_mcp.py index 223a302..6e245c2 100644 --- a/screen_locker/tests/test_mcp.py +++ b/screen_locker/tests/test_mcp.py @@ -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", ), diff --git a/screen_locker/tests/test_runnerup_backfill_credit.py b/screen_locker/tests/test_runnerup_backfill_credit.py index 050ac36..87a70e2 100644 --- a/screen_locker/tests/test_runnerup_backfill_credit.py +++ b/screen_locker/tests/test_runnerup_backfill_credit.py @@ -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 == {} diff --git a/screen_locker/tests/test_screen_lock_coverage_part1.py b/screen_locker/tests/test_screen_lock_coverage_part1.py index 3fcf833..c593473 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part1.py +++ b/screen_locker/tests/test_screen_lock_coverage_part1.py @@ -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, diff --git a/screen_locker/tests/test_screen_lock_coverage_part2.py b/screen_locker/tests/test_screen_lock_coverage_part2.py index b894975..09bb5ef 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part2.py +++ b/screen_locker/tests/test_screen_lock_coverage_part2.py @@ -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)) diff --git a/screen_locker/tests/test_screen_lock_coverage_part4.py b/screen_locker/tests/test_screen_lock_coverage_part4.py index c15a719..d846193 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part4.py +++ b/screen_locker/tests/test_screen_lock_coverage_part4.py @@ -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( diff --git a/screen_locker/tests/test_screen_lock_coverage_part5.py b/screen_locker/tests/test_screen_lock_coverage_part5.py index 3049538..8706a5b 100644 --- a/screen_locker/tests/test_screen_lock_coverage_part5.py +++ b/screen_locker/tests/test_screen_lock_coverage_part5.py @@ -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 diff --git a/screen_locker/tests/test_shutdown_base.py b/screen_locker/tests/test_shutdown_base.py index cb96019..b1b1b5a 100644 --- a/screen_locker/tests/test_shutdown_base.py +++ b/screen_locker/tests/test_shutdown_base.py @@ -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: diff --git a/screen_locker/tests/test_shutdown_base_replay.py b/screen_locker/tests/test_shutdown_base_replay.py index 4b995e6..5891762 100644 --- a/screen_locker/tests/test_shutdown_base_replay.py +++ b/screen_locker/tests/test_shutdown_base_replay.py @@ -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.""" diff --git a/screen_locker/tests/test_shutdown_flat_earners.py b/screen_locker/tests/test_shutdown_flat_earners.py index 2fc76b8..fd49d08 100644 --- a/screen_locker/tests/test_shutdown_flat_earners.py +++ b/screen_locker/tests/test_shutdown_flat_earners.py @@ -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 diff --git a/screen_locker/tests/test_shutdown_leetcode.py b/screen_locker/tests/test_shutdown_leetcode.py index 725f177..ed22dab 100644 --- a/screen_locker/tests/test_shutdown_leetcode.py +++ b/screen_locker/tests/test_shutdown_leetcode.py @@ -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() diff --git a/screen_locker/tests/test_shutdown_minutes.py b/screen_locker/tests/test_shutdown_minutes.py new file mode 100644 index 0000000..3bbc5e3 --- /dev/null +++ b/screen_locker/tests/test_shutdown_minutes.py @@ -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 diff --git a/screen_locker/tests/test_shutdown_part2.py b/screen_locker/tests/test_shutdown_part2.py index e54644d..8725618 100644 --- a/screen_locker/tests/test_shutdown_part2.py +++ b/screen_locker/tests/test_shutdown_part2.py @@ -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, diff --git a/screen_locker/tests/test_shutdown_part3.py b/screen_locker/tests/test_shutdown_part3.py index 741bd23..d417002 100644 --- a/screen_locker/tests/test_shutdown_part3.py +++ b/screen_locker/tests/test_shutdown_part3.py @@ -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", ] diff --git a/screen_locker/tests/test_shutdown_part4.py b/screen_locker/tests/test_shutdown_part4.py index 07515fe..26cc2db 100644 --- a/screen_locker/tests/test_shutdown_part4.py +++ b/screen_locker/tests/test_shutdown_part4.py @@ -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, diff --git a/screen_locker/tests/test_shutdown_reading.py b/screen_locker/tests/test_shutdown_reading.py index bab8171..dfd1f09 100644 --- a/screen_locker/tests/test_shutdown_reading.py +++ b/screen_locker/tests/test_shutdown_reading.py @@ -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(), diff --git a/screen_locker/tests/test_status.py b/screen_locker/tests/test_status.py index 3d933e7..0a6d00a 100644 --- a/screen_locker/tests/test_status.py +++ b/screen_locker/tests/test_status.py @@ -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), diff --git a/screen_locker/tests/test_status_data_part2.py b/screen_locker/tests/test_status_data_part2.py index b371493..5c2575d 100644 --- a/screen_locker/tests/test_status_data_part2.py +++ b/screen_locker/tests/test_status_data_part2.py @@ -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 diff --git a/screen_locker/tests/test_status_data_part4.py b/screen_locker/tests/test_status_data_part4.py index a06d184..2460f07 100644 --- a/screen_locker/tests/test_status_data_part4.py +++ b/screen_locker/tests/test_status_data_part4.py @@ -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 diff --git a/screen_locker/tests/test_status_part2.py b/screen_locker/tests/test_status_part2.py index d5d38a0..a7956b2 100644 --- a/screen_locker/tests/test_status_part2.py +++ b/screen_locker/tests/test_status_part2.py @@ -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), diff --git a/screen_locker/tests/test_status_view.py b/screen_locker/tests/test_status_view.py index 7edb5f6..d9c19c4 100644 --- a/screen_locker/tests/test_status_view.py +++ b/screen_locker/tests/test_status_view.py @@ -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)) diff --git a/screen_locker/tests/test_status_view_part4.py b/screen_locker/tests/test_status_view_part4.py index 3de304d..84bc2aa 100644 --- a/screen_locker/tests/test_status_view_part4.py +++ b/screen_locker/tests/test_status_view_part4.py @@ -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."""