earned_time 0.6.0: per-day registry consumer, sleep-ladder date-proof tests, pin v0.6.0
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Krzysztof Rudnicki 2026-10-09 21:36:53 +02:00
parent 19ccad6978
commit bbc14ec04c
27 changed files with 546 additions and 63 deletions

View File

@ -134,7 +134,7 @@ repos:
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays
- mcp>=2,<3
- pydantic
@ -155,7 +155,7 @@ repos:
# than merely an old one.
- gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
- crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time
- earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time
- freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays
- mcp>=2,<3
- pydantic

View File

@ -10,7 +10,7 @@ dependencies = [
"gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock",
"crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync",
"freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time",
"earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time",
]
[project.scripts]

View File

@ -4,7 +4,7 @@ bandit==1.9.4
codespell==2.4.3
coverage==7.16.2
crdt-sync @ git+https://github.com/kuhyx/utils@crdt-sync-v0.9.2#subdirectory=crdt-sync
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.4.0#subdirectory=earned_time
earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time
freedays @ git+https://github.com/kuhyx/utils@freedays-v0.3.1#subdirectory=freedays
gatelock @ git+https://github.com/kuhyx/utils@gatelock-v0.9.0#subdirectory=gatelock
mcp==2.3.0

View File

@ -16,6 +16,7 @@ midnight, so a 30-minute earner is applied exactly.
from __future__ import annotations
from datetime import datetime
import functools
import logging
from pathlib import Path
@ -26,7 +27,7 @@ import earned_time
from screen_locker._constants import HMAC_KEY_FILE
if TYPE_CHECKING:
from datetime import date, datetime
from datetime import date
_logger: Final = logging.getLogger(__name__)
@ -61,11 +62,40 @@ def ledger_file(earner: earned_time.Earner) -> Path:
return LEDGER_HOME / earner.ledger
def flat_earners() -> tuple[earned_time.Earner, ...]:
"""The once-per-day, ledger-backed earners (the workout is counted here)."""
return tuple(
e for e in earned_time.EARNERS if e.kind == "flat" and e.ledger is not None
)
def registry(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""The earners in force on ``day`` (default today).
``earners_for`` (earned_time >= 0.5) switches the registry on
``TUTOR_FROM``; an older earned_time has one static registry.
"""
fn = getattr(earned_time, "earners_for", None)
return tuple(earned_time.EARNERS if fn is None else fn(day))
def is_gate(earner: earned_time.Earner) -> bool:
"""A ledger-backed earner whose gate publishes credits (not the workout).
The workout has a ledger too, but screen-locker writes it and
``WorkoutCreditMixin`` applies it.
"""
return earner.ledger is not None and earner.name != earned_time.WORKOUT.name
def flat_earners(day: date | None = None) -> tuple[earned_time.Earner, ...]:
"""The once-per-day, ledger-backed earners in force on ``day``."""
return tuple(e for e in registry(day) if e.kind == "flat" and 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))
def watched_earners() -> tuple[earned_time.Earner, ...]:
"""Every gate earner of every registry, so a cutover misses no ledger."""
fn = getattr(earned_time, "all_earners", None)
pool = earned_time.EARNERS if fn is None else fn()
return tuple(e for e in pool if is_gate(e))
def earned_today(
@ -75,20 +105,45 @@ def earned_today(
return earned_time.done_today(earner, ledger_file(earner), HMAC_KEY_FILE, now=now)
def earned_units(earner: earned_time.Earner, day: date) -> int | None:
"""How many of a counted earner's units count for ``day``; ``None`` if unknown."""
return earned_time.credit_units(earner, ledger_file(earner), HMAC_KEY_FILE, day)
def gate_answers(day: date | None = None) -> dict[str, int | bool | None]:
"""Every gate earner's answer for ``day`` (default today), unknown logged.
Flat earners answer "done today"; counted ones (the tutor) their units.
On an earned_time without counted gates this is exactly
:func:`flat_answers`.
"""
target = day or datetime.now().astimezone().date()
answers: dict[str, int | bool | None] = dict(flat_answers())
for earner in counted_gate_earners(target):
units = earned_units(earner, target)
if units is None:
_warn_unknown(earner)
answers[earner.name] = units
return answers
def flat_answers() -> dict[str, bool | None]:
"""Today's answer from every flat earner, an unknown one logged."""
answers: dict[str, bool | None] = {}
for earner in flat_earners():
answer = earned_today(earner)
if answer is None:
_logger.warning(
"%s state could not be checked; no shutdown time for it",
earner.label,
)
_warn_unknown(earner)
answers[earner.name] = answer
return answers
def _warn_unknown(earner: earned_time.Earner) -> None:
_logger.warning(
"%s state could not be checked; no shutdown time for it", earner.label
)
# The day-aware ladder API (earned_time >= 0.4, from 2026-10-10). Looked up at
# call time so the installed 0.3.0 -- which has none of it -- still runs, on
# the raw fields it always used.

View File

@ -27,7 +27,7 @@ import subprocess
import sys
from typing import Final
from screen_locker._earned import flat_earners, ledger_file
from screen_locker._earned import ledger_file, watched_earners
__all__ = ["PATH_UNIT_NAME", "USER_UNIT_DIR", "render_path_unit", "sync_path_unit"]
@ -51,8 +51,15 @@ WantedBy=default.target
def render_path_unit() -> str:
"""The unit text: one ``PathChanged=`` per flat earner's ledger."""
watches = "".join(f"PathChanged={ledger_file(e)}\n" for e in flat_earners())
"""The unit text: one ``PathChanged=`` per gate earner's ledger.
Every registry's gates, not just today's (``watched_earners``), so a
dated cutover (the Automation tutor's ``TUTOR_FROM``) needs no re-render
on the day. The header keeps saying "flat": changing it would rewrite the
installed unit for no behavioural change.
"""
ledgers = dict.fromkeys(ledger_file(e) for e in watched_earners())
watches = "".join(f"PathChanged={path}\n" for path in ledgers)
return _HEADER + watches + _FOOTER

View File

@ -0,0 +1,101 @@
"""Live shutdown credit for counted gate earners: paid per unit, as it lands.
A flat earner (LeetCode, reading) pays once a day, stamped
``<name>_bonus_date``. A counted gate earner -- the Automation tutor, one
signed ledger row per verified 15-minute block -- pays per unit, so its stamp
is ``<name>_bonus_units``: the day, and how many units the config already
holds. The live pass pays only the difference, priced by
``Earner.shutdown_for`` so the uneven rung split (13/13/12/12) and the
``max_units`` cap come from the registry, never from here.
The workout is counted too, but screen-locker owns its log and
``WorkoutCreditMixin`` applies it; it is never in this pass.
State I/O stays in :mod:`screen_locker._shutdown_base`; these functions read
and update the state dict it hands over.
"""
from __future__ import annotations
from datetime import date
import logging
from typing import TYPE_CHECKING, Any
from screen_locker._earned import earned_units, is_gate, span
if TYPE_CHECKING:
from collections.abc import Callable, Iterable
import earned_time
_logger = logging.getLogger(__name__)
def date_stamp(earner: earned_time.Earner) -> str:
"""The once-per-day key of a flat earner's live pass in the state file."""
return f"{earner.name}_bonus_date"
def units_stamp(earner: earned_time.Earner) -> str:
"""The per-day key of ``earner``'s applied units in the state file."""
return f"{earner.name}_bonus_units"
def applied_units(state: dict[str, Any], earner: earned_time.Earner, today: str) -> int:
"""Units of ``earner`` the config already holds today (0 if none stamped)."""
raw = state.get(units_stamp(earner))
if not isinstance(raw, dict) or raw.get("date") != today:
return 0
units = raw.get("units")
return units if isinstance(units, int) and units > 0 else 0
def reset_stamps(terms: Iterable[earned_time.Term], today: str) -> dict[str, object]:
"""The stamps for what a daily reset already wrote into the config.
A flat earner that paid is stamped with the date, a counted gate with
its units, so the live pass does not add either a second time today.
"""
stamps: dict[str, object] = {}
for t in terms:
if t.earner.kind == "flat" and t.shutdown_minutes:
stamps[date_stamp(t.earner)] = today
elif t.earner.kind == "counted" and is_gate(t.earner) and t.answer:
stamps[units_stamp(t.earner)] = {"date": today, "units": t.answer}
return stamps
def apply_counted_bonus(
state: dict[str, Any],
adjust: Callable[[int], bool],
earner: earned_time.Earner,
today: str,
) -> bool:
"""Push shutdown later by ``earner``'s units credited since the last pass.
``adjust`` is the mixin's ``_adjust_shutdown_time_by``. Updates ``state``
(the caller saves it) and returns True when it changed. An unreadable
ledger or key earns nothing and is logged.
"""
day = date.fromisoformat(today)
units = earned_units(earner, day)
if units is None:
_logger.warning(
"%s state could not be checked; no shutdown time for it", earner.label
)
return False
done = applied_units(state, earner, today)
if units <= done:
return False
minutes = earner.shutdown_for(units, day) - earner.shutdown_for(done, day)
if minutes > 0 and not adjust(minutes):
_logger.warning("%s bonus: failed to write shutdown config.", earner.label)
return False
state[units_stamp(earner)] = {"date": today, "units": units}
_logger.info(
"%s bonus: %d unit(s) today, +%s shutdown time.",
earner.label,
units,
span(minutes),
)
return True

View File

@ -4,12 +4,10 @@ On each new calendar day the shutdown config is reset to the day's base so
that the day's bonuses always layer on top of a known floor rather than
accumulating indefinitely across days.
The base and every bonus come from the shared earner registry
(``earned_time``, see :mod:`screen_locker._earned`): 20:00 before any penalty,
minus each earner's cut once its ``penalty_from`` day arrives (book-guard's
reading hour, from 2026-10-01). Nothing here is state. The base used to be
persisted in the state file and read back on every reset, which made the
code's default dead; the file now carries only date stamps.
The base and every bonus come from the shared earner registry in force that
day (``earned_time``, see :mod:`screen_locker._earned`). Nothing here is
state: the file carries only stamps (``<name>_bonus_date`` for a flat
earner, ``<name>_bonus_units`` for a counted gate, :mod:`._gate_bonus`).
The reset re-derives what today has ALREADY earned and writes base plus that
in one go: the workout hours from today's log and every flat earner's hour
@ -20,8 +18,8 @@ followed -- on 2026-09-13 that left the bar at 21:00 with a counted workout on
disk. Any bonus source that is not a term of this derivation has that bug;
registering it in ``earned_time`` makes it one.
The sick-day state file is cleared on reset so the sick-restore path cannot
overwrite the fresh base when it runs later in the same startup.
The reset clears the sick-day state file, so sick-restore cannot overwrite
the fresh base later in the same startup.
"""
from __future__ import annotations
@ -36,12 +34,15 @@ import earned_time
from screen_locker._bonus_lock import bonus_lock
from screen_locker._day import today_str
from screen_locker._earned import (
counted_gate_earners,
earned_today,
first_minutes,
flat_earners,
hhmm,
registry,
span,
)
from screen_locker._gate_bonus import apply_counted_bonus, date_stamp, reset_stamps
from screen_locker._grace_floor import save_lift
from screen_locker._log_io import load_workout_log
from screen_locker._shutdown_target import gather
@ -62,12 +63,7 @@ def base_minutes(day: date | None = None) -> int:
Every penalty in force is already taken off.
"""
target = day or datetime.now().astimezone().date()
return earned_time.base_for(target, earned_time.EARNERS).shutdown_minutes
def _stamp(earner: earned_time.Earner) -> str:
"""The once-per-day key of ``earner``'s live pass in the state file."""
return f"{earner.name}_bonus_date"
return earned_time.base_for(target, registry(target)).shutdown_minutes
def today_credit_count(log_file: Path, today: str) -> int:
@ -172,12 +168,10 @@ def _reset(
# Later additions are paid out of the grace lift first (see _grace_floor).
save_lift(derived.lift)
# 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.
# The bonuses the reset already included are stamped, so the live pass
# does not add them a second time today.
new_state: dict[str, Any] = {"last_reset_date": today}
for term in day.terms:
if term.earner.kind == "flat" and term.shutdown_minutes:
new_state[_stamp(term.earner)] = today
new_state.update(reset_stamps(day.terms, today))
_save_state(state_file, new_state)
earned = " + ".join(
@ -211,7 +205,7 @@ def _apply_flat_bonus(
"""
today = today_str()
state = _load_state(state_file)
stamp = _stamp(earner)
stamp = date_stamp(earner)
if state.get(stamp) == today:
return False
answer = earned_today(earner)
@ -235,11 +229,16 @@ def _apply_flat_bonus(
def apply_flat_bonuses_if_new(state_file: Path, mixin: object) -> None:
"""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`.
Then each counted gate's new units (:mod:`._gate_bonus`); the workout's
live credit is ``WorkoutCreditMixin``'s.
"""
with bonus_lock(state_file):
for earner in flat_earners():
_apply_flat_bonus(state_file, mixin, earner)
adjust = mixin._adjust_shutdown_time_by
for earner in counted_gate_earners():
state = _load_state(state_file)
if apply_counted_bonus(state, adjust, earner, today_str()):
_save_state(state_file, state)
# Last, so the floor compares against everything earned so far.
mixin._apply_grace_floor(getattr(mixin, "log_file", None))

View File

@ -19,7 +19,7 @@ from typing import TYPE_CHECKING
import earned_time
from screen_locker._earned import ceiling, flat_answers
from screen_locker._earned import ceiling, 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
@ -71,7 +71,8 @@ class DayInputs:
Attributes:
day: The day.
flat: Each flat earner's answer (``None`` = could not check).
flat: Each gate earner's answer: done (flat) or units (counted, the
tutor); ``None`` = could not check.
workout_credits: The log's distinct workout credits.
rest_day: Declared rest day.
first_done: Unix time the day's first earner was done, if any.
@ -79,7 +80,7 @@ class DayInputs:
"""
day: date
flat: Mapping[str, bool | None]
flat: Mapping[str, int | bool | None]
workout_credits: int = 0
rest_day: bool = False
first_done: float | None = None
@ -93,9 +94,9 @@ def derive(inputs: DayInputs) -> Target:
answers[earned_time.WORKOUT.name] = workout_units(
inputs.workout_credits, rest_day=inputs.rest_day
)
# The registry is passed explicitly: flat_answers iterated this same
# tuple, so the earners asked and the earners summed can never differ.
resolution = earned_time.resolve(answers, day, earned_time.EARNERS)
# 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))
grace = None if inputs.sick_day else grace_for(inputs.first_done, day)
minutes = resolution.shutdown_minutes
if grace is not None:
@ -124,7 +125,7 @@ def gather(day: date, log_file: Path | None) -> Target:
return derive(
DayInputs(
day=day,
flat=flat_answers(),
flat=gate_answers(day),
workout_credits=day_credit_count(log_file, day),
rest_day=rest_day,
first_done=first_done_at(day, log_file, rest_day=rest_day),

View File

@ -27,6 +27,8 @@ if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
import pytest
KEY: Final = b"test-key-bytes"
@ -37,7 +39,7 @@ def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
# A flat earner the registry does not have yet, for tests that register one
# (``monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))``)
# (``register(monkeypatch, EXTRA)``)
# to pin that nothing in screen-locker needs code for it.
EXTRA: Final = earned_time.Earner(
name="extra",
@ -49,6 +51,19 @@ EXTRA: Final = earned_time.Earner(
)
def register(monkeypatch: pytest.MonkeyPatch, *extra: earned_time.Earner) -> None:
"""Append stand-in earners to every registry, before and after the cutover.
The registry in force is per day (``earned_time.earners_for``: ``EARNERS``
before ``TUTOR_FROM``, ``TUTOR_EARNERS`` from it), so a stand-in patched
into ``EARNERS`` alone would vanish on a tutor day.
"""
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, *extra))
tutor = getattr(earned_time, "TUTOR_EARNERS", None)
if tutor is not None:
monkeypatch.setattr(earned_time, "TUTOR_EARNERS", (*tutor, *extra))
@contextmanager
def signing_key(tmp_path: Path, content: bytes = KEY) -> Iterator[Path]:
"""Write ``content`` as the HMAC key and point ``_earned`` at it."""

View File

@ -9,10 +9,14 @@ The two seams are genuinely different, which is why neither subsumes the
other. ``_isolate_home`` covers paths resolved at *call* time from
``Path.home()``; ``_no_free_days_by_default`` covers paths bound into module
constants at *import* time, which redirecting home afterwards cannot reach.
``pre_ladder`` is the one opt-in fixture here: it keeps a test off the real
calendar's ladder cutover, the date-dependent half of the same question.
"""
from __future__ import annotations
from datetime import date
from pathlib import Path
from typing import TYPE_CHECKING
from unittest.mock import patch
@ -108,3 +112,18 @@ def _no_desktop_notifications(tmp_path: Path) -> Iterator[None]:
),
):
yield
@pytest.fixture
def pre_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
"""Resolve every day on the pre-ladder rules (the first workout = +2h).
From ``earned_time.LADDER_FROM`` (2026-10-10) shutdown is read down from
sleep and each earner's minutes change, so a test pinning the old figures
would go red on that date by the calendar alone. ``on_ladder`` reads the
constant at call time and every ladder decision goes through it, so moving
it out of reach freezes the rules without faking "today" -- which comes
from several clocks, including the ledger windows the credit rows must hit.
Opt-in: tests that assert the ladder itself must keep the real cutover.
"""
monkeypatch.setattr("earned_time._ladder.LADDER_FROM", date.max)

View File

@ -12,6 +12,8 @@ import logging
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker import _credit_slot
from screen_locker._credit_slot import CreditSlot, credit_slot, locked_shutdown_state
from screen_locker.tests.conftest import create_locker
@ -19,8 +21,6 @@ from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
from pathlib import Path
import pytest
from screen_locker._workout_credit import WorkoutCreditResult
from screen_locker.screen_lock import ScreenLocker
@ -121,6 +121,7 @@ def _credit(locker: ScreenLocker, prior: list) -> tuple[WorkoutCreditResult, Mag
return result, notify
@pytest.mark.usefixtures("pre_ladder")
class TestSlotCreditMinutes:
"""The minutes each slot class actually moves the shutdown."""

View File

@ -24,11 +24,13 @@ from screen_locker._earned import (
flat_earners,
hhmm,
ledger_file,
registry,
span,
)
from screen_locker.tests._earned_fixtures import (
EXTRA,
credit,
register,
signing_key,
write_ledger,
)
@ -104,14 +106,14 @@ class TestLedgerFile:
class TestFlatEarners:
def test_registry_order_without_the_counted_workout(self) -> None:
flat = tuple(e for e in earned_time.EARNERS if e.kind == "flat")
flat = tuple(e for e in registry() if e.kind == "flat")
assert flat_earners() == flat
assert flat[:3] == (earned_time.LEETCODE, earned_time.READING, earned_time.ANKI)
assert flat[:2] == (earned_time.LEETCODE, earned_time.READING)
def test_a_newly_registered_earner_is_picked_up(
self, monkeypatch: pytest.MonkeyPatch
) -> None:
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
register(monkeypatch, EXTRA)
assert flat_earners()[-1] is EXTRA
def test_a_flat_earner_without_a_ledger_is_left_out(
@ -121,7 +123,7 @@ class TestFlatEarners:
unledgered = earned_time.Earner(
name="manual", label="manual", gaming_minutes=60, shutdown_minutes=60
)
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, unledgered))
register(monkeypatch, unledgered)
assert unledgered not in flat_earners()
def test_conftest_seeds_every_flat_earners_stamp(self) -> None:

View File

@ -11,7 +11,7 @@ import pytest
from screen_locker import _earner_units
from screen_locker._bonus_lock import bonus_lock
from screen_locker._earned import flat_earners, ledger_file
from screen_locker._earned import flat_earners, ledger_file, watched_earners
from screen_locker._shutdown_base import (
apply_flat_bonuses_if_new,
reset_to_base_if_new_day,
@ -38,6 +38,13 @@ class TestRenderPathUnit:
watched = [ln.removeprefix("PathChanged=") for ln in lines if "=" in ln]
expected = [str(ledger_file(e)) for e in flat_earners()]
assert [w for w in watched if w in expected] == expected
def test_one_path_changed_per_gate_ledger_of_every_registry(self) -> None:
"""Today's and the cutover's gates (the tutor), each ledger once."""
lines = _earner_units.render_path_unit().splitlines()
watched = [ln.removeprefix("PathChanged=") for ln in lines if "=" in ln]
expected = list(dict.fromkeys(str(ledger_file(e)) for e in watched_earners()))
assert [w for w in watched if w.startswith("/")] == expected
assert sum(ln.startswith("PathChanged=") for ln in lines) == len(expected)
def test_never_path_modified(self) -> None:

View File

@ -0,0 +1,237 @@
"""Tests for counted gate earners (the Automation tutor): paid per unit, live.
A stand-in counted gate (``BLOCKS``) is registered by patching
``earned_time.EARNERS``, the registry seam both the installed earned_time and
the tutor-cutover one (``earners_for``) read, so these hold on either.
"""
from __future__ import annotations
from datetime import date, datetime, time
import json
import logging
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
import earned_time
import pytest
from screen_locker._day import today_str
from screen_locker._earned import (
counted_gate_earners,
flat_earners,
gate_answers,
registry,
watched_earners,
)
from screen_locker._gate_bonus import applied_units, apply_counted_bonus, reset_stamps
from screen_locker._shutdown_base import (
apply_flat_bonuses_if_new,
base_minutes,
reset_to_base_if_new_day,
)
from screen_locker.tests._earned_fixtures import (
answering,
register,
signed,
signing_key,
write_ledger,
)
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
def _any_credit(row: dict[str, object], window: tuple[float, float]) -> bool:
del row, window
return True
# 10 minutes for the first unit, 5 for each further one, on any day: no rung.
BLOCKS = earned_time.Earner(
name="blocks",
label="Blocks",
gaming_minutes=15,
shutdown_minutes=10,
kind="counted",
extra_shutdown_minutes=5,
ledger=".local/share/blocks_guard/ledger.json",
match=_any_credit,
)
TODAY = date.fromisoformat(today_str())
def _block(number: int) -> dict[str, Any]:
return signed(
{
"kind": "credit",
"entry_id": f"s-b{number}",
"day": today_str(),
"detail": {"block": number},
}
)
def _minutes(units: int) -> int:
return BLOCKS.shutdown_for(units, TODAY)
@pytest.fixture(autouse=True)
def with_blocks(monkeypatch: pytest.MonkeyPatch) -> None:
register(monkeypatch, BLOCKS)
@pytest.fixture
def key(tmp_path: Path) -> Iterator[Path]:
with signing_key(tmp_path) as path:
yield path
@pytest.fixture
def flat_no() -> Iterator[None]:
with answering({}):
yield
def _mixin() -> MagicMock:
mixin = MagicMock()
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
class TestRegistry:
def test_counted_gates_are_apart_from_flat_and_the_workout(self) -> None:
assert counted_gate_earners()[-1] is BLOCKS
assert earned_time.WORKOUT not in counted_gate_earners()
assert BLOCKS not in flat_earners()
assert earned_time.WORKOUT not in watched_earners()
assert BLOCKS in watched_earners()
@pytest.mark.usefixtures("key", "flat_no")
class TestGateAnswers:
def test_units_alongside_flat_answers(self) -> None:
write_ledger(BLOCKS, [_block(1), _block(2)])
answers = gate_answers()
assert answers["blocks"] == 2
assert {e.name for e in flat_earners()} <= set(answers)
def test_unreadable_ledger_is_none_and_logged(
self, caplog: pytest.LogCaptureFixture
) -> None:
path = write_ledger(BLOCKS, [])
path.write_text("{not json", encoding="utf-8")
assert gate_answers(TODAY)["blocks"] is None
assert "Blocks state could not be checked" in caplog.text
@pytest.mark.usefixtures("key")
class TestApplyCountedBonus:
def test_pays_only_the_new_units(self) -> None:
state: dict[str, Any] = {}
adjust = MagicMock(return_value=True)
write_ledger(BLOCKS, [_block(1), _block(2)])
assert apply_counted_bonus(state, adjust, BLOCKS, today_str())
write_ledger(BLOCKS, [_block(1), _block(2), _block(3)])
assert apply_counted_bonus(state, adjust, BLOCKS, today_str())
assert not apply_counted_bonus(state, adjust, BLOCKS, today_str())
assert [c.args[0] for c in adjust.call_args_list] == [
_minutes(2),
_minutes(3) - _minutes(2),
]
assert state == {"blocks_bonus_units": {"date": today_str(), "units": 3}}
@pytest.mark.parametrize(
"stamp",
[{"date": "2000-01-01", "units": 9}, {"date": None}, "junk", {"units": "x"}],
)
def test_a_stale_or_broken_stamp_is_zero(self, stamp: object) -> None:
assert applied_units({"blocks_bonus_units": stamp}, BLOCKS, today_str()) == 0
def test_failed_write_keeps_the_stamp(
self, caplog: pytest.LogCaptureFixture
) -> None:
state: dict[str, Any] = {}
write_ledger(BLOCKS, [_block(1)])
adjust = MagicMock(return_value=False)
assert not apply_counted_bonus(state, adjust, BLOCKS, today_str())
assert state == {}
assert "Blocks bonus: failed to write" in caplog.text
def test_unknown_earns_nothing_and_says_so(
self, caplog: pytest.LogCaptureFixture
) -> None:
write_ledger(BLOCKS, []).write_text("[]", encoding="utf-8")
with caplog.at_level(logging.WARNING):
assert not apply_counted_bonus({}, MagicMock(), BLOCKS, today_str())
assert "could not be checked" in caplog.text
@pytest.mark.usefixtures("key", "flat_no")
class TestThroughTheShutdownBase:
def test_reset_stamps_flat_dates_and_counted_units(self) -> None:
answers = {"workout": 1, "leetcode": True, "blocks": 2}
terms = earned_time.resolve(answers, TODAY, registry(TODAY)).terms
stamps = reset_stamps(terms, today_str())
assert stamps == {
"leetcode_bonus_date": today_str(),
"blocks_bonus_units": {"date": today_str(), "units": 2},
}
def test_reset_includes_units_then_live_pass_adds_only_new(
self, tmp_path: Path
) -> None:
state = tmp_path / "state.json"
write_ledger(BLOCKS, [_block(1)])
mixin = _mixin()
assert reset_to_base_if_new_day(state, mixin) is True
written = mixin._write_shutdown_config.call_args.args[0]
assert written == base_minutes() + _minutes(1)
saved = json.loads(state.read_text())
assert saved["blocks_bonus_units"] == {"date": today_str(), "units": 1}
write_ledger(BLOCKS, [_block(1), _block(2)])
apply_flat_bonuses_if_new(state, mixin)
mixin._adjust_shutdown_time_by.assert_called_once_with(
_minutes(2) - _minutes(1)
)
units = json.loads(state.read_text())["blocks_bonus_units"]["units"]
assert units == 2
def test_live_pass_without_new_units_writes_nothing(self, tmp_path: Path) -> None:
state = tmp_path / "state.json"
write_ledger(BLOCKS, [])
mixin = _mixin()
apply_flat_bonuses_if_new(state, mixin)
mixin._adjust_shutdown_time_by.assert_not_called()
assert not state.exists()
@pytest.mark.usefixtures("key")
def test_the_real_tutor_rung_pays_13_13_12_12_then_nothing() -> None:
"""The registry's uneven split, through this pass, on the cutover day."""
tutor = getattr(earned_time, "AUTOMATION_TUTOR", None)
if tutor is None:
pytest.skip("installed earned_time predates the tutor (< 0.5)")
cutover = earned_time.TUTOR_FROM.isoformat()
state: dict[str, Any] = {}
adjust = MagicMock(return_value=True)
rows: list[dict[str, Any]] = []
for number in range(1, 6):
ended = datetime.combine(earned_time.TUTOR_FROM, time(18, number)).astimezone()
rows.append(
signed(
{
"kind": "credit",
"entry_id": f"s-b{number}",
"day": cutover,
"detail": {"block": number, "ended_at": ended.timestamp()},
}
)
)
write_ledger(tutor, rows)
apply_counted_bonus(state, adjust, tutor, cutover)
assert [c.args[0] for c in adjust.call_args_list] == [13, 13, 12, 12]
assert state["automation_bonus_units"] == {"date": cutover, "units": 5}

View File

@ -67,7 +67,7 @@ class TestWhenTheDayStarted:
tmp_path,
[{"workout_data": {"type": "manual_workout", "end_time": "19:30"}}],
)
times = {"anki": at("18:55"), "leetcode": at("23:00", _DAY - timedelta(1))}
times = {"reading": at("18:55"), "leetcode": at("23:00", _DAY - timedelta(1))}
with patch.object(
_grace_floor,
"first_credit_time",

View File

@ -154,6 +154,7 @@ class TestMainEntry:
assert locker.demo_mode is False
@pytest.mark.usefixtures("pre_ladder")
class TestAdjustShutdownTimeLater:
"""Tests for _adjust_shutdown_time_later method."""

View File

@ -7,6 +7,8 @@ import pathlib
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._log_mixin import RecordResult
from screen_locker.tests.conftest import create_locker
@ -25,6 +27,7 @@ if TYPE_CHECKING:
from pathlib import Path
@pytest.mark.usefixtures("pre_ladder")
class TestApplyWorkoutCredit:
"""Tests for ScreenLocker._apply_workout_credit under the multi-per-day rule.

View File

@ -13,6 +13,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._log_io import load_workout_log
from screen_locker.tests.conftest import create_locker
@ -113,6 +115,7 @@ class TestBackfillCreditCallback:
assert all(call.args[2] is callback for call in fill.call_args_list)
@pytest.mark.usefixtures("pre_ladder")
class TestSyncPassCreditsRunnerUp:
"""``_auto_fill_week_runnerup_bonus`` (the 15-minute timer path)."""

View File

@ -5,12 +5,15 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker.tests._unlock_helpers import setup_unlock
if TYPE_CHECKING:
from pathlib import Path
@pytest.mark.usefixtures("pre_ladder")
class TestUnlockScreenExtras:
"""unlock_screen's extra-workout bonus and streak display."""

View File

@ -13,6 +13,8 @@ import json
from typing import TYPE_CHECKING, Any
from unittest.mock import MagicMock
import pytest
from screen_locker._day import today_str
from screen_locker._shutdown_base import (
base_minutes,
@ -57,6 +59,7 @@ def _write_log(log_file: Path, day: str, entries: list[dict[str, Any]]) -> Path:
return log_file
@pytest.mark.usefixtures("pre_ladder")
class TestEarnedShutdownBonusMinutes:
"""First credit is worth 2h, every further one 1h."""
@ -124,6 +127,7 @@ class TestSyncedCopySharesCredit:
assert credit_key(_DAY, 0, entry, {"42": 5}) == ("manual_workout", f"{_DAY}#0")
@pytest.mark.usefixtures("pre_ladder")
class TestTodayCreditCount:
"""The log -> credit count the reset hands to the registry."""
@ -144,6 +148,7 @@ class TestTodayCreditCount:
assert today_credit_count(log, _DAY) == 1
@pytest.mark.usefixtures("pre_ladder")
class TestResetReplaysTodaysCredit:
"""reset_to_base_if_new_day writes base + earned, capped at the ceiling."""

View File

@ -16,6 +16,7 @@ from unittest.mock import MagicMock, patch
import earned_time
import pytest
from screen_locker import _earned
from screen_locker._day import today_str
from screen_locker._shutdown_base import (
apply_flat_bonuses_if_new,
@ -27,6 +28,7 @@ from screen_locker.tests._earned_fixtures import (
EXTRA,
answering,
credit,
register,
signing_key,
write_ledger,
)
@ -58,7 +60,7 @@ def answers() -> Iterator[dict[str, bool | None]]:
@pytest.fixture
def with_extra(monkeypatch: pytest.MonkeyPatch) -> None:
"""Register EXTRA after the existing earners, as a new gate would be."""
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, EXTRA))
register(monkeypatch, EXTRA)
class TestFlatBonuses:
@ -79,6 +81,7 @@ class TestFlatBonuses:
apply_flat_bonuses_if_new(state, mixin)
assert mixin._adjust_shutdown_time_by.call_count == 2
@pytest.mark.usefixtures("pre_ladder")
def test_only_reading_earned_applies_only_reading(
self, tmp_path: Path, answers: dict[str, bool | None]
) -> None:
@ -138,11 +141,12 @@ class TestANewlyRegisteredEarner:
penalty_from=cut,
)
before = (base_minutes(eve), base_minutes(cut))
monkeypatch.setattr(earned_time, "EARNERS", (*earned_time.EARNERS, penalised))
register(monkeypatch, penalised)
assert (base_minutes(eve), base_minutes(cut)) == (before[0], before[1] - 60)
class TestRealLedgers:
@pytest.mark.usefixtures("pre_ladder")
def test_signed_credits_on_disk_reach_the_reset(self, tmp_path: Path) -> None:
"""No mock between the ledgers and the written hour."""
# Pinned to local midnight: stamped "now", the grace floor (first
@ -193,10 +197,14 @@ class TestProjectionFollowsTheCut:
def base(day: date) -> int:
# From the sleep ladder (Sat 2026-10-10) the registry prices the
# day itself; before it, the cut from Tuesday holds.
return earned_time.base_for(day, earned_time.EARNERS).shutdown_minutes
# The registry in force that day (the tutor cutover switches it).
return earned_time.base_for(day, _earned.registry(day)).shutdown_minutes
week = [date(2026, 10, 5 + i) for i in range(7)]
assert [base(d) for d in week[1:5]] == [lowered] * 4
assert [base(d) for d in week[1:4]] == [lowered] * 3
# Fri 2026-10-09 the Anki waiver drops Tuesday's cuts again.
assert week[4] == earned_time.ANKI_WAIVED_FROM
assert base(week[4]) == 19 * 60
assert [d.minutes for d in shutdown.rest_of_week] == [19 * 60] + [
base(d) for d in week[1:]
]

View File

@ -53,6 +53,7 @@ def unsolved() -> Iterator[MagicMock]:
yield m
@pytest.mark.usefixtures("pre_ladder")
class TestResetIncludesTheHour:
def test_reset_writes_base_plus_leetcode(
self, tmp_path: Path, solved: MagicMock
@ -86,6 +87,7 @@ class TestResetIncludesTheHour:
mixin._adjust_shutdown_time_by.assert_not_called()
@pytest.mark.usefixtures("pre_ladder")
class TestLivePass:
def test_applies_once_and_stamps(self, tmp_path: Path, solved: MagicMock) -> None:
state = tmp_path / "state.json"

View File

@ -104,6 +104,7 @@ class TestLegacySickDayState:
assert self._load(tmp_path, state) is None
@pytest.mark.usefixtures("pre_ladder")
class TestMinutesReachTheHelperScript:
"""The helper receives HH:MM, so a half hour is not floored on the way."""

View File

@ -5,6 +5,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from screen_locker.tests.conftest import create_locker
if TYPE_CHECKING:
@ -154,6 +156,7 @@ class TestAdjustShutdownTimeEarlier:
assert locker._adjust_shutdown_time_earlier() is False
@pytest.mark.usefixtures("pre_ladder")
class TestAdjustShutdownTimeLater:
"""Tests for _adjust_shutdown_time_later method."""

View File

@ -17,7 +17,7 @@ import earned_time
import pytest
from screen_locker._day import today_str
from screen_locker._earned import flat_earners, hhmm
from screen_locker._earned import flat_earners, hhmm, registry
from screen_locker._shutdown_base import (
_apply_flat_bonus,
apply_flat_bonuses_if_new,
@ -69,17 +69,20 @@ class TestBaseHour:
From earned_time's sleep ladder (2026-10-10) the floor is the ceiling
minus every earner's first unit, not 20:00 minus the cuts, so the day
is priced by the registry and only the "stays lowered" part is ours.
The Anki waiver (``ANKI_WAIVED_FROM``) raises that floor to 19:50, so
"lowered" means below the uncut 20:00, not at or below 19:00.
"""
later = date(2030, 1, 1)
expected = earned_time.base_for(later, earned_time.EARNERS).shutdown_minutes
expected = earned_time.base_for(later, registry(later)).shutdown_minutes
assert base_minutes(later) == expected
assert expected <= 19 * 60
assert expected < base_minutes(date(2026, 9, 30))
def test_default_is_the_local_today(self) -> None:
assert base_minutes() == base_minutes(datetime.now().astimezone().date())
class TestResetIncludesReading:
@pytest.mark.usefixtures("pre_ladder")
def test_reset_writes_base_plus_reading_and_stamps_it(
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
) -> None:
@ -95,6 +98,7 @@ class TestResetIncludesReading:
"reading_bonus_date": today_str(),
}
@pytest.mark.usefixtures("pre_ladder")
def test_reset_with_both_flat_hours_stamps_both(
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
) -> None:
@ -147,6 +151,7 @@ class TestResetIncludesReading:
)
assert json.loads(state.read_text()) == {"last_reset_date": today_str()}
@pytest.mark.usefixtures("pre_ladder")
def test_reset_logs_every_term(
self,
tmp_path: Path,
@ -167,6 +172,7 @@ class TestResetIncludesReading:
class TestReadingLivePass:
@pytest.mark.usefixtures("pre_ladder")
def test_applies_once_and_stamps(
self, tmp_path: Path, answers: tuple[dict[str, bool | None], MagicMock]
) -> None:
@ -228,6 +234,7 @@ class TestReadingLivePass:
mixin._adjust_shutdown_time_by.assert_not_called()
assert not state.exists()
@pytest.mark.usefixtures("pre_ladder")
def test_applied_hour_is_logged(
self,
tmp_path: Path,

View File

@ -67,7 +67,7 @@ class TestTarget:
],
)
with (
patch("screen_locker._shutdown_target.flat_answers", return_value=_FLAT),
patch("screen_locker._shutdown_target.gate_answers", return_value=_FLAT),
patch.object(_grace_floor, "first_credit_time", return_value=None),
):
target = gather(_DAY, log)

View File

@ -4,11 +4,14 @@ from __future__ import annotations
from unittest.mock import MagicMock, patch
import pytest
from screen_locker._status_view_verify import (
_backfill_week_and_apply_bonus,
)
@pytest.mark.usefixtures("pre_ladder")
class TestBackfillWeekAndApplyBonus:
"""The week-scan fallback: backfill unlogged days, apply the earned bonus."""