mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
earned_time 0.14.0: LEISURE_FROM leisure budget (7 h base, 10 h cap) + resolve(leisure=True)
Contract C1 of the leisure rollout (leisure-watch TODO-leisure-rollout.md). New _leisure.py: LEISURE_FROM = 2027-01-01 (parked; Phase 2 sets it to the day after the shadow week), gaming_base_minutes_for(day) (7 h from it, else 5 h) and gaming_ceiling_minutes_for(day) (10 h / 8 h). base_for and resolve use them; leisure=True prices any day with the leisure base and cap (the shadow budget). Pay, penalties, shutdown and the sick rule untouched; every day before LEISURE_FROM resolves byte-identically (full day still 480). resolve() now has six parameters (the contract fixes the signature), so ruff/pylint max-args is 6; the term loop moved into _term() for too-many-locals. test_ladder's shutdown-defaults-to-today test compared to a literal that broke when UNIFIED_FROM passed; it now compares to today. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
parent
26c55675dd
commit
2d97dc4b0e
@ -14,9 +14,18 @@ rows in its ledger — and is registered here once.
|
||||
|
||||
## The model
|
||||
|
||||
**Gaming** (unchanged): a base of 5 h, minus every earner's `gaming_minutes`
|
||||
from its `penalty_from` date; each earner done adds its `gaming_minutes` back.
|
||||
Capped at 8 h.
|
||||
**Gaming**: a base (`gaming_base_minutes_for(day)`) minus every earner's
|
||||
`gaming_minutes` from its `penalty_from` date; each earner done adds its
|
||||
`gaming_minutes` back. Capped at `gaming_ceiling_minutes_for(day)`.
|
||||
|
||||
**Leisure budget (0.14.0, `_leisure.py`).** From `LEISURE_FROM` the budget
|
||||
covers all leisure (games, browsing, video, messaging): base 5 h -> 7 h, cap
|
||||
8 h -> 10 h; pay, penalties and shutdown untouched (fully done day: 180 + 300
|
||||
= 480 before, 300 + 300 = 600 from it). `LEISURE_FROM` is parked at 2027-01-01
|
||||
until Phase 2 sets it to the day after kuhy confirms the shadow week, so every
|
||||
real day still resolves byte-identically. `resolve(..., leisure=True)` (and
|
||||
`base_for(..., leisure=True)`) prices any day with the leisure base and cap:
|
||||
the shadow budget the enforcer shows during that week.
|
||||
|
||||
**Shutdown** has two regimes, switched by date (`LADDER_FROM`, 2026-10-10 —
|
||||
never a same-day cut):
|
||||
@ -29,8 +38,7 @@ never a same-day cut):
|
||||
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 (workout 110, LeetCode 50, reading 30, Anki 25, Automation 25; floor
|
||||
19:00), is never in force on a real day.
|
||||
split (floor 19:00), is never in force on a real day.
|
||||
|
||||
A second workout earns nothing more (`Rung.extra` defaults to 0).
|
||||
Move the alarm (`WAKE_MINUTES = 6 * 60`) and the whole ladder shifts an hour
|
||||
@ -59,11 +67,10 @@ 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 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.
|
||||
`detail.ended_at`). Until 0.9.0 it was `AUTOMATION_TUTOR` (name `automation`,
|
||||
its own ledger, copied whole on the rename: 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.
|
||||
**Weekday quota (0.10.0, `TUTOR_QUOTA_FROM` 2026-10-12, `_quota.py`):** all 60
|
||||
units take 15 min Tue-Thu (`WORKDAYS`, a copy of freedays'), 30 Mon/Fri, 50
|
||||
Sat/Sun; `credit_units` scales the day's deduped minute sum,
|
||||
@ -110,15 +117,11 @@ 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.
|
||||
|
||||
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09).** anki-guard never wrote a
|
||||
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: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 was confirmed in 0.7.0 (`confirmed_on`
|
||||
2026-10-09), so its penalty bites from `TUTOR_FROM`.
|
||||
**The gap waiver (`ANKI_WAIVED_FROM`, 2026-10-09 only).** Until `TUTOR_FROM`
|
||||
(2026-10-10) the registry is `EARNERS` without `anki` and `automation`: neither
|
||||
penalised nor paid, so the base rises by what they could pay back (nothing
|
||||
done 4 h / 19:00; everything 8 h / 23:00). 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)
|
||||
|
||||
@ -157,9 +160,11 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
|
||||
## API
|
||||
|
||||
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False)`
|
||||
`-> Resolution` — the one sum. `sick_shutdown_from(paid_minutes, day) -> int`.
|
||||
- `base_for(day=None, earners=EARNERS) -> Base` — the day's floors.
|
||||
- `resolve(answers, day=None, earners=None, *, first_credits=None, sick=False,
|
||||
leisure=False) -> Resolution` — the one sum. `sick_shutdown_from(paid_minutes,
|
||||
day) -> int`. `base_for(day=None, earners=None, *, first_credits=None,
|
||||
leisure=False) -> Base` — the day's floors. `gaming_base_minutes_for(day)`,
|
||||
`gaming_ceiling_minutes_for(day) -> int` — the gaming base and cap.
|
||||
- `done_today(earner, ledger, key_file, *, now=None) -> bool | None`
|
||||
- `first_credit_at(earner, ledger, key_file, day) -> float | None` — unix time
|
||||
of the earliest verified credit for `day`, on its matcher's stamp
|
||||
@ -174,11 +179,10 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
on a day, and every earner of every registry (what a ledger watcher must
|
||||
watch). They read `earned_time.EARNERS` through the package, so a consumer
|
||||
test that patches it still registers its stand-in.
|
||||
- `shutdown_minutes_for(earner, day) -> int`, `extra_shutdown_minutes_for(earner,
|
||||
day) -> int`, `shutdown_ceiling_for(day) -> int`, `on_ladder(day) -> bool` — what a status
|
||||
view needs to list what is still left to earn. `Earner.shutdown_for(units,
|
||||
day=None)` is the day-aware sum (default today). Never read
|
||||
`earner.shutdown_minutes` directly: it is the pre-ladder value.
|
||||
- `shutdown_minutes_for(earner, day)`, `extra_shutdown_minutes_for(earner, day)`,
|
||||
`shutdown_ceiling_for(day)` (ints), `on_ladder(day) -> bool` — what a status
|
||||
view lists as still left to earn. `Earner.shutdown_for(units, day=None)` is
|
||||
the day-aware sum. Never read `earner.shutdown_minutes`: pre-ladder value.
|
||||
- `day_window(day)`, `today_window(now=None)`, `entry_signature`, `verified`.
|
||||
|
||||
## The workout ledger
|
||||
@ -217,10 +221,9 @@ on the day itself is logged and does not count.
|
||||
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 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.
|
||||
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.
|
||||
|
||||
@ -232,17 +235,15 @@ on the day itself is logged and does not count.
|
||||
- **`None` is "could not check", never "no".** It earns nothing (fail closed),
|
||||
but consumers log it. A missing answer in `resolve()` is logged as well; an
|
||||
unknown earner name raises.
|
||||
- **The HMAC key is root-owned and 0644 on purpose.** The gates run as the
|
||||
user and must sign their rows, so the user can read the key; root owns it
|
||||
so a user process cannot replace or rotate it. The signature therefore
|
||||
stops casual edits and corruption, not a determined forger with a shell --
|
||||
the accepted trade-off of user-run gates.
|
||||
- **The HMAC key is root-owned and 0644 on purpose.** User-run gates must sign
|
||||
rows, so the user reads it; root owns it so no user process can replace it.
|
||||
It stops casual edits and corruption, not a determined forger with a shell.
|
||||
- **Stdlib only.** The gaming daemon imports this as root, headless.
|
||||
|
||||
## Install
|
||||
|
||||
```console
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.13.1#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.14.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
|
||||
@ -23,6 +23,8 @@ A consumer's whole integration::
|
||||
apply(day.gaming_minutes)
|
||||
|
||||
``sick`` (0.11.0) only raises shutdown to at least 20:00; gaming is unchanged.
|
||||
``leisure=True`` (0.14.0) prices gaming with the leisure base and cap
|
||||
(:data:`LEISURE_FROM`) whatever the day: the shadow budget.
|
||||
|
||||
How far to trust a gate is computed, not listed: :func:`maturity` (and
|
||||
``python -m earned_time maturity``).
|
||||
@ -57,6 +59,13 @@ from earned_time._ladder import (
|
||||
shutdown_minutes_for,
|
||||
)
|
||||
from earned_time._ledger import done_today, entry_signature, today_window, verified
|
||||
from earned_time._leisure import (
|
||||
LEISURE_BASE_MINUTES,
|
||||
LEISURE_CEILING_MINUTES,
|
||||
LEISURE_FROM,
|
||||
gaming_base_minutes_for,
|
||||
gaming_ceiling_minutes_for,
|
||||
)
|
||||
from earned_time._maturity import (
|
||||
MATURE_MIN_AGE_DAYS,
|
||||
MATURE_MIN_CREDIT_DAYS,
|
||||
@ -96,6 +105,9 @@ __all__ = [
|
||||
"LADDER",
|
||||
"LADDER_FROM",
|
||||
"LEETCODE",
|
||||
"LEISURE_BASE_MINUTES",
|
||||
"LEISURE_CEILING_MINUTES",
|
||||
"LEISURE_FROM",
|
||||
"MATURE_MIN_AGE_DAYS",
|
||||
"MATURE_MIN_CREDIT_DAYS",
|
||||
"READING",
|
||||
@ -130,6 +142,8 @@ __all__ = [
|
||||
"entry_signature",
|
||||
"extra_shutdown_minutes_for",
|
||||
"first_credit_at",
|
||||
"gaming_base_minutes_for",
|
||||
"gaming_ceiling_minutes_for",
|
||||
"ladder_for",
|
||||
"maturity",
|
||||
"on_ladder",
|
||||
|
||||
47
earned_time/earned_time/_leisure.py
Normal file
47
earned_time/earned_time/_leisure.py
Normal file
@ -0,0 +1,47 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The leisure budget: from ``LEISURE_FROM`` gaming time covers all leisure.
|
||||
|
||||
The daily "gaming" budget becomes a budget for every non-productive activity
|
||||
on the PC (games, browsing, video, messaging), so a fully earned day grows
|
||||
from 8 h to 10 h: the base rises 5 h -> 7 h and the cap 8 h -> 10 h. Earner
|
||||
pay, penalties and the shutdown ladder are untouched, so a day still earns
|
||||
exactly what it did; only the floor and the cap move, by the same 2 h.
|
||||
|
||||
Days before ``LEISURE_FROM`` keep ``GAMING_BASE_MINUTES`` and
|
||||
``GAMING_CEILING_MINUTES``, byte-identical. ``resolve(..., leisure=True)``
|
||||
prices any day as if ``LEISURE_FROM`` had already passed: the shadow budget
|
||||
the enforcer reports during the shadow week, while it still enforces the old.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from typing import Final
|
||||
|
||||
from earned_time._policy import GAMING_BASE_MINUTES, GAMING_CEILING_MINUTES
|
||||
|
||||
# Parked far ahead: Phase 2 of the leisure rollout sets it to the day after
|
||||
# kuhy confirms the shadow week, never a same-day cut (2026-09-26).
|
||||
LEISURE_FROM: Final = date(2027, 1, 1)
|
||||
# From ``LEISURE_FROM``: 7 h base, 10 h cap (+2 h each).
|
||||
LEISURE_BASE_MINUTES: Final = 7 * 60
|
||||
LEISURE_CEILING_MINUTES: Final = 10 * 60
|
||||
|
||||
|
||||
def leisure_day(day: date, *, leisure: bool = False) -> date:
|
||||
"""The day the gaming base and cap are priced on.
|
||||
|
||||
``day`` itself, unless ``leisure``: then no earlier than ``LEISURE_FROM``,
|
||||
so the shadow budget of an old day uses the leisure base and cap.
|
||||
"""
|
||||
return max(day, LEISURE_FROM) if leisure else day
|
||||
|
||||
|
||||
def gaming_base_minutes_for(day: date) -> int:
|
||||
"""The gaming base before penalties: 7 h from ``LEISURE_FROM``, else 5 h."""
|
||||
return LEISURE_BASE_MINUTES if day >= LEISURE_FROM else GAMING_BASE_MINUTES
|
||||
|
||||
|
||||
def gaming_ceiling_minutes_for(day: date) -> int:
|
||||
"""The gaming cap: 10 h from ``LEISURE_FROM``, else 8 h."""
|
||||
return LEISURE_CEILING_MINUTES if day >= LEISURE_FROM else GAMING_CEILING_MINUTES
|
||||
@ -26,13 +26,13 @@ import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
||||
from earned_time._maturity import confirmed_start, penalty_start
|
||||
from earned_time._policy import (
|
||||
GAMING_BASE_MINUTES,
|
||||
GAMING_CEILING_MINUTES,
|
||||
SHUTDOWN_BASE_MINUTES,
|
||||
Earner,
|
||||
from earned_time._leisure import (
|
||||
gaming_base_minutes_for,
|
||||
gaming_ceiling_minutes_for,
|
||||
leisure_day,
|
||||
)
|
||||
from earned_time._maturity import confirmed_start, penalty_start
|
||||
from earned_time._policy import SHUTDOWN_BASE_MINUTES, Earner
|
||||
from earned_time._registry import all_earners, earners_for
|
||||
from earned_time._resolution import Base, Resolution, Term
|
||||
from earned_time._sick import sick_shutdown_from
|
||||
@ -92,6 +92,7 @@ def base_for(
|
||||
earners: tuple[Earner, ...] | None = None,
|
||||
*,
|
||||
first_credits: Mapping[str, date | None] | None = None,
|
||||
leisure: bool = False,
|
||||
) -> Base:
|
||||
"""The floor for ``day`` (default today) under ``earners`` (default its own).
|
||||
|
||||
@ -100,12 +101,14 @@ def base_for(
|
||||
derived separately so the ladder can never move a gaming minute.
|
||||
``first_credits`` (per earner name) delays each penalty, and the ladder
|
||||
rung, to :func:`penalty_start`; omitted, 0.5.0's ``penalty_from`` applies.
|
||||
The gaming base is :func:`gaming_base_minutes_for` the day, or with
|
||||
``leisure`` the leisure base whatever the date (:func:`leisure_day`).
|
||||
"""
|
||||
target = day or _today()
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
penalised = [e for e in registry if _penalised(e, target, first_credits)]
|
||||
return Base(
|
||||
gaming_minutes=GAMING_BASE_MINUTES
|
||||
gaming_minutes=gaming_base_minutes_for(leisure_day(target, leisure=leisure))
|
||||
- sum(e.max_gaming_minutes for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
||||
)
|
||||
@ -128,6 +131,22 @@ def _check_registered(
|
||||
raise KeyError(msg)
|
||||
|
||||
|
||||
def _term(item: Earner, answers: Mapping[str, Answer], day: date) -> Term:
|
||||
"""``item``'s term on ``day``; no answer at all is logged and earns nothing."""
|
||||
if item.name not in answers:
|
||||
_logger.warning(
|
||||
"No answer for the %s earner; it earns nothing today", item.label
|
||||
)
|
||||
units = _units(answers.get(item.name))
|
||||
count = units or 0
|
||||
return Term(
|
||||
earner=item,
|
||||
answer=units,
|
||||
gaming_minutes=item.gaming_for(count),
|
||||
shutdown_minutes=item.shutdown_for(count, day),
|
||||
)
|
||||
|
||||
|
||||
def _sick_or(earned: int, terms: list[Term], day: date) -> int:
|
||||
"""A sick day's shutdown: 20:00 plus gaming pay, never below ``earned``."""
|
||||
return max(earned, sick_shutdown_from(sum(t.gaming_minutes for t in terms), day))
|
||||
@ -140,6 +159,7 @@ def resolve(
|
||||
*,
|
||||
first_credits: Mapping[str, date | None] | None = None,
|
||||
sick: bool = False,
|
||||
leisure: bool = False,
|
||||
) -> Resolution:
|
||||
"""Sum the base and every earned term, each capped at its ceiling.
|
||||
|
||||
@ -159,6 +179,9 @@ def resolve(
|
||||
is 20:00 plus the terms' gaming pay
|
||||
(:func:`~earned_time._sick.sick_shutdown_from`, 0.13.0).
|
||||
Gaming and every term are unchanged.
|
||||
leisure: Price gaming as if ``LEISURE_FROM`` had passed (0.14.0): the
|
||||
leisure base and cap whatever the day -- the shadow budget.
|
||||
Terms, penalties and shutdown are unchanged.
|
||||
|
||||
Returns:
|
||||
The resolution, with a term for every registered earner.
|
||||
@ -170,23 +193,8 @@ def resolve(
|
||||
target = day or _today()
|
||||
registry = earners_for(target) if earners is None else earners
|
||||
_check_registered(answers, registry)
|
||||
base = base_for(target, registry, first_credits=first_credits)
|
||||
terms: list[Term] = []
|
||||
for item in registry:
|
||||
if item.name not in answers:
|
||||
_logger.warning(
|
||||
"No answer for the %s earner; it earns nothing today", item.label
|
||||
)
|
||||
units = _units(answers.get(item.name))
|
||||
count = units or 0
|
||||
terms.append(
|
||||
Term(
|
||||
earner=item,
|
||||
answer=units,
|
||||
gaming_minutes=item.gaming_for(count),
|
||||
shutdown_minutes=item.shutdown_for(count, target),
|
||||
)
|
||||
)
|
||||
base = base_for(target, registry, first_credits=first_credits, leisure=leisure)
|
||||
terms = [_term(item, answers, target) for item in registry]
|
||||
gaming = base.gaming_minutes + sum(t.gaming_minutes for t in terms)
|
||||
earned = min(
|
||||
shutdown_ceiling_for(target),
|
||||
@ -197,7 +205,9 @@ def resolve(
|
||||
day=target,
|
||||
base=base,
|
||||
terms=tuple(terms),
|
||||
gaming_minutes=min(GAMING_CEILING_MINUTES, gaming),
|
||||
gaming_minutes=min(
|
||||
gaming_ceiling_minutes_for(leisure_day(target, leisure=leisure)), gaming
|
||||
),
|
||||
shutdown_minutes=shutdown,
|
||||
sick=sick,
|
||||
sick_lift_minutes=shutdown - earned,
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date
|
||||
from datetime import UTC, date, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
@ -127,9 +127,10 @@ def test_earner_without_ladder_values_keeps_its_own() -> None:
|
||||
assert extra_shutdown_minutes_for(plain, FIRST) == 5
|
||||
|
||||
|
||||
def test_shutdown_for_defaults_to_today(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setattr(ladder, "LADDER_FROM", date(2000, 1, 1))
|
||||
assert WORKOUT.shutdown_for(2) == 110
|
||||
def test_shutdown_for_defaults_to_today() -> None:
|
||||
# Pinned to today, not a constant: a literal broke when UNIFIED_FROM passed.
|
||||
today = datetime.now(tz=UTC).astimezone().date()
|
||||
assert WORKOUT.shutdown_for(2) == WORKOUT.shutdown_for(2, today)
|
||||
|
||||
|
||||
def test_the_ladder() -> None:
|
||||
|
||||
70
earned_time/earned_time/tests/test_leisure.py
Normal file
70
earned_time/earned_time/tests/test_leisure.py
Normal file
@ -0,0 +1,70 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The leisure budget (0.14.0): base 7 h, cap 10 h from ``LEISURE_FROM``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
|
||||
from earned_time import (
|
||||
GAMING_BASE_MINUTES,
|
||||
GAMING_CEILING_MINUTES,
|
||||
LEISURE_FROM,
|
||||
TUTOR_EARNERS,
|
||||
UNIFIED_FROM,
|
||||
base_for,
|
||||
gaming_base_minutes_for,
|
||||
gaming_ceiling_minutes_for,
|
||||
resolve,
|
||||
)
|
||||
|
||||
EVE = LEISURE_FROM - timedelta(days=1)
|
||||
FULL = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60}
|
||||
NONE = dict.fromkeys(FULL, 0)
|
||||
|
||||
|
||||
def _gaming(day: date, answers: dict[str, int], *, leisure: bool = False) -> int:
|
||||
return resolve(answers, day, TUTOR_EARNERS, leisure=leisure).gaming_minutes
|
||||
|
||||
|
||||
def test_parked_until_phase_two() -> None:
|
||||
assert date(2027, 1, 1) == LEISURE_FROM
|
||||
|
||||
|
||||
def test_base_and_ceiling_switch_on_leisure_from() -> None:
|
||||
assert gaming_base_minutes_for(EVE) == GAMING_BASE_MINUTES == 5 * 60
|
||||
assert gaming_base_minutes_for(LEISURE_FROM) == 7 * 60
|
||||
assert gaming_ceiling_minutes_for(EVE) == GAMING_CEILING_MINUTES == 8 * 60
|
||||
assert gaming_ceiling_minutes_for(LEISURE_FROM) == 10 * 60
|
||||
|
||||
|
||||
def test_enforced_days_before_leisure_from_are_unchanged() -> None:
|
||||
for day in (UNIFIED_FROM, EVE):
|
||||
assert base_for(day).gaming_minutes == 180
|
||||
assert _gaming(day, NONE) == 180
|
||||
assert _gaming(day, FULL) == 180 + 300 == 480
|
||||
assert _gaming(day, FULL, leisure=False) == 480
|
||||
|
||||
|
||||
def test_leisure_prices_an_old_day_as_the_shadow_budget() -> None:
|
||||
assert base_for(UNIFIED_FROM, leisure=True).gaming_minutes == 300
|
||||
assert _gaming(UNIFIED_FROM, NONE, leisure=True) == 300
|
||||
assert _gaming(UNIFIED_FROM, FULL, leisure=True) == 300 + 300 == 600
|
||||
|
||||
|
||||
def test_from_leisure_from_the_flag_changes_nothing() -> None:
|
||||
assert _gaming(LEISURE_FROM, NONE) == 300
|
||||
assert _gaming(LEISURE_FROM, FULL) == 600
|
||||
assert _gaming(LEISURE_FROM, FULL, leisure=True) == 600
|
||||
|
||||
|
||||
def test_leisure_never_moves_shutdown_or_the_sick_rule() -> None:
|
||||
for sick in (False, True):
|
||||
for answers in (NONE, FULL, {**NONE, "leetcode": 1, "reading": 1}):
|
||||
plain = resolve(answers, UNIFIED_FROM, TUTOR_EARNERS, sick=sick)
|
||||
shadow = resolve(
|
||||
answers, UNIFIED_FROM, TUTOR_EARNERS, sick=sick, leisure=True
|
||||
)
|
||||
assert shadow.shutdown_minutes == plain.shutdown_minutes
|
||||
assert shadow.sick_lift_minutes == plain.sick_lift_minutes
|
||||
assert shadow.terms == plain.terms
|
||||
assert shadow.base.shutdown_minutes == plain.base.shutdown_minutes
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.13.2"
|
||||
version = "0.14.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
@ -33,6 +33,10 @@ unfixable = []
|
||||
"**/tests/**/*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||
"**/test_*.py" = ["ARG", "D", "PLC0415", "PLR2004", "S101", "SLF001"]
|
||||
|
||||
[tool.ruff.lint.pylint]
|
||||
# Same as [tool.pylint.design] max-args: resolve()'s keyword-only switches.
|
||||
max-args = 6
|
||||
|
||||
[tool.ruff.lint.pydocstyle]
|
||||
convention = "google"
|
||||
|
||||
@ -108,6 +112,9 @@ min-public-methods = 0
|
||||
max-module-lines = 1000
|
||||
# Earner is a frozen registry record: its fields are the policy (cf. music_theory).
|
||||
max-attributes = 13
|
||||
# resolve()'s keyword-only switches (first_credits, sick, leisure) are the
|
||||
# public API every consumer calls; bundling them would break each caller.
|
||||
max-args = 6
|
||||
|
||||
[tool.pylint.typecheck]
|
||||
generated-members = [
|
||||
|
||||
Loading…
Reference in New Issue
Block a user