mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 18:07:22 +01:00
Cap live bonuses at the day's ceiling
The by-minutes bonus path clamped at 24:00 (_MIDNIGHT) and relied on
adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00). It now clamps at
earned_time.shutdown_ceiling_for(day) via the new _ceiling.ceiling, so a
moved WAKE_MINUTES cannot be passed by a bonus. The shell's 1380 stays
the hard upper bound; ceiling() is min()'d with it, and a test pins the
Python constant to the script's value. _preview_sim and the invariant
test reuse that constant instead of a second 23 * 60.
Failure mode: if the earned_time lookup raises (ArithmeticError,
LookupError, TypeError, ValueError), ceiling() falls back to 23:00
loudly (logger.warning), never silently and never above the helper's
bound. That is fail-open relative to a lowered ceiling, bounded by the
shell clamp; an earned_time without the API (0.3.0) uses its constant.
Red first: new tests (21:00 ceiling, failing lookup, caps-at-ceiling)
failed on c3a5d14, pass now. 14-day replay of the real ledgers
(2026-10-18..31) through old and new code, plus an all-done-at-22:30
counterfactual: identical, every day 23:00; live files unchanged.
TODO-cap-bonus-at-day-ceiling.md removed (done).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYdwRAtMg7ki6TKxuU
This commit is contained in:
parent
1e6f17257c
commit
aa2776a45e
@ -1,24 +0,0 @@
|
||||
# Cap every live shutdown bonus at the day's ceiling, not 24:00 / 23:00
|
||||
|
||||
Carried over from the 2026-10-10 tutor-cutover handoff (item 1a, optional).
|
||||
|
||||
## what
|
||||
The live bonus paths clamp at 24:00 (`_shutdown.py` `_MIDNIGHT`, ~:152) and
|
||||
then at `adjust_shutdown_schedule.sh`'s fixed `RESTORE_CEILING=1380` (23:00).
|
||||
If `WAKE_MINUTES` ever moves the earned_time ceiling below 23:00, a bonus could
|
||||
push shutdown past the real ceiling. Cap them at
|
||||
`earned_time.shutdown_ceiling_for(day)` instead (already wrapped in
|
||||
`_earned.py` ~:167).
|
||||
|
||||
## where
|
||||
`screen_locker/_shutdown.py`, `screen_locker/adjust_shutdown_schedule.sh`,
|
||||
`scripts/_preview_sim.py` (`HELPER_CEILING` mirrors `RESTORE_CEILING`).
|
||||
|
||||
## must
|
||||
- The 168-case "do every task => 23:00" invariant test (fc54534) stays green.
|
||||
- must not: loosen anything — the shell ceiling remains a hard upper bound.
|
||||
|
||||
## done
|
||||
A test that sets a ceiling below 23:00 shows no bonus path exceeding it.
|
||||
|
||||
REMOVE ME AFTER FINISH
|
||||
39
screen_locker/_ceiling.py
Normal file
39
screen_locker/_ceiling.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""The latest shutdown a day can earn: ``earned_time``'s ceiling, bounded at 23:00."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
# adjust_shutdown_schedule.sh's RESTORE_CEILING (23:00): the hard upper bound
|
||||
# every --restore write is clamped to, and the answer when the lookup fails.
|
||||
HARD_CEILING_MINUTES = 23 * 60
|
||||
|
||||
|
||||
def ceiling(day: date) -> int:
|
||||
"""The latest shutdown ``day`` can earn, minutes after its midnight.
|
||||
|
||||
Never above :data:`HARD_CEILING_MINUTES`. A lookup that raises fails open
|
||||
to that bound -- the same cap the helper enforces anyway -- and says so at
|
||||
warning, so a broken registry cannot silently lift or lower the cap.
|
||||
"""
|
||||
fn = getattr(earned_time, "shutdown_ceiling_for", None)
|
||||
if fn is None:
|
||||
return min(HARD_CEILING_MINUTES, int(earned_time.SHUTDOWN_CEILING_MINUTES))
|
||||
try:
|
||||
return min(HARD_CEILING_MINUTES, int(fn(day)))
|
||||
except (ArithmeticError, LookupError, TypeError, ValueError) as err:
|
||||
_logger.warning(
|
||||
"shutdown ceiling for %s unavailable (%s); using %s",
|
||||
day,
|
||||
err,
|
||||
f"{HARD_CEILING_MINUTES // 60:02d}:{HARD_CEILING_MINUTES % 60:02d}",
|
||||
)
|
||||
return HARD_CEILING_MINUTES
|
||||
@ -176,12 +176,6 @@ def extra_minutes(earner: earned_time.Earner, day: date) -> int:
|
||||
return earner.extra_shutdown_minutes if fn is None else int(fn(earner, day))
|
||||
|
||||
|
||||
def ceiling(day: date) -> int:
|
||||
"""The latest shutdown ``day`` can earn, minutes after its midnight."""
|
||||
fn = getattr(earned_time, "shutdown_ceiling_for", None)
|
||||
return earned_time.SHUTDOWN_CEILING_MINUTES if fn is None else int(fn(day))
|
||||
|
||||
|
||||
def first_credit_time(earner: earned_time.Earner, day: date) -> float | None:
|
||||
"""Unix time of ``earner``'s first verified credit counting for ``day``.
|
||||
|
||||
|
||||
@ -19,8 +19,9 @@ import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from screen_locker._ceiling import ceiling
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, hhmm
|
||||
from screen_locker._earned import hhmm
|
||||
from screen_locker._grace_floor import save_lift, tonight_minutes
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
@ -9,12 +9,13 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._ceiling import ceiling
|
||||
from screen_locker._constants import (
|
||||
ADJUST_SHUTDOWN_SCRIPT,
|
||||
SHUTDOWN_CONFIG_FILE,
|
||||
)
|
||||
from screen_locker._day import today_str
|
||||
from screen_locker._earned import ceiling, first_minutes, hhmm, span
|
||||
from screen_locker._earned import first_minutes, hhmm, span
|
||||
from screen_locker._grace_floor import plan_absorb, save_lift
|
||||
from screen_locker._late_credit import LateCreditMixin
|
||||
from screen_locker._sick import is_sick
|
||||
@ -31,9 +32,6 @@ _logger = logging.getLogger(__name__)
|
||||
_SHUTDOWN_CONFIG_NAMES = ("MON_WED", "THU_SUN", "MORNING_END")
|
||||
_MINUTES_PER_HOUR = 60
|
||||
|
||||
# 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*."""
|
||||
@ -107,16 +105,17 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
def _adjust_shutdown_time_by(
|
||||
self, extra_minutes: int, at: float | None = None
|
||||
) -> bool:
|
||||
"""Push shutdown later by *extra_minutes*, capped at 24:00 (midnight).
|
||||
"""Push shutdown later by *extra_minutes*, capped at the day's ceiling.
|
||||
|
||||
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.
|
||||
Used for earner and extra-workout bonuses. The cap is
|
||||
``earned_time.shutdown_ceiling_for(today)`` (23:00 today, lower if
|
||||
``WAKE_MINUTES`` ever moves); the helper's 23:00 stays the hard bound.
|
||||
|
||||
``at`` is when the credit was earned (default now): a late one counts
|
||||
from it (:mod:`._late_credit`). Returns True if successful.
|
||||
"""
|
||||
if is_sick(date.fromisoformat(today_str())):
|
||||
day = date.fromisoformat(today_str())
|
||||
if is_sick(day):
|
||||
return self._raise_on_sick_day(extra_minutes, at)
|
||||
late = self._late_write(extra_minutes, at)
|
||||
if late is not None:
|
||||
@ -129,8 +128,8 @@ class ShutdownMixin(SickFloorMixin, LateCreditMixin):
|
||||
return False
|
||||
mw, ts, morning = config_values
|
||||
ok = self._write_shutdown_config(
|
||||
min(_MIDNIGHT, mw + moved),
|
||||
min(_MIDNIGHT, ts + moved),
|
||||
min(ceiling(day), mw + moved),
|
||||
min(ceiling(day), ts + moved),
|
||||
morning,
|
||||
restore=True,
|
||||
)
|
||||
|
||||
@ -15,7 +15,7 @@ from unittest.mock import patch
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _cli, _earned, _grace_floor, _runnerup_tcx, _workout_credit
|
||||
from screen_locker import _ceiling, _cli, _earned, _grace_floor, _runnerup_tcx, _workout_credit
|
||||
from screen_locker._shutdown import ShutdownMixin
|
||||
from screen_locker._workout_credit import WorkoutCreditMixin
|
||||
|
||||
@ -129,7 +129,7 @@ class TestPreLadderEarnedTime:
|
||||
workout = earned_time.WORKOUT
|
||||
assert _earned.first_minutes(workout, day) == workout.shutdown_minutes
|
||||
assert _earned.extra_minutes(workout, day) == workout.extra_shutdown_minutes
|
||||
assert _earned.ceiling(day) == earned_time.SHUTDOWN_CEILING_MINUTES
|
||||
assert _ceiling.ceiling(day) == earned_time.SHUTDOWN_CEILING_MINUTES
|
||||
_earned._warn_no_first_credit.cache_clear()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert _earned.first_credit_time(earned_time.ANKI, day) is None
|
||||
|
||||
@ -26,7 +26,8 @@ import earned_time
|
||||
import pytest
|
||||
|
||||
from screen_locker import _gate_bonus, _grace_floor, _late_credit, _shutdown
|
||||
from screen_locker._earned import ceiling, extra_minutes, first_minutes, is_gate
|
||||
from screen_locker._ceiling import HARD_CEILING_MINUTES, ceiling
|
||||
from screen_locker._earned import extra_minutes, first_minutes, is_gate
|
||||
from screen_locker._earned import registry as registry_for
|
||||
from screen_locker._gate_bonus import apply_counted_bonus
|
||||
from screen_locker._shutdown import ShutdownMixin
|
||||
@ -40,7 +41,7 @@ _LAST = date(2026, 12, 31)
|
||||
_DAYS = tuple(_FIRST + timedelta(days=n) for n in range((_LAST - _FIRST).days + 1))
|
||||
# adjust_shutdown_schedule.sh's RESTORE_CEILING: the helper clamps every
|
||||
# --restore write to 23:00, so the double does too.
|
||||
_HELPER_CEILING = 23 * 60
|
||||
_HELPER_CEILING = HARD_CEILING_MINUTES
|
||||
_MORNING = 5 * 60
|
||||
|
||||
Credits = dict[str, date | None] | None
|
||||
|
||||
@ -2,13 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import earned_time
|
||||
|
||||
from screen_locker._ceiling import HARD_CEILING_MINUTES
|
||||
from screen_locker.tests.conftest import create_locker
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
import pytest
|
||||
|
||||
|
||||
def test_hard_ceiling_matches_the_helpers_restore_ceiling() -> None:
|
||||
"""The Python bound and adjust_shutdown_schedule.sh's must not drift."""
|
||||
script = Path(__file__).parents[1] / "adjust_shutdown_schedule.sh"
|
||||
match = re.search(
|
||||
r"^readonly RESTORE_CEILING=(\d+)", script.read_text(), re.MULTILINE
|
||||
)
|
||||
assert match is not None
|
||||
assert int(match.group(1)) == HARD_CEILING_MINUTES
|
||||
|
||||
|
||||
class TestAdjustShutdownTimeBy:
|
||||
@ -33,13 +49,13 @@ class TestAdjustShutdownTimeBy:
|
||||
1320, 1320, 300, restore=True
|
||||
)
|
||||
|
||||
def test_caps_at_midnight(
|
||||
def test_caps_at_the_day_ceiling(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Times are capped at 1440 minutes (midnight-safe shutdown)."""
|
||||
"""Times are capped at the day's ceiling (23:00 today), not midnight."""
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(
|
||||
locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300))
|
||||
@ -49,9 +65,59 @@ class TestAdjustShutdownTimeBy:
|
||||
)
|
||||
locker._adjust_shutdown_time_by(120)
|
||||
locker._write_shutdown_config.assert_called_once_with(
|
||||
1440, 1440, 300, restore=True
|
||||
1380, 1380, 300, restore=True
|
||||
)
|
||||
|
||||
def test_caps_at_a_ceiling_below_23(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
"""If WAKE_MINUTES moves the ceiling to 21:00 no bonus passes it."""
|
||||
monkeypatch.setattr(earned_time, "shutdown_ceiling_for", lambda _d: 21 * 60)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(
|
||||
locker, "_read_shutdown_config", MagicMock(return_value=(1200, 1200, 300))
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_write_shutdown_config", MagicMock(return_value=True)
|
||||
)
|
||||
locker._adjust_shutdown_time_by(120)
|
||||
locker._write_shutdown_config.assert_called_once_with(
|
||||
1260, 1260, 300, restore=True
|
||||
)
|
||||
|
||||
def test_failed_ceiling_lookup_falls_back_to_23_loudly(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
mock_sys_exit: MagicMock,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
) -> None:
|
||||
"""earned_time raising must not lift the cap: 23:00, logged at warning."""
|
||||
|
||||
def boom(_day: object) -> int:
|
||||
msg = "registry unreadable"
|
||||
raise ValueError(msg)
|
||||
|
||||
monkeypatch.setattr(earned_time, "shutdown_ceiling_for", boom)
|
||||
locker = create_locker(mock_tk, tmp_path)
|
||||
object.__setattr__(
|
||||
locker, "_read_shutdown_config", MagicMock(return_value=(1380, 1380, 300))
|
||||
)
|
||||
object.__setattr__(
|
||||
locker, "_write_shutdown_config", MagicMock(return_value=True)
|
||||
)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
locker._adjust_shutdown_time_by(120)
|
||||
locker._write_shutdown_config.assert_called_once_with(
|
||||
1380, 1380, 300, restore=True
|
||||
)
|
||||
assert "ceiling" in caplog.text
|
||||
|
||||
def test_returns_false_when_config_is_none(
|
||||
self,
|
||||
mock_tk: MagicMock,
|
||||
|
||||
@ -14,6 +14,7 @@ from typing import TYPE_CHECKING
|
||||
import earned_time
|
||||
import sickdays
|
||||
|
||||
from screen_locker._ceiling import HARD_CEILING_MINUTES as HELPER_CEILING
|
||||
from screen_locker._earned import first_minutes
|
||||
from screen_locker._runnerup_verification import RunnerUpVerificationMixin
|
||||
from screen_locker._shutdown import ShutdownMixin
|
||||
@ -23,7 +24,6 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
_NS = "http://www.garmin.com/xmlschemas/TrainingCenterDatabase/v2"
|
||||
HELPER_CEILING = 23 * 60 # adjust_shutdown_schedule.sh's RESTORE_CEILING
|
||||
|
||||
|
||||
def stamp(day: date, hhmm_text: str) -> float:
|
||||
|
||||
Loading…
Reference in New Issue
Block a user