mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
earned_time 0.6.0: maturity-aware penalty start (first_credits); grace floor covers every gate earner
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (flutter, flutter-analyze, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (mypy, mypy, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (pylint, pylint, ) (push) Waiting to run
pre-commit / pre-commit (${{ matrix.group }}) (rest, , mypy,pylint,flutter-analyze) (push) Waiting to run
Tests / test (push) Waiting to run
Tests / lock-popup-safety (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (0) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (1) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (2) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (3) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (4) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (5) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (6) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (7) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (8) (push) Waiting to run
Tests / flutter test (shard ${{ matrix.shard }}) (9) (push) Waiting to run
Tests / flutter-coverage (push) Blocked by required conditions
Tests / web-coverage (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
bbc14ec04c
commit
215f03ecfc
@ -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"
|
||||
)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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),
|
||||
)
|
||||
)
|
||||
|
||||
160
screen_locker/tests/test_first_credits.py
Normal file
160
screen_locker/tests/test_first_credits.py
Normal file
@ -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
|
||||
)
|
||||
@ -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:
|
||||
|
||||
@ -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(),
|
||||
|
||||
115
screen_locker/tests/test_tutor_cutover.py
Normal file
115
screen_locker/tests/test_tutor_cutover.py
Normal file
@ -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
|
||||
Loading…
Reference in New Issue
Block a user