mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
earned_time 0.6.1: tutor cutover 2026-10-10; new-gate rungs spare the shutdown floor
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
TUTOR_FROM = 2026-10-10 (the day after the deploy). The automation tutor keeps confirmed_on=None, so it stays "new". Ladder floor: a rung comes off the floor only for a pure bonus (no penalty_from) or an earner whose penalty has started -- the same _penalised() rule (first_credits -> penalty_start, confirmed_on fail-closed fallback, penalty_from without first_credits) the gaming base uses. Before this, a never-credited tutor would have lowered 2026-10-10's floor by its full 50-minute rung (19:50 -> 19:00) even with first_credits. Maturity can only raise time: new property test against the 0.6.0 floor formula. ruff 0.17.0 (dependency-freshness): CPY001 ignored -- personal repo, no copyright policy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WdfmsGMht9ypyvxGPmNF1V
This commit is contained in:
parent
6f4f739ed7
commit
fd6c772eaf
@ -67,7 +67,7 @@ repos:
|
||||
types: [python]
|
||||
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.16.7
|
||||
rev: v0.17.0
|
||||
hooks:
|
||||
- id: ruff
|
||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
||||
|
||||
@ -25,9 +25,11 @@ never a same-day cut):
|
||||
each earner's `shutdown_minutes` (a second workout +60), capped at 23:00.
|
||||
- **From `LADDER_FROM` — the sleep ladder.** The ceiling is eight hours of
|
||||
sleep before the alarm: `WAKE_MINUTES` (07:00) − 8 h = 23:00. The floor is
|
||||
that ceiling minus the sum of every registered earner's first-unit rung
|
||||
(`LADDER`, a `Rung` per earner name), so doing everything lands exactly on
|
||||
the ceiling:
|
||||
that ceiling minus the sum of every registered earner's rung (a `Rung` per
|
||||
earner name; all of a counted earner's capped units), so doing everything
|
||||
lands exactly on the ceiling. Since 0.6.1 `TUTOR_FROM == LADDER_FROM`, so
|
||||
every real ladder day uses `TUTOR_LADDER` (below); `LADDER`, the pre-tutor
|
||||
split, is never in force on a real day:
|
||||
|
||||
| done | shutdown |
|
||||
|---|---|
|
||||
@ -59,9 +61,10 @@ one signed row per verified 15-minute block, `tutor_match` on
|
||||
| each | +15 min | 13 / 13 / 12 / 12 |
|
||||
| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) |
|
||||
|
||||
The gaming base drops by the full 60 (`Earner.max_gaming_minutes`) and the
|
||||
ladder floor by the full 50, so nothing done is still 3 h / 19:00 and
|
||||
everything done is still 8 h / 23:00. Days before `TUTOR_FROM` resolve on
|
||||
Once its penalty has started, the gaming base drops by the full 60
|
||||
(`Earner.max_gaming_minutes`) and the ladder floor by the full 50, so nothing
|
||||
done is 3 h / 19:00 and everything done is still 8 h / 23:00. Until then
|
||||
(given `first_credits`, see below) it costs nothing: 4 h / 19:50. Days before `TUTOR_FROM` resolve on
|
||||
`EARNERS` exactly as before. Consumers must iterate `earners_for(day)`, never
|
||||
`EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an
|
||||
earner of another day's registry.
|
||||
@ -70,10 +73,10 @@ earner of another day's registry.
|
||||
credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until
|
||||
`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`:
|
||||
neither penalised nor paid. The base rises by exactly what the two could have
|
||||
paid back (nothing done: 4 h / 19:50 on a ladder day; everything done: still
|
||||
8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` stays a far-future
|
||||
sentinel (2099-01-01) until kuhy confirms a real tutor session credits a
|
||||
block; it is then set to the day after, never the deploy day.
|
||||
paid back (nothing done: 4 h / 19:00, pre-ladder; everything done: still
|
||||
8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` is 2026-10-10
|
||||
(0.6.1), the day after the deploy, never the deploy day; the waiver therefore
|
||||
covers 2026-10-09 only. The tutor has no `confirmed_on` yet, so it stays `new`.
|
||||
|
||||
## Gate maturity (0.6.0)
|
||||
|
||||
@ -97,8 +100,14 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
max(`penalty_from`, day after `confirmed_on`), so deleting or locking a
|
||||
ledger cannot lift a penalty. Pass `first_credits={name: Maturity.first_credit}`, computed
|
||||
over `earners_for(day)` (two earners share the name `automation`), to `resolve`
|
||||
/ `base_for` to apply it; omitted, every day resolves exactly as in 0.5.0.
|
||||
It can only delay a penalty, never add one.
|
||||
/ `base_for` to apply it; omitted, a penalty starts at `penalty_from`, as
|
||||
in 0.5.0. It can only delay a penalty, never add one.
|
||||
- **Ladder floor (0.6.1):** the same start gates the shutdown floor. A rung
|
||||
comes off the floor only for a pure bonus (no `penalty_from`: workout,
|
||||
LeetCode) or an earner whose penalty has started, so a gate that never paid
|
||||
out cannot lower it either (10-10, tutor never credited: 19:50, not 19:00).
|
||||
An unwired consumer (no `first_credits`) cuts the tutor from `penalty_from`:
|
||||
19:00 / 3 h.
|
||||
- **CLI:** `python -m earned_time maturity [--json] [--day YYYY-MM-DD]`
|
||||
prints one row per ledger-backed earner of every registry. It is the only
|
||||
code that resolves real paths (`~/<Earner.ledger>`,
|
||||
@ -164,11 +173,12 @@ on the day itself is logged and does not count.
|
||||
)
|
||||
```
|
||||
|
||||
Then give it a rung in `LADDER` (`PIANO.name: Rung(20)`), and, if its
|
||||
Then give it a rung in `TUTOR_LADDER` (`PIANO.name: Rung(20)`), and, if its
|
||||
matcher decides on an event stamp, an entry in `_match.CREDIT_STAMPS`.
|
||||
|
||||
On the ladder a new earner's rung comes out of the floor the day its pin
|
||||
lands (the ceiling is fixed): re-split the rungs and ship it the day the
|
||||
On the ladder a new earner's rung comes out of the floor from its
|
||||
penalty start (a pure bonus: the day its pin lands; the ceiling is fixed):
|
||||
re-split the rungs and ship it the day the
|
||||
gate can actually be satisfied.
|
||||
3. Bump the version, tag `earned-time-vX.Y.Z`, and bump the pin in both
|
||||
consumers. Neither needs code for a ledger-backed flat earner.
|
||||
@ -191,7 +201,7 @@ on the day itself is logged and does not count.
|
||||
## Install
|
||||
|
||||
```console
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.6.1#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
|
||||
@ -35,10 +35,12 @@ LADDER_FROM: Final = date(2026, 10, 10)
|
||||
# sleep before it, so moving the alarm moves the whole ladder with it.
|
||||
WAKE_MINUTES: Final = 7 * 60
|
||||
# From this day the Anki earner is retired and Automation is paid per tutor
|
||||
# block (``earned_time._policy.AUTOMATION_TUTOR``). A far-future sentinel
|
||||
# until kuhy confirms a real tutor session earns a block; then the day after
|
||||
# that confirmation -- never the deploy day itself (a same-day cut, 2026-09-26).
|
||||
TUTOR_FROM: Final = date(2099, 1, 1)
|
||||
# block (``earned_time._policy.AUTOMATION_TUTOR``). The day after the deploy
|
||||
# (0.6.1, 2026-10-09) -- never the deploy day itself (a same-day cut,
|
||||
# 2026-09-26). The tutor is still unconfirmed (no ``confirmed_on``), so given
|
||||
# ``first_credits`` it costs nothing -- neither gaming base nor ladder floor
|
||||
# -- until the day after it first pays out.
|
||||
TUTOR_FROM: Final = date(2026, 10, 10)
|
||||
# From this day until TUTOR_FROM the Anki and old Automation earners are
|
||||
# waived: neither penalised nor paid, since both are being retired and the
|
||||
# tutor that replaces them is not yet confirmed. Raise-only on every day (the
|
||||
|
||||
@ -203,8 +203,8 @@ AUTOMATION_TUTOR: Final = Earner(
|
||||
gaming_minutes=15,
|
||||
extra_gaming_minutes=15,
|
||||
max_units=4,
|
||||
# Pre-ladder values, unused (TUTOR_FROM is after LADDER_FROM); the ladder
|
||||
# pays 13/13/12/12 (``TUTOR_LADDER``).
|
||||
# Pre-ladder values, unused (TUTOR_FROM is not before LADDER_FROM); the
|
||||
# ladder pays 13/13/12/12 (``TUTOR_LADDER``).
|
||||
shutdown_minutes=13,
|
||||
extra_shutdown_minutes=13,
|
||||
kind="counted",
|
||||
|
||||
@ -14,8 +14,9 @@ all -- earns nothing: fail closed. The difference shows up only in
|
||||
**Maturity can only delay a penalty.** Given ``first_credits`` (each earner's
|
||||
first real credit, from :func:`earned_time.maturity`), a penalty starts no
|
||||
earlier than the day after the gate first paid out
|
||||
(:func:`~earned_time._maturity.penalty_start`). Without it every day resolves
|
||||
exactly as in 0.5.0.
|
||||
(:func:`~earned_time._maturity.penalty_start`). Without it a penalty starts at
|
||||
``penalty_from``, as in 0.5.0. The same start spares the ladder floor: a gate
|
||||
whose penalty has not started does not lower it (0.6.1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -129,23 +130,21 @@ def _penalised(
|
||||
return start is not None and day >= start
|
||||
|
||||
|
||||
def _shutdown_floor(
|
||||
day: date,
|
||||
earners: tuple[Earner, ...],
|
||||
first_credits: Mapping[str, date | None] | None = None,
|
||||
) -> int:
|
||||
"""Shutdown of a day that earned nothing.
|
||||
def _shutdown_floor(day: date, earners: tuple[Earner, ...], cut: list[Earner]) -> int:
|
||||
"""Shutdown of a day that earned nothing; ``cut``: the penalties in force.
|
||||
|
||||
On the ladder: the ceiling minus the most every registered earner can
|
||||
pay (all of a capped earner's units), so doing everything lands exactly
|
||||
on the ceiling. Before it: the old base minus the penalties in force.
|
||||
On the ladder: the ceiling minus the most each earner can pay (all of a
|
||||
capped earner's units), so doing everything lands exactly on the ceiling
|
||||
-- but only a pure bonus (no ``penalty_from``) or an earner in ``cut``: a
|
||||
gate whose penalty has not started cannot lower the floor (0.6.1). Before
|
||||
it: the old base minus the penalties in force.
|
||||
"""
|
||||
if on_ladder(day):
|
||||
rungs = (e for e in earners if e.penalty_from is None or e in cut)
|
||||
return shutdown_ceiling_for(day) - sum(
|
||||
e.shutdown_for(e.max_units or 1, day) for e in earners
|
||||
e.shutdown_for(e.max_units or 1, day) for e in rungs
|
||||
)
|
||||
penalised = [e for e in earners if _penalised(e, day, first_credits)]
|
||||
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in penalised)
|
||||
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in cut)
|
||||
|
||||
|
||||
def base_for(
|
||||
@ -159,8 +158,8 @@ def base_for(
|
||||
Gaming: every penalty in force, the most each earner can pay, taken off
|
||||
the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are
|
||||
derived separately so the ladder can never move a gaming minute.
|
||||
``first_credits`` (per earner name) delays each penalty to
|
||||
:func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
|
||||
``first_credits`` (per earner name) delays each penalty, and the ladder
|
||||
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
|
||||
"""
|
||||
target = day or _today()
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
@ -168,7 +167,7 @@ def base_for(
|
||||
return Base(
|
||||
gaming_minutes=GAMING_BASE_MINUTES
|
||||
- sum(e.max_gaming_minutes for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, registry, first_credits),
|
||||
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -34,6 +34,17 @@ FIRST = date(2026, 10, 10)
|
||||
NAMES = [e.name for e in EARNERS]
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _pre_tutor_ladder(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Keep the tutor cutover out of the way of ``LADDER``'s own arithmetic.
|
||||
|
||||
Since 0.6.1 ``TUTOR_FROM == LADDER_FROM``, so no real day resolves on
|
||||
``LADDER``; these tests pin the ladder mechanism on it regardless (the
|
||||
tutor's rungs: ``test_tutor.py``).
|
||||
"""
|
||||
monkeypatch.setattr(ladder, "TUTOR_FROM", date(2099, 1, 1))
|
||||
|
||||
|
||||
def _shutdown(day: date, **done: int) -> str:
|
||||
answers = {name: done.get(name, 0) for name in NAMES}
|
||||
minutes = resolve(answers, day, EARNERS).shutdown_minutes
|
||||
|
||||
148
earned_time/earned_time/tests/test_maturity_floor.py
Normal file
148
earned_time/earned_time/tests/test_maturity_floor.py
Normal file
@ -0,0 +1,148 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""A gate whose penalty has not started spares the ladder floor (0.6.1).
|
||||
|
||||
Maturity may only raise time: the floor is never below 0.6.0's (the ceiling
|
||||
minus every registered earner's rung), never above the ceiling, and passing
|
||||
``first_credits`` never lowers anything.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION_TUTOR,
|
||||
EARNERS,
|
||||
LADDER_FROM,
|
||||
TUTOR_FROM,
|
||||
WORKOUT,
|
||||
Earner,
|
||||
base_for,
|
||||
earners_for,
|
||||
resolve,
|
||||
shutdown_ceiling_for,
|
||||
)
|
||||
|
||||
DAY = timedelta(days=1)
|
||||
LADDER_DAYS = (LADDER_FROM, LADDER_FROM + DAY, LADDER_FROM + 30 * DAY)
|
||||
FIRSTS = ("missing", None, -5, 0, 3)
|
||||
CONFIRMED = (None, -5, 3)
|
||||
# What the real ledgers said on 2026-10-09: the tutor never paid out.
|
||||
REAL = {
|
||||
"workout": date(2026, 10, 1),
|
||||
"leetcode": date(2026, 8, 12),
|
||||
"reading": date(2026, 10, 2),
|
||||
"automation": None,
|
||||
}
|
||||
|
||||
|
||||
def _hhmm(minutes: int) -> str:
|
||||
return f"{minutes // 60:02d}:{minutes % 60:02d}"
|
||||
|
||||
|
||||
def _head_floor(day: date, registry: tuple[Earner, ...]) -> int:
|
||||
"""0.6.0's ladder floor: every registered rung, maturity ignored."""
|
||||
return shutdown_ceiling_for(day) - sum(
|
||||
e.shutdown_for(e.max_units or 1, day) for e in registry
|
||||
)
|
||||
|
||||
|
||||
def _anchor(item: Earner) -> date:
|
||||
return item.penalty_from or date(2026, 9, 1)
|
||||
|
||||
|
||||
def _registry(day: date, confirmed: int | None) -> tuple[Earner, ...]:
|
||||
return tuple(
|
||||
replace(
|
||||
e,
|
||||
confirmed_on=None if confirmed is None else _anchor(e) + confirmed * DAY,
|
||||
)
|
||||
for e in earners_for(day)
|
||||
)
|
||||
|
||||
|
||||
def _firsts(
|
||||
registry: tuple[Earner, ...], first: str | int | None
|
||||
) -> dict[str, date | None]:
|
||||
if first == "missing":
|
||||
return {}
|
||||
if first is None:
|
||||
return dict.fromkeys((e.name for e in registry), None)
|
||||
assert isinstance(first, int)
|
||||
return {e.name: _anchor(e) + first * DAY for e in registry}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", LADDER_DAYS)
|
||||
@pytest.mark.parametrize("first", FIRSTS)
|
||||
@pytest.mark.parametrize("confirmed", CONFIRMED)
|
||||
@pytest.mark.parametrize("blocks", range(5))
|
||||
@pytest.mark.parametrize("done", [0, 1])
|
||||
def test_the_floor_only_ever_rises(
|
||||
day: date, first: str | int | None, confirmed: int | None, blocks: int, done: int
|
||||
) -> None:
|
||||
registry = _registry(day, confirmed)
|
||||
answers = {e.name: done for e in registry} | {"automation": blocks}
|
||||
firsts = _firsts(registry, first)
|
||||
ceiling = shutdown_ceiling_for(day)
|
||||
head = _head_floor(day, registry)
|
||||
old = resolve(answers, day, registry)
|
||||
new = resolve(answers, day, registry, first_credits=firsts)
|
||||
earned = sum(t.shutdown_minutes for t in new.terms)
|
||||
assert head <= old.base.shutdown_minutes <= new.base.shutdown_minutes <= ceiling
|
||||
assert new.shutdown_minutes >= old.shutdown_minutes >= min(ceiling, head + earned)
|
||||
assert new.gaming_minutes >= old.gaming_minutes
|
||||
assert new.base.gaming_minutes >= old.base.gaming_minutes
|
||||
|
||||
|
||||
@pytest.mark.parametrize("day", LADDER_DAYS)
|
||||
@pytest.mark.parametrize("registry", [EARNERS, None])
|
||||
def test_omitting_first_credits_keeps_0_6_0_on_the_real_registries(
|
||||
day: date, registry: tuple[Earner, ...] | None
|
||||
) -> None:
|
||||
earners = earners_for(day) if registry is None else registry
|
||||
assert base_for(day, registry).shutdown_minutes == _head_floor(day, earners)
|
||||
|
||||
|
||||
def test_a_never_paid_tutor_spares_the_first_tutor_day() -> None:
|
||||
assert TUTOR_FROM == LADDER_FROM == date(2026, 10, 10)
|
||||
base = base_for(TUTOR_FROM, first_credits=REAL)
|
||||
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (240, "19:50")
|
||||
full = resolve(
|
||||
{"workout": 1, "leetcode": 1, "reading": 1, "automation": 4},
|
||||
TUTOR_FROM,
|
||||
first_credits=REAL,
|
||||
)
|
||||
assert (full.gaming_minutes, _hhmm(full.shutdown_minutes)) == (480, "23:00")
|
||||
# Unwired consumer (no first_credits): the tutor is cut from penalty_from.
|
||||
fallback = base_for(TUTOR_FROM)
|
||||
assert (fallback.gaming_minutes, _hhmm(fallback.shutdown_minutes)) == (
|
||||
180,
|
||||
"19:00",
|
||||
)
|
||||
|
||||
|
||||
def test_the_floor_waits_for_the_day_after_the_first_block() -> None:
|
||||
paid = REAL | {"automation": TUTOR_FROM}
|
||||
for day, gaming, floor in (
|
||||
(TUTOR_FROM, 240, "19:50"),
|
||||
(TUTOR_FROM + DAY, 180, "19:00"),
|
||||
):
|
||||
base = base_for(day, first_credits=paid)
|
||||
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (gaming, floor)
|
||||
|
||||
|
||||
def test_a_confirmed_tutor_with_no_credit_fails_closed_on_the_floor() -> None:
|
||||
confirmed = replace(AUTOMATION_TUTOR, confirmed_on=TUTOR_FROM - DAY)
|
||||
registry = (*earners_for(TUTOR_FROM)[:-1], confirmed)
|
||||
base = base_for(TUTOR_FROM, registry, first_credits=REAL)
|
||||
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (180, "19:00")
|
||||
|
||||
|
||||
def test_a_pure_bonus_is_always_on_the_ladder() -> None:
|
||||
assert WORKOUT.penalty_from is None
|
||||
never = base_for(TUTOR_FROM, (WORKOUT,), first_credits={"workout": None})
|
||||
assert never.shutdown_minutes == 23 * 60 - 110
|
||||
assert never.gaming_minutes == 300 # and never cut from gaming
|
||||
@ -12,6 +12,7 @@ from earned_time import (
|
||||
AUTOMATION_TUTOR,
|
||||
EARNERS,
|
||||
LADDER,
|
||||
LADDER_FROM,
|
||||
TUTOR_EARNERS,
|
||||
TUTOR_FROM,
|
||||
TUTOR_LADDER,
|
||||
@ -38,9 +39,11 @@ def _day(blocks: int, **others: int) -> tuple[int, str]:
|
||||
return day.gaming_minutes, _hhmm(day.shutdown_minutes)
|
||||
|
||||
|
||||
def test_tutor_from_is_after_the_ladder_and_not_today() -> None:
|
||||
assert date(2026, 10, 10) < TUTOR_FROM
|
||||
def test_tutor_from_is_the_day_after_the_deploy() -> None:
|
||||
assert date(2026, 10, 10) == TUTOR_FROM
|
||||
assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared
|
||||
assert LADDER_FROM <= TUTOR_FROM
|
||||
assert AUTOMATION_TUTOR.confirmed_on is None # no real session confirmed yet
|
||||
|
||||
|
||||
def test_registry_switches_on_tutor_from() -> None:
|
||||
@ -48,8 +51,9 @@ def test_registry_switches_on_tutor_from() -> None:
|
||||
assert earners_for(None) is earners_for(datetime.now().astimezone().date())
|
||||
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
|
||||
assert "anki" not in {e.name for e in TUTOR_EARNERS}
|
||||
assert ladder_for(EVE) is LADDER
|
||||
assert ladder_for(TUTOR_FROM) is TUTOR_LADDER
|
||||
assert ladder_for(EVE) is None # the eve is pre-ladder
|
||||
# TUTOR_FROM == LADDER_FROM: the first ladder day already has the tutor's.
|
||||
assert ladder_for(LADDER_FROM) is TUTOR_LADDER
|
||||
assert ladder_for(date(2026, 10, 1)) is None
|
||||
|
||||
|
||||
@ -68,6 +72,9 @@ def test_tutor_ladder_still_sums_to_the_old_one() -> None:
|
||||
|
||||
|
||||
def test_nothing_done_is_three_hours_and_19() -> None:
|
||||
# Without first_credits (an unwired consumer): the tutor is cut from
|
||||
# penalty_from; given them, a never-paid tutor costs nothing
|
||||
# (test_maturity_floor.py).
|
||||
assert _day(0) == (180, "19:00")
|
||||
assert base_for(TUTOR_FROM).gaming_minutes == 180
|
||||
|
||||
@ -105,9 +112,10 @@ def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None:
|
||||
|
||||
|
||||
def test_the_eve_resolves_as_before() -> None:
|
||||
# The eve is pre-ladder: 20:00 minus the cuts in force, plus the terms.
|
||||
eve = resolve({**OTHERS, "anki": 0, "automation": 0}, EVE, EARNERS)
|
||||
assert eve.base.gaming_minutes == 180
|
||||
assert (eve.gaming_minutes, _hhmm(eve.shutdown_minutes)) == (420, "22:10")
|
||||
assert (eve.gaming_minutes, _hhmm(eve.shutdown_minutes)) == (420, "22:00")
|
||||
done = resolve({**OTHERS, "anki": 1, "automation": 1}, EVE, EARNERS)
|
||||
assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00")
|
||||
assert done.term("automation").earner is AUTOMATION
|
||||
|
||||
@ -30,12 +30,15 @@ def test_the_waiver_sits_between_the_old_registry_and_the_tutor() -> None:
|
||||
|
||||
def test_nothing_done_is_raised_by_exactly_what_the_two_paid() -> None:
|
||||
zero = dict.fromkeys(NAMES, 0)
|
||||
# The first ladder day inside the waiver (the waiver began pre-ladder).
|
||||
assert ANKI_WAIVED_FROM <= LADDER_FROM < TUTOR_FROM
|
||||
gap = resolve(zero, LADDER_FROM)
|
||||
assert gap.base.gaming_minutes == 240
|
||||
assert gap.gaming_minutes == 240
|
||||
assert gap.shutdown_minutes == 23 * 60 - 190 # 19:50: workout+leetcode+reading
|
||||
# TUTOR_FROM == LADDER_FROM: the waiver's only day is pre-ladder; the first
|
||||
# ladder day's floor is in test_maturity_floor.py.
|
||||
assert ANKI_WAIVED_FROM < LADDER_FROM == TUTOR_FROM
|
||||
gap = resolve(zero, ANKI_WAIVED_FROM)
|
||||
unwaived = resolve(zero, ANKI_WAIVED_FROM, EARNERS)
|
||||
assert (gap.base.gaming_minutes, gap.gaming_minutes) == (240, 240)
|
||||
assert gap.shutdown_minutes == 19 * 60 # 20:00 minus reading's 60
|
||||
assert gap.gaming_minutes - unwaived.gaming_minutes == 30 + 30
|
||||
assert gap.shutdown_minutes - unwaived.shutdown_minutes == 30 + 30
|
||||
|
||||
|
||||
@pytest.mark.parametrize("workout", [0, 1])
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.6.0"
|
||||
version = "0.6.1"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
@ -25,6 +25,8 @@ ignore = [
|
||||
"COM812",
|
||||
"ISC001",
|
||||
"S603",
|
||||
# personal repo, no copyright policy (ruff 0.17 made CPY001 stable under select=ALL)
|
||||
"CPY001",
|
||||
]
|
||||
fixable = ["ALL"]
|
||||
unfixable = []
|
||||
|
||||
@ -7,4 +7,4 @@ pre-commit==4.6.2
|
||||
pylint==4.1.2
|
||||
pytest==9.1.1
|
||||
pytest-cov==7.1.0
|
||||
ruff==0.16.10
|
||||
ruff==0.17.0
|
||||
|
||||
@ -85,7 +85,7 @@ def _line(label: str, answers: dict[str, int], day: date) -> str:
|
||||
def main() -> None:
|
||||
"""Print the table."""
|
||||
eve = TUTOR_FROM - timedelta(days=1)
|
||||
_say(f"TUTOR_FROM = {TUTOR_FROM} (placeholder; set at go-live)")
|
||||
_say(f"TUTOR_FROM = {TUTOR_FROM} (no first_credits: an unwired consumer)")
|
||||
_say(f"{'case':<44} {'gaming':>15} shutdown")
|
||||
for done in (0, 1):
|
||||
answers = {**_OTHERS, "anki": done, "automation": done}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user