mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-12 08:51:00 +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]
|
types: [python]
|
||||||
|
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.16.7
|
rev: v0.17.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
args: [--fix, --unsafe-fixes, --exit-non-zero-on-fix, --show-fixes]
|
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.
|
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
|
- **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
|
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
|
that ceiling minus the sum of every registered earner's rung (a `Rung` per
|
||||||
(`LADDER`, a `Rung` per earner name), so doing everything lands exactly on
|
earner name; all of a counted earner's capped units), so doing everything
|
||||||
the ceiling:
|
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 |
|
| done | shutdown |
|
||||||
|---|---|
|
|---|---|
|
||||||
@ -59,9 +61,10 @@ one signed row per verified 15-minute block, `tutor_match` on
|
|||||||
| each | +15 min | 13 / 13 / 12 / 12 |
|
| each | +15 min | 13 / 13 / 12 / 12 |
|
||||||
| all 4 | +60 (Anki's 30 folded in) | +50 (Anki's 25 folded in) |
|
| 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
|
Once its penalty has started, the gaming base drops by the full 60
|
||||||
ladder floor by the full 50, so nothing done is still 3 h / 19:00 and
|
(`Earner.max_gaming_minutes`) and the ladder floor by the full 50, so nothing
|
||||||
everything done is still 8 h / 23:00. Days before `TUTOR_FROM` resolve on
|
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` exactly as before. Consumers must iterate `earners_for(day)`, never
|
||||||
`EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an
|
`EARNERS`; `resolve`/`base_for` default to it, and ignore answers for an
|
||||||
earner of another day's registry.
|
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
|
credit, and the tutor is not confirmed yet, so from `ANKI_WAIVED_FROM` until
|
||||||
`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`:
|
`TUTOR_FROM` the registry is `EARNERS` without `anki` and `automation`:
|
||||||
neither penalised nor paid. The base rises by exactly what the two could have
|
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
|
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` stays a far-future
|
8 h / 23:00), so it can only ever raise time. `TUTOR_FROM` is 2026-10-10
|
||||||
sentinel (2099-01-01) until kuhy confirms a real tutor session credits a
|
(0.6.1), the day after the deploy, never the deploy day; the waiver therefore
|
||||||
block; it is then set to the day after, never the deploy day.
|
covers 2026-10-09 only. The tutor has no `confirmed_on` yet, so it stays `new`.
|
||||||
|
|
||||||
## Gate maturity (0.6.0)
|
## 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
|
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
|
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`
|
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.
|
/ `base_for` to apply it; omitted, a penalty starts at `penalty_from`, as
|
||||||
It can only delay a penalty, never add one.
|
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]`
|
- **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
|
prints one row per ledger-backed earner of every registry. It is the only
|
||||||
code that resolves real paths (`~/<Earner.ledger>`,
|
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`.
|
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
|
On the ladder a new earner's rung comes out of the floor from its
|
||||||
lands (the ceiling is fixed): re-split the rungs and ship it the day the
|
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.
|
gate can actually be satisfied.
|
||||||
3. Bump the version, tag `earned-time-vX.Y.Z`, and bump the pin in both
|
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.
|
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
|
## Install
|
||||||
|
|
||||||
```console
|
```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
|
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.
|
# sleep before it, so moving the alarm moves the whole ladder with it.
|
||||||
WAKE_MINUTES: Final = 7 * 60
|
WAKE_MINUTES: Final = 7 * 60
|
||||||
# From this day the Anki earner is retired and Automation is paid per tutor
|
# From this day the Anki earner is retired and Automation is paid per tutor
|
||||||
# block (``earned_time._policy.AUTOMATION_TUTOR``). A far-future sentinel
|
# block (``earned_time._policy.AUTOMATION_TUTOR``). The day after the deploy
|
||||||
# until kuhy confirms a real tutor session earns a block; then the day after
|
# (0.6.1, 2026-10-09) -- never the deploy day itself (a same-day cut,
|
||||||
# that confirmation -- never the deploy day itself (a same-day cut, 2026-09-26).
|
# 2026-09-26). The tutor is still unconfirmed (no ``confirmed_on``), so given
|
||||||
TUTOR_FROM: Final = date(2099, 1, 1)
|
# ``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
|
# 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
|
# 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
|
# 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,
|
gaming_minutes=15,
|
||||||
extra_gaming_minutes=15,
|
extra_gaming_minutes=15,
|
||||||
max_units=4,
|
max_units=4,
|
||||||
# Pre-ladder values, unused (TUTOR_FROM is after LADDER_FROM); the ladder
|
# Pre-ladder values, unused (TUTOR_FROM is not before LADDER_FROM); the
|
||||||
# pays 13/13/12/12 (``TUTOR_LADDER``).
|
# ladder pays 13/13/12/12 (``TUTOR_LADDER``).
|
||||||
shutdown_minutes=13,
|
shutdown_minutes=13,
|
||||||
extra_shutdown_minutes=13,
|
extra_shutdown_minutes=13,
|
||||||
kind="counted",
|
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
|
**Maturity can only delay a penalty.** Given ``first_credits`` (each earner's
|
||||||
first real credit, from :func:`earned_time.maturity`), a penalty starts no
|
first real credit, from :func:`earned_time.maturity`), a penalty starts no
|
||||||
earlier than the day after the gate first paid out
|
earlier than the day after the gate first paid out
|
||||||
(:func:`~earned_time._maturity.penalty_start`). Without it every day resolves
|
(:func:`~earned_time._maturity.penalty_start`). Without it a penalty starts at
|
||||||
exactly as in 0.5.0.
|
``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
|
from __future__ import annotations
|
||||||
@ -129,23 +130,21 @@ def _penalised(
|
|||||||
return start is not None and day >= start
|
return start is not None and day >= start
|
||||||
|
|
||||||
|
|
||||||
def _shutdown_floor(
|
def _shutdown_floor(day: date, earners: tuple[Earner, ...], cut: list[Earner]) -> int:
|
||||||
day: date,
|
"""Shutdown of a day that earned nothing; ``cut``: the penalties in force.
|
||||||
earners: tuple[Earner, ...],
|
|
||||||
first_credits: Mapping[str, date | None] | None = None,
|
|
||||||
) -> int:
|
|
||||||
"""Shutdown of a day that earned nothing.
|
|
||||||
|
|
||||||
On the ladder: the ceiling minus the most every registered earner can
|
On the ladder: the ceiling minus the most each earner can pay (all of a
|
||||||
pay (all of a capped earner's units), so doing everything lands exactly
|
capped earner's units), so doing everything lands exactly on the ceiling
|
||||||
on the ceiling. Before it: the old base minus the penalties in force.
|
-- 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):
|
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(
|
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 cut)
|
||||||
return SHUTDOWN_BASE_MINUTES - sum(e.shutdown_minutes for e in penalised)
|
|
||||||
|
|
||||||
|
|
||||||
def base_for(
|
def base_for(
|
||||||
@ -159,8 +158,8 @@ def base_for(
|
|||||||
Gaming: every penalty in force, the most each earner can pay, taken off
|
Gaming: every penalty in force, the most each earner can pay, taken off
|
||||||
the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are
|
the gaming base. Shutdown: see :func:`_shutdown_floor` -- the two are
|
||||||
derived separately so the ladder can never move a gaming minute.
|
derived separately so the ladder can never move a gaming minute.
|
||||||
``first_credits`` (per earner name) delays each penalty to
|
``first_credits`` (per earner name) delays each penalty, and the ladder
|
||||||
:func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
|
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
|
||||||
"""
|
"""
|
||||||
target = day or _today()
|
target = day or _today()
|
||||||
registry = earners_for(target) if earners is None else earners
|
registry = earners_for(target) if earners is None else earners
|
||||||
@ -168,7 +167,7 @@ def base_for(
|
|||||||
return Base(
|
return Base(
|
||||||
gaming_minutes=GAMING_BASE_MINUTES
|
gaming_minutes=GAMING_BASE_MINUTES
|
||||||
- sum(e.max_gaming_minutes for e in penalised),
|
- 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]
|
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:
|
def _shutdown(day: date, **done: int) -> str:
|
||||||
answers = {name: done.get(name, 0) for name in NAMES}
|
answers = {name: done.get(name, 0) for name in NAMES}
|
||||||
minutes = resolve(answers, day, EARNERS).shutdown_minutes
|
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,
|
AUTOMATION_TUTOR,
|
||||||
EARNERS,
|
EARNERS,
|
||||||
LADDER,
|
LADDER,
|
||||||
|
LADDER_FROM,
|
||||||
TUTOR_EARNERS,
|
TUTOR_EARNERS,
|
||||||
TUTOR_FROM,
|
TUTOR_FROM,
|
||||||
TUTOR_LADDER,
|
TUTOR_LADDER,
|
||||||
@ -38,9 +39,11 @@ def _day(blocks: int, **others: int) -> tuple[int, str]:
|
|||||||
return day.gaming_minutes, _hhmm(day.shutdown_minutes)
|
return day.gaming_minutes, _hhmm(day.shutdown_minutes)
|
||||||
|
|
||||||
|
|
||||||
def test_tutor_from_is_after_the_ladder_and_not_today() -> None:
|
def test_tutor_from_is_the_day_after_the_deploy() -> None:
|
||||||
assert date(2026, 10, 10) < TUTOR_FROM
|
assert date(2026, 10, 10) == TUTOR_FROM
|
||||||
assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared
|
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:
|
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(None) is earners_for(datetime.now().astimezone().date())
|
||||||
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
|
assert earners_for(TUTOR_FROM) is TUTOR_EARNERS
|
||||||
assert "anki" not in {e.name for e in TUTOR_EARNERS}
|
assert "anki" not in {e.name for e in TUTOR_EARNERS}
|
||||||
assert ladder_for(EVE) is LADDER
|
assert ladder_for(EVE) is None # the eve is pre-ladder
|
||||||
assert ladder_for(TUTOR_FROM) is TUTOR_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
|
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:
|
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 _day(0) == (180, "19:00")
|
||||||
assert base_for(TUTOR_FROM).gaming_minutes == 180
|
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:
|
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)
|
eve = resolve({**OTHERS, "anki": 0, "automation": 0}, EVE, EARNERS)
|
||||||
assert eve.base.gaming_minutes == 180
|
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)
|
done = resolve({**OTHERS, "anki": 1, "automation": 1}, EVE, EARNERS)
|
||||||
assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00")
|
assert (done.gaming_minutes, _hhmm(done.shutdown_minutes)) == (480, "23:00")
|
||||||
assert done.term("automation").earner is AUTOMATION
|
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:
|
def test_nothing_done_is_raised_by_exactly_what_the_two_paid() -> None:
|
||||||
zero = dict.fromkeys(NAMES, 0)
|
zero = dict.fromkeys(NAMES, 0)
|
||||||
# The first ladder day inside the waiver (the waiver began pre-ladder).
|
# TUTOR_FROM == LADDER_FROM: the waiver's only day is pre-ladder; the first
|
||||||
assert ANKI_WAIVED_FROM <= LADDER_FROM < TUTOR_FROM
|
# ladder day's floor is in test_maturity_floor.py.
|
||||||
gap = resolve(zero, LADDER_FROM)
|
assert ANKI_WAIVED_FROM < LADDER_FROM == TUTOR_FROM
|
||||||
assert gap.base.gaming_minutes == 240
|
gap = resolve(zero, ANKI_WAIVED_FROM)
|
||||||
assert gap.gaming_minutes == 240
|
unwaived = resolve(zero, ANKI_WAIVED_FROM, EARNERS)
|
||||||
assert gap.shutdown_minutes == 23 * 60 - 190 # 19:50: workout+leetcode+reading
|
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])
|
@pytest.mark.parametrize("workout", [0, 1])
|
||||||
|
|||||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "earned-time"
|
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"
|
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||||
requires-python = ">=3.14"
|
requires-python = ">=3.14"
|
||||||
dependencies = []
|
dependencies = []
|
||||||
@ -25,6 +25,8 @@ ignore = [
|
|||||||
"COM812",
|
"COM812",
|
||||||
"ISC001",
|
"ISC001",
|
||||||
"S603",
|
"S603",
|
||||||
|
# personal repo, no copyright policy (ruff 0.17 made CPY001 stable under select=ALL)
|
||||||
|
"CPY001",
|
||||||
]
|
]
|
||||||
fixable = ["ALL"]
|
fixable = ["ALL"]
|
||||||
unfixable = []
|
unfixable = []
|
||||||
|
|||||||
@ -7,4 +7,4 @@ pre-commit==4.6.2
|
|||||||
pylint==4.1.2
|
pylint==4.1.2
|
||||||
pytest==9.1.1
|
pytest==9.1.1
|
||||||
pytest-cov==7.1.0
|
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:
|
def main() -> None:
|
||||||
"""Print the table."""
|
"""Print the table."""
|
||||||
eve = TUTOR_FROM - timedelta(days=1)
|
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")
|
_say(f"{'case':<44} {'gaming':>15} shutdown")
|
||||||
for done in (0, 1):
|
for done in (0, 1):
|
||||||
answers = {**_OTHERS, "anki": done, "automation": done}
|
answers = {**_OTHERS, "anki": done, "automation": done}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user