mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 19:07:17 +01:00
earned_time 0.9.0: tutor earner renamed AUTOMATION_TUTOR/automation -> TUTOR/tutor, ledger ~/.local/share/tutor
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
Rows carry no earner name, so only the ledger path moves; the tutor copies its data dir (old rows included). Pre-cutover AUTOMATION (anki quota), LADDER and the waiver are unchanged. Same minutes, cap, ladder, TUTOR_FROM and confirmed_on. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YADHKXzZhhePzQ2rNoXFcK
This commit is contained in:
parent
cd76eb0a95
commit
e9bb43b8e8
@ -51,9 +51,12 @@ ladder cannot move a gaming minute.
|
||||
**The tutor cutover (`TUTOR_FROM`, one constant in `_ladder.py`).** The
|
||||
registry is per day: `earners_for(day)` is `EARNERS` before it and
|
||||
`TUTOR_EARNERS` from it. From `TUTOR_FROM` Anki is retired (neither penalised
|
||||
nor paid) and `automation` is `AUTOMATION_TUTOR`, a counted earner on the
|
||||
Automation tutor's ledger (`~/.local/share/automation_tutor/ledger.json`,
|
||||
`tutor_match` on `detail.ended_at`). Since 0.8.0 it pays per **active
|
||||
nor paid) and the tutor is `TUTOR` (name `tutor`), a counted earner on the
|
||||
tutor's ledger (`~/.local/share/tutor/ledger.json`, `tutor_match` on
|
||||
`detail.ended_at`). Until 0.9.0 it was `AUTOMATION_TUTOR`, name `automation`,
|
||||
on `~/.local/share/automation_tutor/ledger.json`; rows carry no earner name,
|
||||
so the rename only moved the ledger (copied whole, old rows still pay).
|
||||
Since 0.8.0 it pays per **active
|
||||
minute** (a unit is one minute), at most 60 a day summed across sessions:
|
||||
|
||||
| units | gaming | shutdown rung |
|
||||
@ -79,7 +82,7 @@ per-matcher reader `_match.ROW_UNITS`):
|
||||
invalid is refused by `tutor_match` (logged) and pays nothing anywhere.
|
||||
- An `entry_id` pays once; rows repeated under one id pay the **smallest**
|
||||
of their counts, so a replay or a rewrite can never raise a credit.
|
||||
- What the writer (plc-lab `plc_lab/tutor/credit.py`) must guarantee: every
|
||||
- What the writer ([kuhyx/tutor](https://github.com/kuhyx/tutor) `tutor/credit.py`) must guarantee: every
|
||||
new row carries `detail.minutes`; one row per credited minute,
|
||||
`entry_id = f"{session_id}-m{n}"` with `n` the session's n-th credited
|
||||
active minute (deterministic, so a retry rewrites the same id; never the
|
||||
@ -105,7 +108,8 @@ neither penalised nor paid. The base rises by exactly what the two could have
|
||||
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`.
|
||||
covers 2026-10-09 only. The tutor was confirmed in 0.7.0 (`confirmed_on`
|
||||
2026-10-09), so its penalty bites from `TUTOR_FROM`.
|
||||
|
||||
## Gate maturity (0.6.0)
|
||||
|
||||
@ -128,7 +132,7 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
not penalised -- unless `confirmed_on` is set: then fail closed,
|
||||
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`
|
||||
over `earners_for(day)` (until 0.9.0 two earners shared the name `automation`), to `resolve`
|
||||
/ `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
|
||||
@ -232,7 +236,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.8.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.9.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
|
||||
@ -65,13 +65,13 @@ from earned_time._maturity import (
|
||||
from earned_time._policy import (
|
||||
ANKI,
|
||||
AUTOMATION,
|
||||
AUTOMATION_TUTOR,
|
||||
EARNERS,
|
||||
GAMING_BASE_MINUTES,
|
||||
GAMING_CEILING_MINUTES,
|
||||
LEETCODE,
|
||||
READING,
|
||||
SHUTDOWN_BASE_MINUTES,
|
||||
TUTOR,
|
||||
TUTOR_EARNERS,
|
||||
WORKOUT,
|
||||
Earner,
|
||||
@ -84,7 +84,6 @@ __all__ = [
|
||||
"ANKI",
|
||||
"ANKI_WAIVED_FROM",
|
||||
"AUTOMATION",
|
||||
"AUTOMATION_TUTOR",
|
||||
"EARNERS",
|
||||
"GAMING_BASE_MINUTES",
|
||||
"GAMING_CEILING_MINUTES",
|
||||
@ -96,6 +95,7 @@ __all__ = [
|
||||
"READING",
|
||||
"SHUTDOWN_BASE_MINUTES",
|
||||
"SHUTDOWN_CEILING_MINUTES",
|
||||
"TUTOR",
|
||||
"TUTOR_EARNERS",
|
||||
"TUTOR_FROM",
|
||||
"TUTOR_LADDER",
|
||||
|
||||
@ -53,8 +53,9 @@ def home() -> Path:
|
||||
def _gates(day: date) -> list[tuple[str, Earner, Maturity]]:
|
||||
"""Each ledger-backed earner of every registry, with its verdict on ``day``.
|
||||
|
||||
Two earners can share a name across registries (``automation`` before and
|
||||
after ``TUTOR_FROM``); those get their ledger's directory appended.
|
||||
Two earners may share a name across registries (``automation`` did before
|
||||
and after ``TUTOR_FROM`` until 0.9.0); those get their ledger's directory
|
||||
appended.
|
||||
"""
|
||||
earners = [e for e in all_earners() if e.ledger is not None]
|
||||
names = Counter(e.name for e in earners)
|
||||
|
||||
@ -9,7 +9,7 @@ frozen ceiling, so the switch never rewrites how an old day resolved. Gaming
|
||||
is not on the ladder.
|
||||
|
||||
From ``TUTOR_FROM`` the rungs are :data:`TUTOR_LADDER`: Anki is retired and
|
||||
its 25 minutes fold into the Automation tutor's, paid per active minute.
|
||||
its 25 minutes fold into the tutor's, paid per active minute.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -35,7 +35,7 @@ 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 by the
|
||||
# tutor (``earned_time._policy.AUTOMATION_TUTOR``: per 15-minute block until
|
||||
# tutor (``earned_time._policy.TUTOR``: per 15-minute block until
|
||||
# 0.8.0, per active minute since). The day after the deploy (0.6.1,
|
||||
# 2026-10-09) -- never the deploy day itself (a same-day cut, 2026-09-26).
|
||||
# Confirmed in 0.7.0 (``confirmed_on`` 2026-10-09, kuhy's call on
|
||||
@ -94,8 +94,9 @@ LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
||||
)
|
||||
|
||||
|
||||
# From ``TUTOR_FROM``: Anki's 25 folded into Automation's, which is paid per
|
||||
# active tutor minute (0.8.0; 0.7.0 paid 15 per 15-minute block). Each minute
|
||||
# From ``TUTOR_FROM``: Anki's and Automation's 25 each folded into the tutor's
|
||||
# rung (``tutor``, named ``automation`` until 0.9.0), paid per active minute
|
||||
# (0.8.0; 0.7.0 paid 15 per 15-minute block). Each minute
|
||||
# earns the minute it costs (the fairness rule), so the rungs sum to 250:
|
||||
# doing everything is still 23:00, doing nothing 18:50.
|
||||
TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
||||
@ -103,7 +104,7 @@ TUTOR_LADDER: Final[Mapping[str, Rung]] = MappingProxyType(
|
||||
"workout": Rung(110),
|
||||
"leetcode": Rung(50),
|
||||
"reading": Rung(30),
|
||||
"automation": Rung(1, extra=1),
|
||||
"tutor": Rung(1, extra=1),
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -18,7 +18,7 @@ From ``LADDER_FROM`` shutdown follows the sleep ladder instead
|
||||
(:mod:`earned_time._ladder`); gaming keeps the penalty derivation unchanged.
|
||||
|
||||
**The registry is per day** (:func:`earners_for`). From ``TUTOR_FROM`` Anki
|
||||
is retired -- neither penalised nor paid -- and Automation is the tutor's
|
||||
is retired -- neither penalised nor paid -- and the tutor (``TUTOR``) is the
|
||||
counted earner; every earlier day resolves on :data:`EARNERS` exactly as
|
||||
before, so the cutover never rewrites an old day.
|
||||
"""
|
||||
@ -182,7 +182,7 @@ ANKI: Final = Earner(
|
||||
)
|
||||
# Same gate, same row shape: anki-guard's ``automation`` quota (the Automation
|
||||
# deck only) writes its own ledger, so the Anki day matcher applies unchanged.
|
||||
# Days before ``TUTOR_FROM`` only; from then on see AUTOMATION_TUTOR.
|
||||
# Days before ``TUTOR_FROM`` only; from then on see TUTOR.
|
||||
AUTOMATION: Final = Earner(
|
||||
name="automation",
|
||||
label="Automation",
|
||||
@ -194,12 +194,14 @@ AUTOMATION: Final = Earner(
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
# From ``TUTOR_FROM``: the Automation tutor pays per active minute (0.8.0; a
|
||||
# 0.7.0 15-minute block row counts 15), 1 gaming minute each, up to 60 a day
|
||||
# summed across sessions. Anki's 30 is folded in, so the base drops by 60.
|
||||
AUTOMATION_TUTOR: Final = Earner(
|
||||
name="automation",
|
||||
label="Automation",
|
||||
# From ``TUTOR_FROM``: the tutor pays per active minute (0.8.0; a 0.7.0
|
||||
# 15-minute block row counts 15), 1 gaming minute each, up to 60 a day summed
|
||||
# across sessions. Anki's 30 is folded in, so the base drops by 60. Named
|
||||
# ``automation`` (``AUTOMATION_TUTOR``) until 0.9.0; rows carry no earner name,
|
||||
# so the rename only moved the ledger (copied over, old rows included).
|
||||
TUTOR: Final = Earner(
|
||||
name="tutor",
|
||||
label="Tutor",
|
||||
gaming_minutes=1,
|
||||
extra_gaming_minutes=1,
|
||||
max_units=60,
|
||||
@ -209,7 +211,7 @@ AUTOMATION_TUTOR: Final = Earner(
|
||||
extra_shutdown_minutes=1,
|
||||
kind="counted",
|
||||
penalty_from=TUTOR_FROM,
|
||||
ledger=".local/share/automation_tutor/ledger.json",
|
||||
ledger=".local/share/tutor/ledger.json",
|
||||
match=tutor_match,
|
||||
missing_ledger_is_no=True,
|
||||
# Confirmed by kuhy on 2026-10-10 before any block had paid out, dated the
|
||||
@ -227,12 +229,12 @@ EARNERS: Final[tuple[Earner, ...]] = (
|
||||
ANKI,
|
||||
AUTOMATION,
|
||||
)
|
||||
# From ``TUTOR_FROM``: Anki retired, Automation paid per tutor minute.
|
||||
# From ``TUTOR_FROM``: Anki and Automation retired, the tutor paid per minute.
|
||||
TUTOR_EARNERS: Final[tuple[Earner, ...]] = (
|
||||
WORKOUT,
|
||||
LEETCODE,
|
||||
READING,
|
||||
AUTOMATION_TUTOR,
|
||||
TUTOR,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
Before ``TUTOR_FROM`` every day resolves on :data:`~earned_time.EARNERS`,
|
||||
exactly as it always has; from it on :data:`~earned_time.TUTOR_EARNERS`
|
||||
(Anki retired, Automation paid per tutor block). The switch is a date, so it
|
||||
(Anki and Automation retired, the tutor paid per minute). The switch is a date, so it
|
||||
never rewrites how an old day resolved.
|
||||
"""
|
||||
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from datetime import date, datetime, time
|
||||
import json
|
||||
import runpy
|
||||
@ -55,8 +56,8 @@ def test_table(home: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"leetcode",
|
||||
"reading",
|
||||
"anki",
|
||||
"automation:anki_guard",
|
||||
"automation:automation_tutor",
|
||||
"automation",
|
||||
"tutor",
|
||||
}
|
||||
assert rows["leetcode"].split()[1:5] == ["maturing", "1", str(DAY), str(DAY)]
|
||||
assert "not in the registry on 2026-10-09" in rows["anki"]
|
||||
@ -94,7 +95,7 @@ def test_default_day_and_unregistered_earners(
|
||||
"leetcode",
|
||||
"workout",
|
||||
"reading",
|
||||
"automation",
|
||||
"tutor",
|
||||
]
|
||||
|
||||
|
||||
@ -129,3 +130,16 @@ def test_python_dash_m(
|
||||
runpy.run_module("earned_time", run_name="__main__")
|
||||
assert exit_info.value.code == 0
|
||||
assert json.loads(capsys.readouterr().out)["gates"]
|
||||
|
||||
|
||||
def test_a_name_shared_across_registries_gets_its_ledger_dir(
|
||||
home: Path, monkeypatch: pytest.MonkeyPatch, capsys: pytest.CaptureFixture[str]
|
||||
) -> None:
|
||||
# ``automation`` was both the anki quota and the tutor until 0.9.0.
|
||||
old = replace(LEETCODE, name="twin", ledger="a/l.json")
|
||||
new = replace(LEETCODE, name="twin", ledger="b/l.json")
|
||||
monkeypatch.setattr(earned_time, "EARNERS", (old,))
|
||||
monkeypatch.setattr(earned_time, "TUTOR_EARNERS", (new,))
|
||||
assert cli.main(["maturity", "--day", DAY.isoformat()]) == 0
|
||||
rows = [line.split()[0] for line in capsys.readouterr().out.splitlines()[2:-1]]
|
||||
assert rows == ["twin:a", "twin:b"]
|
||||
|
||||
@ -14,7 +14,7 @@ from datetime import date, timedelta
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION_TUTOR,
|
||||
TUTOR,
|
||||
EARNERS,
|
||||
LADDER_FROM,
|
||||
TUTOR_FROM,
|
||||
@ -35,7 +35,7 @@ REAL = {
|
||||
"workout": date(2026, 10, 1),
|
||||
"leetcode": date(2026, 8, 12),
|
||||
"reading": date(2026, 10, 2),
|
||||
"automation": None,
|
||||
"tutor": None,
|
||||
}
|
||||
|
||||
|
||||
@ -84,7 +84,7 @@ 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}
|
||||
answers = {e.name: done for e in registry} | {"tutor": blocks}
|
||||
firsts = _firsts(registry, first)
|
||||
ceiling = shutdown_ceiling_for(day)
|
||||
head = _head_floor(day, registry)
|
||||
@ -108,7 +108,7 @@ def test_omitting_first_credits_keeps_0_6_0_on_the_real_registries(
|
||||
|
||||
def _unconfirmed() -> tuple[Earner, ...]:
|
||||
"""The tutor day's registry as it was before 0.7.0 confirmed the tutor."""
|
||||
tutor = replace(AUTOMATION_TUTOR, confirmed_on=None)
|
||||
tutor = replace(TUTOR, confirmed_on=None)
|
||||
return (*earners_for(TUTOR_FROM)[:-1], tutor)
|
||||
|
||||
|
||||
@ -118,7 +118,7 @@ def test_a_never_paid_unconfirmed_tutor_spares_the_first_tutor_day() -> None:
|
||||
base = base_for(TUTOR_FROM, registry, first_credits=REAL)
|
||||
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (240, "19:50")
|
||||
full = resolve(
|
||||
{"workout": 1, "leetcode": 1, "reading": 1, "automation": 4},
|
||||
{"workout": 1, "leetcode": 1, "reading": 1, "tutor": 4},
|
||||
TUTOR_FROM,
|
||||
registry,
|
||||
first_credits=REAL,
|
||||
@ -133,7 +133,7 @@ def test_a_never_paid_unconfirmed_tutor_spares_the_first_tutor_day() -> None:
|
||||
|
||||
|
||||
def test_the_floor_waits_for_the_day_after_the_first_block() -> None:
|
||||
paid = REAL | {"automation": TUTOR_FROM}
|
||||
paid = REAL | {"tutor": TUTOR_FROM}
|
||||
for day, gaming, floor in (
|
||||
(TUTOR_FROM, 240, "19:50"),
|
||||
(TUTOR_FROM + DAY, 180, "18:50"),
|
||||
@ -145,11 +145,11 @@ def test_the_floor_waits_for_the_day_after_the_first_block() -> None:
|
||||
def test_the_confirmed_tutor_with_no_credit_fails_closed_on_the_floor() -> None:
|
||||
# 0.7.0: confirmed the eve, before any block paid out. The real ledgers
|
||||
# of 2026-10-10 give the 5 h base (180 + LeetCode's 60 + reading's 60).
|
||||
assert AUTOMATION_TUTOR.confirmed_on == TUTOR_FROM - DAY
|
||||
assert TUTOR.confirmed_on == TUTOR_FROM - DAY
|
||||
base = base_for(TUTOR_FROM, first_credits=REAL)
|
||||
assert (base.gaming_minutes, _hhmm(base.shutdown_minutes)) == (180, "18:50")
|
||||
day = resolve(
|
||||
{"workout": 0, "leetcode": 1, "reading": 1, "automation": 0},
|
||||
{"workout": 0, "leetcode": 1, "reading": 1, "tutor": 0},
|
||||
TUTOR_FROM,
|
||||
first_credits=REAL,
|
||||
)
|
||||
|
||||
@ -9,7 +9,7 @@ import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION,
|
||||
AUTOMATION_TUTOR,
|
||||
TUTOR,
|
||||
EARNERS,
|
||||
LADDER,
|
||||
LADDER_FROM,
|
||||
@ -35,7 +35,7 @@ def _hhmm(minutes: int) -> str:
|
||||
|
||||
|
||||
def _day(minutes: int, **others: int) -> tuple[int, str]:
|
||||
day = resolve({**others, "automation": minutes}, TUTOR_FROM)
|
||||
day = resolve({**others, "tutor": minutes}, TUTOR_FROM)
|
||||
return day.gaming_minutes, _hhmm(day.shutdown_minutes)
|
||||
|
||||
|
||||
@ -44,7 +44,7 @@ def test_tutor_from_is_the_day_after_the_deploy() -> None:
|
||||
assert date(2026, 10, 9) < TUTOR_FROM # never the day it was prepared
|
||||
assert LADDER_FROM <= TUTOR_FROM
|
||||
# Confirmed the eve, so the penalty bites on TUTOR_FROM itself.
|
||||
assert AUTOMATION_TUTOR.confirmed_on == EVE
|
||||
assert TUTOR.confirmed_on == EVE
|
||||
|
||||
|
||||
def test_registry_switches_on_tutor_from() -> None:
|
||||
@ -62,7 +62,7 @@ def test_all_earners_lists_each_once() -> None:
|
||||
names = [e.name for e in all_earners()]
|
||||
assert names.count("workout") == 1
|
||||
assert AUTOMATION in all_earners()
|
||||
assert AUTOMATION_TUTOR in all_earners()
|
||||
assert TUTOR in all_earners()
|
||||
|
||||
|
||||
def test_each_tutor_minute_earns_the_minute_it_costs() -> None:
|
||||
@ -72,11 +72,11 @@ def test_each_tutor_minute_earns_the_minute_it_costs() -> None:
|
||||
full = {
|
||||
e.name: TUTOR_LADDER[e.name].minutes(e.max_units or 1) for e in TUTOR_EARNERS
|
||||
}
|
||||
assert full == {"workout": 110, "leetcode": 50, "reading": 30, "automation": 60}
|
||||
assert full == {"workout": 110, "leetcode": 50, "reading": 30, "tutor": 60}
|
||||
assert sum(full.values()) == 250
|
||||
for n in range(61):
|
||||
assert AUTOMATION_TUTOR.shutdown_for(n, TUTOR_FROM) == n
|
||||
assert AUTOMATION_TUTOR.gaming_for(n) == n
|
||||
assert TUTOR.shutdown_for(n, TUTOR_FROM) == n
|
||||
assert TUTOR.gaming_for(n) == n
|
||||
|
||||
|
||||
def test_nothing_done_is_three_hours_and_18_50() -> None:
|
||||
@ -114,12 +114,12 @@ def test_partial_credit_without_the_others() -> None:
|
||||
|
||||
def test_minutes_past_60_are_clamped() -> None:
|
||||
assert _day(61, **OTHERS) == _day(60, **OTHERS) == (480, "23:00")
|
||||
term = resolve({"automation": 75}, TUTOR_FROM).term("automation")
|
||||
term = resolve({"tutor": 75}, TUTOR_FROM).term("tutor")
|
||||
assert (term.answer, term.gaming_minutes, term.shutdown_minutes) == (75, 60, 60)
|
||||
|
||||
|
||||
def test_anki_is_neither_penalised_nor_paid_from_tutor_from() -> None:
|
||||
with_anki = resolve({**OTHERS, "anki": 1, "automation": 60}, TUTOR_FROM)
|
||||
with_anki = resolve({**OTHERS, "anki": 1, "tutor": 60}, TUTOR_FROM)
|
||||
assert (with_anki.gaming_minutes, with_anki.shutdown_minutes) == (480, 23 * 60)
|
||||
assert "anki" not in {t.earner.name for t in with_anki.terms}
|
||||
|
||||
@ -145,18 +145,18 @@ def test_rung_steps_and_extras() -> None:
|
||||
assert rung.second == 13
|
||||
assert Rung(10, extra=5).minutes(3) == 20
|
||||
assert Rung(10, extra=5).second == 5
|
||||
assert extra_shutdown_minutes_for(AUTOMATION_TUTOR, TUTOR_FROM) == 1
|
||||
assert extra_shutdown_minutes_for(TUTOR, TUTOR_FROM) == 1
|
||||
|
||||
|
||||
def test_gaming_for_and_max() -> None:
|
||||
gaming = [AUTOMATION_TUTOR.gaming_for(n) for n in (-1, 0, 1, 15, 60, 90)]
|
||||
gaming = [TUTOR.gaming_for(n) for n in (-1, 0, 1, 15, 60, 90)]
|
||||
assert gaming == [0, 0, 1, 15, 60, 60]
|
||||
assert AUTOMATION_TUTOR.max_gaming_minutes == 60
|
||||
assert TUTOR.max_gaming_minutes == 60
|
||||
assert WORKOUT.max_gaming_minutes == WORKOUT.gaming_minutes
|
||||
assert WORKOUT.gaming_for(3) == WORKOUT.gaming_minutes
|
||||
|
||||
|
||||
def test_tutor_shutdown_before_the_ladder_uses_its_own_fields() -> None:
|
||||
old = date(2026, 10, 1)
|
||||
assert AUTOMATION_TUTOR.shutdown_for(2, old) == 2
|
||||
assert AUTOMATION_TUTOR.shutdown_for(0, old) == 0
|
||||
assert TUTOR.shutdown_for(2, old) == 2
|
||||
assert TUTOR.shutdown_for(0, old) == 0
|
||||
|
||||
@ -10,7 +10,7 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION_TUTOR,
|
||||
TUTOR,
|
||||
credit_units,
|
||||
day_window,
|
||||
entry_signature,
|
||||
@ -96,8 +96,8 @@ def test_legacy_blocks_count_15_once_and_skip_forgeries(
|
||||
]
|
||||
path = _ledger(tmp_path, rows)
|
||||
# 0.7.0 block rows carry no detail.minutes: each pays 15 minutes.
|
||||
assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 30
|
||||
assert first_credit_at(AUTOMATION_TUTOR, path, key_file, DAY) == _at(18, 15)
|
||||
assert credit_units(TUTOR, path, key_file, DAY) == 30
|
||||
assert first_credit_at(TUTOR, path, key_file, DAY) == _at(18, 15)
|
||||
|
||||
|
||||
def test_rows_without_entry_id_each_count(tmp_path: Path, key_file: Path) -> None:
|
||||
@ -107,8 +107,8 @@ def test_rows_without_entry_id_each_count(tmp_path: Path, key_file: Path) -> Non
|
||||
del row["entry_id"]
|
||||
rows.append({**row, "hmac": entry_signature(row, KEY)})
|
||||
path = _ledger(tmp_path, rows)
|
||||
assert credit_units(AUTOMATION_TUTOR, path, key_file, DAY) == 30
|
||||
assert credit_units(TUTOR, path, key_file, DAY) == 30
|
||||
|
||||
|
||||
def test_missing_tutor_ledger_is_zero(tmp_path: Path, key_file: Path) -> None:
|
||||
assert credit_units(AUTOMATION_TUTOR, tmp_path / "no.json", key_file, DAY) == 0
|
||||
assert credit_units(TUTOR, tmp_path / "no.json", key_file, DAY) == 0
|
||||
|
||||
@ -8,7 +8,7 @@ from typing import TYPE_CHECKING
|
||||
import pytest
|
||||
|
||||
from earned_time import (
|
||||
AUTOMATION_TUTOR,
|
||||
TUTOR,
|
||||
WORKOUT,
|
||||
credit_units,
|
||||
entry_signature,
|
||||
@ -47,7 +47,7 @@ def key_file(tmp_path: Path) -> Path:
|
||||
|
||||
|
||||
def _units(tmp_path: Path, key_file: Path, rows: list[object]) -> int | None:
|
||||
return credit_units(AUTOMATION_TUTOR, _ledger(tmp_path, rows), key_file, DAY)
|
||||
return credit_units(TUTOR, _ledger(tmp_path, rows), key_file, DAY)
|
||||
|
||||
|
||||
def test_minute_rows_sum(tmp_path: Path, key_file: Path) -> None:
|
||||
@ -55,7 +55,7 @@ def test_minute_rows_sum(tmp_path: Path, key_file: Path) -> None:
|
||||
rows.append(minute("s", 4, _at(18, 13), minutes=10))
|
||||
assert _units(tmp_path, key_file, rows) == 13
|
||||
assert first_credit_at(
|
||||
AUTOMATION_TUTOR, _ledger(tmp_path, rows), key_file, DAY
|
||||
TUTOR, _ledger(tmp_path, rows), key_file, DAY
|
||||
) == _at(18, 1)
|
||||
|
||||
|
||||
@ -69,7 +69,7 @@ def test_mixed_day_sums_blocks_and_minutes(tmp_path: Path, key_file: Path) -> No
|
||||
rows += [minute("new", n, _at(18, n)) for n in range(1, 21)]
|
||||
units = _units(tmp_path, key_file, rows)
|
||||
assert units == 35
|
||||
day = resolve({**OTHERS, "automation": units or 0}, DAY)
|
||||
day = resolve({**OTHERS, "tutor": units or 0}, DAY)
|
||||
assert (day.gaming_minutes, day.shutdown_minutes) == (420 + 35, 22 * 60 + 35)
|
||||
|
||||
|
||||
@ -78,7 +78,7 @@ def test_the_day_caps_at_60_across_sessions(tmp_path: Path, key_file: Path) -> N
|
||||
rows += [minute("b", n, _at(14, n)) for n in range(1, 21)]
|
||||
units = _units(tmp_path, key_file, rows)
|
||||
assert units == 65 # the raw sum; resolve applies max_units
|
||||
term = resolve({"automation": units}, DAY).term("automation")
|
||||
term = resolve({"tutor": units}, DAY).term("tutor")
|
||||
assert (term.gaming_minutes, term.shutdown_minutes) == (60, 60)
|
||||
|
||||
|
||||
@ -104,7 +104,7 @@ def test_bad_minutes_never_count(
|
||||
assert tutor_minutes(row) is None
|
||||
assert not tutor_match(row, WINDOW)
|
||||
assert "bad minutes" in caplog.text
|
||||
assert row_units(AUTOMATION_TUTOR, row) == 0
|
||||
assert row_units(TUTOR, row) == 0
|
||||
assert _units(tmp_path, key_file, [row, minute("s", 2, _at(18, 1))]) == 1
|
||||
|
||||
|
||||
@ -121,7 +121,7 @@ def test_an_all_legacy_day_resolves_as_0_7_0_did(
|
||||
units = _units(tmp_path, key_file, rows) or 0
|
||||
paid = 15 * min(blocks, 4)
|
||||
for others in ({}, OTHERS):
|
||||
day = resolve({**others, "automation": units}, DAY)
|
||||
day = resolve({**others, "tutor": units}, DAY)
|
||||
base = 420 if others else 180
|
||||
floor = 22 * 60 if others else 18 * 60 + 50
|
||||
assert (day.gaming_minutes, day.shutdown_minutes) == (
|
||||
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.8.0"
|
||||
version = "0.9.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
@ -20,7 +20,7 @@ import sys
|
||||
import tempfile
|
||||
|
||||
import earned_time
|
||||
from earned_time import AUTOMATION_TUTOR, TUTOR_FROM, credit_units, entry_signature
|
||||
from earned_time import TUTOR, TUTOR_FROM, credit_units, entry_signature
|
||||
|
||||
_KEY = b"demo-key-not-the-real-one"
|
||||
_OTHERS = {"workout": 1, "leetcode": 1, "reading": 1}
|
||||
@ -60,7 +60,7 @@ def _units(folder: Path, rows: list[dict[str, object]], day: date) -> int:
|
||||
ledger.write_text(json.dumps({"entries": rows}), encoding="utf-8")
|
||||
key = folder / "hmac.key"
|
||||
key.write_bytes(_KEY)
|
||||
units = credit_units(AUTOMATION_TUTOR, ledger, key, day)
|
||||
units = credit_units(TUTOR, ledger, key, day)
|
||||
if units is None:
|
||||
msg = "demo ledger unreadable"
|
||||
raise RuntimeError(msg)
|
||||
@ -70,9 +70,9 @@ def _units(folder: Path, rows: list[dict[str, object]], day: date) -> int:
|
||||
def _line(label: str, answers: dict[str, int], day: date) -> str:
|
||||
res = earned_time.resolve(answers, day)
|
||||
names = {t.earner.name for t in res.terms}
|
||||
if "automation" in names:
|
||||
term = res.term("automation")
|
||||
paid = f"automation +{term.gaming_minutes}g/+{term.shutdown_minutes}s"
|
||||
if "tutor" in names:
|
||||
term = res.term("tutor")
|
||||
paid = f"tutor +{term.gaming_minutes}g/+{term.shutdown_minutes}s"
|
||||
else:
|
||||
paid = "automation waived" # ANKI_WAIVED_FROM..TUTOR_FROM
|
||||
return (
|
||||
@ -99,7 +99,7 @@ def main() -> None:
|
||||
rows = [_block("s1", n, TUTOR_FROM) for n in range(1, blocks + 1)]
|
||||
units = _units(folder, rows, TUTOR_FROM)
|
||||
for others, tag in ((_OTHERS, "+ all others"), (_NONE, "tutor only")):
|
||||
answers = {**others, "automation": units}
|
||||
answers = {**others, "tutor": units}
|
||||
label = f"{TUTOR_FROM}: {blocks} rows -> {units} units, {tag}"
|
||||
_say(_line(label, answers, TUTOR_FROM))
|
||||
rows = [_block("s2", n, TUTOR_FROM) for n in range(1, 5)]
|
||||
@ -110,8 +110,8 @@ def main() -> None:
|
||||
rows[2]["detail"] = {**detail, "active_seconds": 9000} # edited after signing
|
||||
units = _units(folder, rows, TUTOR_FROM)
|
||||
label = f"{TUTOR_FROM}: 4 rows, 1 tampered -> {units} units"
|
||||
_say(_line(label, {**_OTHERS, "automation": units}, TUTOR_FROM))
|
||||
anki = {**_OTHERS, "anki": 1, "automation": 60}
|
||||
_say(_line(label, {**_OTHERS, "tutor": units}, TUTOR_FROM))
|
||||
anki = {**_OTHERS, "anki": 1, "tutor": 60}
|
||||
_say(
|
||||
_line(
|
||||
f"{TUTOR_FROM}: all + a stale anki answer (ignored)", anki, TUTOR_FROM
|
||||
|
||||
Loading…
Reference in New Issue
Block a user