diff --git a/screen_locker/_earned.py b/screen_locker/_earned.py index 48b5dc2..3c0b663 100644 --- a/screen_locker/_earned.py +++ b/screen_locker/_earned.py @@ -16,18 +16,16 @@ midnight, so a 30-minute earner is applied exactly. from __future__ import annotations -from datetime import datetime +from datetime import date, datetime import functools import logging from pathlib import Path -from typing import TYPE_CHECKING, Final +from typing import Final import earned_time from screen_locker._constants import HMAC_KEY_FILE - -if TYPE_CHECKING: - from datetime import date +from screen_locker._day import today_str _logger: Final = logging.getLogger(__name__) @@ -86,6 +84,11 @@ def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]: return tuple(e for e in registry(day) if e.kind == "flat" and is_gate(e)) +def gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]: + """Every gate earner in force on ``day``, flat and counted, in registry order.""" + return tuple(e for e in registry(day) if is_gate(e)) + + def counted_gate_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]: """Gate earners paid per unit on ``day`` (the Automation tutor's blocks).""" return tuple(e for e in registry(day) if e.kind == "counted" and is_gate(e)) @@ -187,3 +190,42 @@ def _warn_no_first_credit() -> None: "earned_time has no first_credit_at (needs >= 0.4); the grace floor " "sees no ledger earner, only the workout log" ) + + +def first_credits( + earners: tuple[earned_time.Earner, ...], day: date +) -> dict[str, date | None] | None: + """Each penalised ledger earner's first real credit up to ``day``, for resolve. + + ``earned_time.resolve`` / ``base_for`` take it as ``first_credits=`` (>= 0.6) + and then start a penalty no earlier than the day after the gate first + paid out. Keyed by name over ``earners`` -- the registry the answers came + from, so the two ``automation`` earners (either side of ``TUTOR_FROM``) + never collide. An earner without ``penalty_from``, a ledger or a matcher + is left out (``resolve`` falls back to its ``confirmed_on``). + + Returns: + The map; ``None`` for a day before today -- history keeps the shutdown + it was enforced at, never re-resolved -- and on an earned_time + without ``maturity`` (< 0.6, logged), where callers resolve as before. + """ + if day < date.fromisoformat(today_str()): + return None + fn = getattr(earned_time, "maturity", None) + if fn is None: + _warn_no_maturity() + return None + return { + e.name: fn(e, ledger_file(e), HMAC_KEY_FILE, day).first_credit + for e in earners + if e.penalty_from is not None and e.ledger is not None and e.match is not None + } + + +@functools.cache +def _warn_no_maturity() -> None: + """Once per process: the installed earned_time predates maturity.""" + _logger.warning( + "earned_time has no maturity (needs >= 0.6); every penalty starts on " + "its penalty_from, even for a gate that never paid out" + ) diff --git a/screen_locker/_grace_floor.py b/screen_locker/_grace_floor.py index c9e201d..8bf060f 100644 --- a/screen_locker/_grace_floor.py +++ b/screen_locker/_grace_floor.py @@ -2,10 +2,11 @@ Someone who gets home at 18:10 and finishes their first task at 18:55 is not cut off at 19:00. The floor is ``first done + GRACE_MINUTES``, capped at the -day's ceiling. "First done" is the earliest of every ledger earner's first -verified credit (``earned_time.first_credit_at``) and the workout's own -completion time -- the end of the run or walk, never the moment a sync -happened to log it, so a late upload does not move the floor later. +day's ceiling. "First done" is the earliest of every gate earner's first +verified credit, flat or counted (``earned_time.first_credit_at``), and the +workout's own completion time -- the end of the run or walk, never the +moment a sync happened to log it, so a late upload does not move the floor +later. **A floor, not a bonus.** The config is otherwise read-add-write: every earner adds its minutes to whatever is there. Lifting it to the floor and @@ -30,7 +31,7 @@ import earned_time from screen_locker._bonus_lock import bonus_lock from screen_locker._constants import GRACE_MINUTES, GRACE_STATE_FILE from screen_locker._day import today_str -from screen_locker._earned import ceiling, first_credit_time, flat_earners, hhmm +from screen_locker._earned import ceiling, first_credit_time, gate_earners, hhmm from screen_locker._log_io import load_workout_log from screen_locker._rest_day import is_rest_day from screen_locker._sick_tracker import is_sick_day, load_history @@ -80,8 +81,12 @@ def _workout_time(day: date, log_file: Path | None, *, rest_day: bool) -> float def first_done_at( day: date, log_file: Path | None, *, rest_day: bool = False ) -> float | None: - """The earliest moment any earner was done on ``day``; ``None`` if none.""" - times = [first_credit_time(e, day) for e in flat_earners()] + """The earliest moment any earner was done on ``day``; ``None`` if none. + + Every gate in force on ``day`` counts, flat or counted: the Automation + tutor's first verified block starts the floor like a LeetCode solve does. + """ + times = [first_credit_time(e, day) for e in gate_earners(day)] times.append(_workout_time(day, log_file, rest_day=rest_day)) on_day = [ t diff --git a/screen_locker/_shutdown_base.py b/screen_locker/_shutdown_base.py index 039ed07..a48ac5a 100644 --- a/screen_locker/_shutdown_base.py +++ b/screen_locker/_shutdown_base.py @@ -36,6 +36,7 @@ from screen_locker._day import today_str from screen_locker._earned import ( counted_gate_earners, earned_today, + first_credits, first_minutes, flat_earners, hhmm, @@ -60,10 +61,15 @@ _DEFAULT_MORNING_END = 5 * 60 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. + Every penalty in force is already taken off; from today on, a gate's not + before it has paid out (:func:`~screen_locker._earned.first_credits`). """ target = day or datetime.now().astimezone().date() - return earned_time.base_for(target, registry(target)).shutdown_minutes + earners = registry(target) + starts = first_credits(earners, target) + if starts is None: + return earned_time.base_for(target, earners).shutdown_minutes + return earned_time.base_for(target, earners, first_credits=starts).shutdown_minutes def today_credit_count(log_file: Path, today: str) -> int: diff --git a/screen_locker/_shutdown_target.py b/screen_locker/_shutdown_target.py index e18b319..1da3c83 100644 --- a/screen_locker/_shutdown_target.py +++ b/screen_locker/_shutdown_target.py @@ -19,7 +19,7 @@ from typing import TYPE_CHECKING import earned_time -from screen_locker._earned import ceiling, gate_answers, registry +from screen_locker._earned import ceiling, first_credits, gate_answers, registry from screen_locker._grace_floor import first_done_at, grace_for, sick_on from screen_locker._log_io import load_workout_log from screen_locker._rest_day import is_rest_day @@ -77,6 +77,9 @@ class DayInputs: rest_day: Declared rest day. first_done: Unix time the day's first earner was done, if any. sick_day: Sick day: the sick-day shutdown wins, no grace floor. + first_credits: Each penalised gate's first real credit + (:func:`~screen_locker._earned.first_credits`); ``None`` resolves + on ``penalty_from`` alone, as before earned_time 0.6. """ day: date @@ -85,6 +88,7 @@ class DayInputs: rest_day: bool = False first_done: float | None = None sick_day: bool = False + first_credits: Mapping[str, date | None] | None = None def derive(inputs: DayInputs) -> Target: @@ -96,7 +100,13 @@ def derive(inputs: DayInputs) -> Target: ) # The registry is passed explicitly: gate_answers iterated this same # day's registry, so the earners asked and the earners summed match. - resolution = earned_time.resolve(answers, day, registry(day)) + earners = registry(day) + starts = inputs.first_credits + resolution = ( + earned_time.resolve(answers, day, earners) + if starts is None + else earned_time.resolve(answers, day, earners, first_credits=starts) + ) grace = None if inputs.sick_day else grace_for(inputs.first_done, day) minutes = resolution.shutdown_minutes if grace is not None: @@ -130,5 +140,6 @@ def gather(day: date, log_file: Path | None) -> Target: rest_day=rest_day, first_done=first_done_at(day, log_file, rest_day=rest_day), sick_day=sick_on(day), + first_credits=first_credits(registry(day), day), ) ) diff --git a/screen_locker/tests/test_first_credits.py b/screen_locker/tests/test_first_credits.py new file mode 100644 index 0000000..a367c4a --- /dev/null +++ b/screen_locker/tests/test_first_credits.py @@ -0,0 +1,160 @@ +"""Tests for ``_earned.first_credits``: maturity only delays a penalty. + +A stand-in earner (``piano``) is registered with a penalty from well before +the pinned day. Its signed ledger lives under the suite's redirected +``LEDGER_HOME``; whether it has ever paid out decides whether its cut applies +(earned_time 0.6.0). Every date is fixed and "today" is pinned on +``_earned.today_str``, so the real calendar cannot move a result. +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date, datetime, time, timedelta +import logging +from typing import TYPE_CHECKING +from unittest.mock import patch + +import earned_time +import pytest + +from screen_locker import _earned +from screen_locker._earned import first_credits, registry +from screen_locker._shutdown_base import base_minutes +from screen_locker._shutdown_target import DayInputs, derive, gather +from screen_locker.tests._earned_fixtures import ( + EXTRA, + credit, + register, + signing_key, + write_ledger, +) + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +# Before the sleep ladder (2026-10-10), where a penalty still lowers the +# shutdown base; before the Anki waiver too, so the registry is EARNERS. +_DAY = date(2026, 10, 8) +_ONE = timedelta(days=1) + + +def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool: + """Every verified credit counts: the dating is earned_time's own.""" + del row, window + return True + + +# Penalised for days already, never confirmed by kuhy: a new gate. +PIANO = earned_time.Earner( + name="piano", + label="Piano", + gaming_minutes=30, + shutdown_minutes=30, + ledger=".local/share/piano_guard/ledger.json", + match=_any_credit, + penalty_from=_DAY - 5 * _ONE, +) + + +@pytest.fixture +def today() -> Iterator[None]: + """Pin "today" to ``_DAY`` for the history check. + + Yields: + None, with ``_earned.today_str`` answering ``_DAY``. + """ + with patch.object(_earned, "today_str", return_value=_DAY.isoformat()): + yield + + +@pytest.fixture +def piano(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[None]: + """Register ``piano`` and sign its ledger with a throwaway key. + + Args: + monkeypatch: Patches the registries. + tmp_path: Where the key is written. + + Yields: + None, with ``piano`` in every registry and the key patched. + """ + register(monkeypatch, PIANO) + with signing_key(tmp_path): + yield + + +def _paid(day: date) -> None: + """``piano`` paid out once, at noon on ``day``.""" + write_ledger(PIANO, [credit(PIANO, datetime.combine(day, time(12)).astimezone())]) + + +@pytest.mark.usefixtures("today", "piano") +class TestTheMap: + def test_each_penalised_reader_maps_to_its_first_credit(self) -> None: + """A bonus, a ledger-less and a matcher-less earner are left out.""" + _paid(_DAY - _ONE) + never = replace(PIANO, name="never", ledger=".local/share/never/l.json") + earners = ( + PIANO, + never, + EXTRA, + replace(PIANO, name="bare", ledger=None, match=None), + replace(PIANO, name="blind", match=None), + ) + assert first_credits(earners, _DAY) == {"piano": _DAY - _ONE, "never": None} + + def test_a_future_day_is_resolved_too(self) -> None: + assert first_credits((PIANO,), _DAY + _ONE) == {"piano": None} + + def test_history_is_never_re_resolved(self) -> None: + assert first_credits((PIANO,), _DAY - _ONE) is None + + def test_an_earned_time_without_maturity_says_so( + self, monkeypatch: pytest.MonkeyPatch, caplog: pytest.LogCaptureFixture + ) -> None: + monkeypatch.delattr(earned_time, "maturity") + _earned._warn_no_maturity.cache_clear() + with caplog.at_level(logging.WARNING): + assert first_credits((PIANO,), _DAY) is None + _earned._warn_no_maturity.cache_clear() + assert "no maturity" in caplog.text + + +class TestTheBase: + """``base_minutes`` and the derived target agree on who is penalised.""" + + @pytest.mark.usefixtures("today") + def test_only_a_gate_that_paid_out_is_penalised( + self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path + ) -> None: + """Never paid out: free. Paid yesterday: cut. Paid today: from tomorrow.""" + with signing_key(tmp_path): + without = base_minutes(_DAY) + register(monkeypatch, PIANO) + assert base_minutes(_DAY) == without + assert gather(_DAY, None).resolution.base.shutdown_minutes == without + _paid(_DAY - _ONE) + assert base_minutes(_DAY) == without - PIANO.shutdown_minutes + _paid(_DAY) + assert base_minutes(_DAY) == without + + @pytest.mark.usefixtures("piano") + def test_a_past_day_keeps_its_penalty_from_base(self) -> None: + """History: priced on ``penalty_from`` alone, as it was enforced.""" + later = (_DAY + _ONE).isoformat() + strict = earned_time.base_for(_DAY, registry(_DAY)).shutdown_minutes + with patch.object(_earned, "today_str", return_value=later): + assert base_minutes(_DAY) == strict + + +@pytest.mark.usefixtures("piano") +class TestDerive: + def test_first_credits_reach_the_resolution(self) -> None: + answers = {e.name: False for e in registry(_DAY)} + lenient = derive(DayInputs(_DAY, answers, first_credits={"piano": None})) + strict = derive(DayInputs(_DAY, answers)) + assert lenient.resolution.base.shutdown_minutes == ( + strict.resolution.base.shutdown_minutes + PIANO.shutdown_minutes + ) diff --git a/screen_locker/tests/test_grace_floor.py b/screen_locker/tests/test_grace_floor.py index 6ac43d1..b995141 100644 --- a/screen_locker/tests/test_grace_floor.py +++ b/screen_locker/tests/test_grace_floor.py @@ -84,7 +84,7 @@ class TestWhenTheDayStarted: patch.object(_grace_floor, "first_credit_time", return_value=at("00:00")), ): assert first_done_at(_DAY, None) == at("00:00") - with patch.object(_grace_floor, "flat_earners", return_value=()): + with patch.object(_grace_floor, "gate_earners", return_value=()): assert first_done_at(_DAY, None, rest_day=True) is None def test_without_a_workout_ledger_only_the_log_answers(self) -> None: diff --git a/screen_locker/tests/test_shutdown_flat_earners.py b/screen_locker/tests/test_shutdown_flat_earners.py index dd144b6..b66bd15 100644 --- a/screen_locker/tests/test_shutdown_flat_earners.py +++ b/screen_locker/tests/test_shutdown_flat_earners.py @@ -158,8 +158,10 @@ class TestRealLedgers: mixin = _mixin() with signing_key(tmp_path): assert reset_to_base_if_new_day(state, mixin) is True + # Under the same key: the base reads the ledgers too (maturity). + base = base_minutes() mixin._write_shutdown_config.assert_called_once_with( - base_minutes() + 120, base_minutes() + 120, 300, restore=True + base + 120, base + 120, 300, restore=True ) assert json.loads(state.read_text()) == { "last_reset_date": today_str(), diff --git a/screen_locker/tests/test_tutor_cutover.py b/screen_locker/tests/test_tutor_cutover.py new file mode 100644 index 0000000..66d57d6 --- /dev/null +++ b/screen_locker/tests/test_tutor_cutover.py @@ -0,0 +1,115 @@ +"""The Automation tutor on a simulated cutover: penalty start and grace floor. + +``earned_time.TUTOR_FROM`` is a far-future sentinel, so the cutover is +simulated on a fixed day: the registry's cutover (bound by value in +``_registry`` and ``_ladder``) and the tutor's own ``penalty_from`` move to +:data:`_CUTOVER`. Nothing is written outside the suite's redirected home. +""" + +from __future__ import annotations + +from dataclasses import replace +from datetime import date, datetime, time +from typing import TYPE_CHECKING, Any +from unittest.mock import patch + +import earned_time +import pytest + +from screen_locker import _earned +from screen_locker._earned import first_credits, registry +from screen_locker._grace_floor import first_done_at +from screen_locker._shutdown_target import DayInputs, derive +from screen_locker.tests._earned_fixtures import signed, signing_key, write_ledger + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_CUTOVER = date(2026, 10, 10) + + +@pytest.fixture +def tutor(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> Iterator[Any]: + """The tutor registry from ``_CUTOVER``, its ledger signed, today pinned. + + Args: + monkeypatch: Moves the cutover. + tmp_path: Where the key is written. + + Yields: + The tutor earner, penalised from ``_CUTOVER``. + """ + item = replace(earned_time.AUTOMATION_TUTOR, penalty_from=_CUTOVER) + swapped = tuple( + item if e is earned_time.AUTOMATION_TUTOR else e + for e in earned_time.TUTOR_EARNERS + ) + monkeypatch.setattr(earned_time, "TUTOR_EARNERS", swapped) + monkeypatch.setattr("earned_time._registry.TUTOR_FROM", _CUTOVER) + monkeypatch.setattr("earned_time._ladder.TUTOR_FROM", _CUTOVER) + with ( + signing_key(tmp_path), + patch.object(_earned, "today_str", return_value=_CUTOVER.isoformat()), + ): + yield item + + +def _block(hhmm: str) -> dict[str, Any]: + hours, minutes = map(int, hhmm.split(":")) + ended = datetime.combine(_CUTOVER, time(hours, minutes)).astimezone() + return signed( + { + "kind": "credit", + "entry_id": f"s-b{hhmm}", + "day": _CUTOVER.isoformat(), + "detail": {"block": 1, "ended_at": ended.timestamp()}, + } + ) + + +def test_the_simulated_cutover_registers_the_tutor(tutor: Any) -> None: + assert tutor in registry(_CUTOVER) + assert tutor in _earned.gate_earners(_CUTOVER) + assert tutor not in _earned.flat_earners(_CUTOVER) + + +@pytest.mark.usefixtures("tutor") +class TestNeverCredited: + """A tutor that never paid out is not penalised once first_credits is wired.""" + + def test_the_map_says_never(self) -> None: + starts = first_credits(registry(_CUTOVER), _CUTOVER) + assert starts is not None + assert starts["automation"] is None + + def test_no_tutor_penalty_on_the_gaming_base(self) -> None: + answers = {e.name: 0 for e in registry(_CUTOVER)} + starts = first_credits(registry(_CUTOVER), _CUTOVER) + wired = derive(DayInputs(_CUTOVER, answers, first_credits=starts)) + unwired = derive(DayInputs(_CUTOVER, answers)) + cut = earned_time.AUTOMATION_TUTOR.max_gaming_minutes + assert wired.resolution.base.gaming_minutes == ( + unwired.resolution.base.gaming_minutes + cut + ) + # KNOWN GAP (earned_time 0.6.0): on the sleep ladder the shutdown + # floor is the ceiling minus every registered earner's full pay and + # never asks whether a penalty is in force, so a never-credited tutor + # still costs its shutdown minutes. A fix there flips this assertion. + assert wired.resolution.base.shutdown_minutes == ( + unwired.resolution.base.shutdown_minutes + ) + + +@pytest.mark.usefixtures("tutor") +class TestTheGraceFloor: + """A tutor block is a first task like any other.""" + + def test_a_tutor_block_starts_the_floor(self, tutor: Any) -> None: + write_ledger(tutor, [_block("18:55"), _block("19:10")]) + expected = datetime.combine(_CUTOVER, time(18, 55)).astimezone() + assert first_done_at(_CUTOVER, None) == expected.timestamp() + + def test_no_block_no_floor(self, tutor: Any) -> None: + write_ledger(tutor, []) + assert first_done_at(_CUTOVER, None) is None