mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:27:15 +01:00
earned_time 0.19.0: focus project earner
earners_for swaps a focus project's replaced gates for one focus earner paid per minute from the focus ledger (floor of summed minutes). The gaming cut equals what the replaced penalised gates cost, so a project day is neutral; maturity never delays it. An unusable focus_project means no project (logged once). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
This commit is contained in:
parent
087ed1c400
commit
8398ca4823
@ -168,17 +168,14 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
- `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
|
||||
(`_match.CREDIT_STAMPS`; Anki/Automation: `created_at`). It can lie outside
|
||||
`day` (Anki's rollover, a rest day declared the evening before).
|
||||
- `credit_units(earner, ledger, key_file, day) -> int | None` — the units
|
||||
the verified rows pay for `day`: 1 per row (the workout), or the row's own
|
||||
count (the tutor's `detail.minutes`, legacy block rows 15; from 0.10.0 the
|
||||
day's sum scaled to its weekday quota). A repeated `entry_id` pays once,
|
||||
its smallest count; `max_units` is applied by `resolve`.
|
||||
(`_match.CREDIT_STAMPS`; else `created_at`); it can lie outside `day`.
|
||||
- `credit_units(earner, ledger, key_file, day) -> int | None` — the units the
|
||||
verified rows pay for `day`: 1 per row (the workout), or the row's own count
|
||||
(`detail.minutes`: tutor, legacy blocks 15, from 0.10.0 scaled to its weekday
|
||||
quota; focus). A repeated `entry_id` pays once; `resolve` applies `max_units`.
|
||||
- `earners_for(day)`, `all_earners()`, `registries()` — the registry in force
|
||||
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.
|
||||
watch). They read `earned_time.EARNERS` through the package, so a patch holds.
|
||||
- `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
|
||||
@ -187,14 +184,21 @@ How far to trust a gate is computed from its ledger, never listed by name:
|
||||
|
||||
## The workout ledger
|
||||
|
||||
`WORKOUT` is `kind="counted"` with a ledger at
|
||||
`~/.local/share/workout_locker/ledger.json`, written by screen-locker: one
|
||||
signed credit row per credited unit,
|
||||
`{"kind": "credit", "entry_id", "day", "detail": {"completed_at", "source"}}`.
|
||||
`workout_match` counts a `runnerup_tcx` row when `completed_at` falls in the
|
||||
window, and a `rest_day` row on the `day` it names — only if
|
||||
`detail.declared_at` is before that day's local midnight. A rest day declared
|
||||
on the day itself is logged and does not count.
|
||||
`WORKOUT` is `kind="counted"`, ledger `~/.local/share/workout_locker/ledger.json`
|
||||
(screen-locker; one signed credit row per unit, `detail.completed_at`/`source`).
|
||||
`workout_match` counts a `runnerup_tcx` row by `completed_at`, a `rest_day` row
|
||||
on its `day` only if `detail.declared_at` predates that day (else logged, no).
|
||||
|
||||
## The focus project (0.19.0)
|
||||
|
||||
While `focus_project.active(day)` returns a project, `earners_for` drops the
|
||||
gates it `replaces` (`book` = `reading`) and adds one counted `focus` earner
|
||||
(`max_units` = `daily_minutes`) paying what they paid (shutdown by their rungs,
|
||||
gaming by `max_gaming_minutes`: 60+60+60 over 120 min = 1.5/min):
|
||||
`floor(summed minutes * pay / daily_minutes)`, never per row. The penalty runs
|
||||
from the start day, cutting what the replaced gates cost; maturity never delays it. Ledger `~/.local/share/focus_project/ledger.json`:
|
||||
signed `credit` rows, `entry_id` `<project>-<YYYYMMDD>-m<n>`, `detail.minutes` (positive int),
|
||||
`detail.ended_at` (else `day`). `focus_project` unusable = no project (logged).
|
||||
|
||||
## Adding a gate
|
||||
|
||||
@ -208,43 +212,39 @@ on the day itself is logged and does not count.
|
||||
```python
|
||||
PIANO = Earner(
|
||||
name="piano", # also screen-locker's "piano_bonus_date" stamp
|
||||
label="piano",
|
||||
gaming_minutes=30,
|
||||
shutdown_minutes=30, # pre-ladder value
|
||||
penalty_from=date(2026, 11, 1), # gaming base drops by 30 from then on
|
||||
ledger=".local/share/piano_guard/ledger.json",
|
||||
match=piano_match, # which verified credit rows count today
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
```
|
||||
|
||||
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`.
|
||||
Then a rung in `TUTOR_LADDER` (`PIANO.name: Rung(20)`) and, for 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.
|
||||
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.
|
||||
A new rung comes out of the floor from its penalty start (a pure bonus:
|
||||
the day its pin lands): ship it the day the gate can be satisfied.
|
||||
3. Bump, tag `earned-time-vX.Y.Z`, re-pin every consumer (no code needed).
|
||||
|
||||
## Rules the consumers rely on
|
||||
|
||||
- **Minutes everywhere.** Shutdown is minutes after local midnight.
|
||||
- **Paths are passed in.** No reader resolves a real path itself, except the
|
||||
switch file: consumer tests patch `earned_time._leisure.LEISURE_FROM_PATH`.
|
||||
- **Paths are passed in.** Only the switch file is resolved here
|
||||
(patch `earned_time._leisure.LEISURE_FROM_PATH`).
|
||||
- **`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.** 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.
|
||||
- **The HMAC key is root 0644 on purpose.** User-run gates sign rows with it;
|
||||
it stops casual edits, not a determined forger with a shell.
|
||||
- **Stdlib only.** The root gaming daemon imports this headless; `focus_project`
|
||||
(stdlib too) is imported lazily and may be absent.
|
||||
|
||||
## Install
|
||||
|
||||
```console
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.18.0#subdirectory=earned_time"
|
||||
pip install --user --break-system-packages --no-deps "earned-time @ git+https://github.com/kuhyx/utils@earned-time-v0.19.0#subdirectory=earned_time"
|
||||
```
|
||||
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy`, so it imports
|
||||
from the same user site-packages. Test: `scripts/run_subproject_tests.sh earned_time`.
|
||||
The root `steam-backlog-enforcer.service` sets `HOME=/home/kuhy` and imports from
|
||||
the same user site-packages. Test: `scripts/run_subproject_tests.sh earned_time`.
|
||||
|
||||
@ -43,6 +43,7 @@ from earned_time._credits import (
|
||||
first_credit_at,
|
||||
)
|
||||
from earned_time._evidence import real_credit
|
||||
from earned_time._focus import FOCUS_LEDGER, FocusEarner
|
||||
from earned_time._ladder import (
|
||||
ANKI_WAIVED_FROM,
|
||||
LADDER,
|
||||
@ -105,6 +106,7 @@ __all__ = [
|
||||
"ANKI_WAIVED_FROM",
|
||||
"AUTOMATION",
|
||||
"EARNERS",
|
||||
"FOCUS_LEDGER",
|
||||
"GAMING_BASE_MINUTES",
|
||||
"GAMING_CEILING_MINUTES",
|
||||
"LADDER",
|
||||
@ -132,6 +134,7 @@ __all__ = [
|
||||
"WORKOUT",
|
||||
"Base",
|
||||
"Earner",
|
||||
"FocusEarner",
|
||||
"Level",
|
||||
"Maturity",
|
||||
"Resolution",
|
||||
|
||||
@ -20,6 +20,7 @@ from pathlib import Path
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._focus import FOCUS_WATCH
|
||||
from earned_time._maturity import Maturity, maturity
|
||||
from earned_time._registry import all_earners, earners_for
|
||||
|
||||
@ -57,9 +58,14 @@ def _gates(day: date) -> list[tuple[str, Earner, Maturity]]:
|
||||
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)
|
||||
registered = earners_for(day)
|
||||
earners = [
|
||||
e
|
||||
for e in (*all_earners(), *registered)
|
||||
if e.ledger is not None and e is not FOCUS_WATCH
|
||||
]
|
||||
earners = list(dict.fromkeys(earners))
|
||||
names = Counter(e.name for e in earners)
|
||||
gates = []
|
||||
for item in earners:
|
||||
ledger = Path(str(item.ledger))
|
||||
|
||||
109
earned_time/earned_time/_focus.py
Normal file
109
earned_time/earned_time/_focus.py
Normal file
@ -0,0 +1,109 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The focus project (0.19.0): replaced gates stand down, one earner pays."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
import importlib
|
||||
import logging
|
||||
from typing import Any, Final
|
||||
|
||||
from earned_time._ladder import shutdown_units_minutes
|
||||
from earned_time._match import focus_match
|
||||
from earned_time._policy import Earner
|
||||
|
||||
__all__ = [
|
||||
"FOCUS_LEDGER",
|
||||
"FOCUS_WATCH",
|
||||
"FocusEarner",
|
||||
"focus_earner",
|
||||
"with_focus",
|
||||
]
|
||||
|
||||
FOCUS_LEDGER: Final = ".local/share/focus_project/ledger.json"
|
||||
_SYNONYMS: Final = {"book": "reading"}
|
||||
_logger: Final = logging.getLogger(__name__)
|
||||
_warned: set[str] = set()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FocusEarner(Earner):
|
||||
"""Counted; ``gaming_minutes``/``shutdown_minutes`` pay a FULL day.
|
||||
|
||||
``cut_gaming_minutes``: the base cut, what the replaced penalised gates cost.
|
||||
"""
|
||||
|
||||
cut_gaming_minutes: int = 0
|
||||
|
||||
def gaming_for(self, units: int) -> int:
|
||||
"""Gaming minutes of ``units`` focused minutes, floored."""
|
||||
return self.capped(units) * self.gaming_minutes // (self.max_units or 1)
|
||||
|
||||
def shutdown_for(
|
||||
self,
|
||||
units: int,
|
||||
day: date | None = None, # noqa: ARG002
|
||||
) -> int:
|
||||
"""Shutdown minutes of ``units`` focused minutes, floored."""
|
||||
return self.capped(units) * self.shutdown_minutes // (self.max_units or 1)
|
||||
|
||||
|
||||
def _active(day: date) -> Any: # noqa: ANN401
|
||||
"""The active project on ``day``; ``None`` for none or on any failure."""
|
||||
try:
|
||||
return importlib.import_module("focus_project").active(day)
|
||||
except Exception as exc: # noqa: BLE001 # pylint: disable=broad-exception-caught
|
||||
message = f"focus_project unusable ({type(exc).__name__}: {exc})"
|
||||
if message not in _warned:
|
||||
_warned.add(message)
|
||||
_logger.warning("%s; behaving as if no project is active", message)
|
||||
return None
|
||||
|
||||
|
||||
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
|
||||
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``."""
|
||||
return FocusEarner(
|
||||
name="focus",
|
||||
label="Focus",
|
||||
gaming_minutes=sum(e.max_gaming_minutes for e in dropped),
|
||||
cut_gaming_minutes=sum(
|
||||
e.max_gaming_minutes for e in dropped if e.penalty_from is not None
|
||||
),
|
||||
shutdown_minutes=sum(
|
||||
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
|
||||
),
|
||||
kind="counted",
|
||||
max_units=int(project.daily_minutes),
|
||||
penalty_from=project.start,
|
||||
confirmed_on=project.start - timedelta(days=1),
|
||||
ledger=FOCUS_LEDGER,
|
||||
match=focus_match,
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
|
||||
FOCUS_WATCH: Final = FocusEarner(
|
||||
name="focus",
|
||||
label="Focus",
|
||||
gaming_minutes=0,
|
||||
shutdown_minutes=0,
|
||||
kind="counted",
|
||||
max_units=1,
|
||||
ledger=FOCUS_LEDGER,
|
||||
match=focus_match,
|
||||
missing_ledger_is_no=True,
|
||||
)
|
||||
|
||||
|
||||
def with_focus(registry: tuple[Earner, ...], day: date) -> tuple[Earner, ...]:
|
||||
"""``registry`` reshaped by the active project; unchanged if none."""
|
||||
project = _active(day)
|
||||
if project is None:
|
||||
return registry
|
||||
gates = {_SYNONYMS.get(g, g) for g in project.replaces}
|
||||
dropped = tuple(e for e in registry if e.name in gates)
|
||||
if not dropped or int(project.daily_minutes) < 1:
|
||||
return registry
|
||||
kept = tuple(e for e in registry if e not in dropped)
|
||||
return (*kept, focus_earner(project, dropped, day))
|
||||
@ -156,13 +156,15 @@ def shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
without a rung, its own ``shutdown_minutes``.
|
||||
"""
|
||||
rung = _rung(item, day)
|
||||
return item.shutdown_minutes if rung is None else rung.first
|
||||
return item.shutdown_for(1, day) if rung is None else rung.first
|
||||
|
||||
|
||||
def extra_shutdown_minutes_for(item: Earner, day: date) -> int:
|
||||
"""What ``item``'s second unit earns on ``day`` (counted only)."""
|
||||
rung = _rung(item, day)
|
||||
return item.extra_shutdown_minutes if rung is None else rung.second
|
||||
if rung is not None:
|
||||
return rung.second
|
||||
return item.shutdown_for(2, day) - item.shutdown_for(1, day)
|
||||
|
||||
|
||||
def shutdown_units_minutes(item: Earner, units: int, day: date) -> int:
|
||||
|
||||
@ -167,6 +167,30 @@ def tutor_match(row: dict[str, object], window: Window) -> bool:
|
||||
return landed
|
||||
|
||||
|
||||
def focus_minutes(row: dict[str, object]) -> int | None:
|
||||
"""Minutes a focus row pays: ``detail.minutes``, a positive int, else ``None``."""
|
||||
detail = row.get("detail")
|
||||
raw = detail.get("minutes") if isinstance(detail, dict) else None
|
||||
if isinstance(raw, bool) or not isinstance(raw, int) or raw < 1:
|
||||
return None
|
||||
return raw
|
||||
|
||||
|
||||
def focus_match(row: dict[str, object], window: Window) -> bool:
|
||||
"""A focus credit counts on the day of ``detail.ended_at``, else its ``day``."""
|
||||
if focus_minutes(row) is None:
|
||||
_logger.warning("focus credit %r has bad minutes", row.get("entry_id"))
|
||||
return False
|
||||
detail = row.get("detail")
|
||||
landed = _within(
|
||||
detail.get("ended_at") if isinstance(detail, dict) else None, window
|
||||
)
|
||||
if landed is not None:
|
||||
return landed
|
||||
local = datetime.fromtimestamp(window[0], tz=UTC).astimezone()
|
||||
return row.get("day") == local.date().isoformat()
|
||||
|
||||
|
||||
# The ``detail`` field each matcher decides on: the unix second the work
|
||||
# happened, which :func:`earned_time._credits.credit_time` reports. A matcher
|
||||
# without one (Anki's day key) falls back to the row's ``created_at``.
|
||||
@ -176,6 +200,7 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
||||
reading_match: "ended_at",
|
||||
workout_match: "completed_at",
|
||||
tutor_match: "ended_at",
|
||||
focus_match: "ended_at",
|
||||
}
|
||||
)
|
||||
# How many units one counting row pays, per matcher; a matcher without an
|
||||
@ -183,7 +208,7 @@ CREDIT_STAMPS: Final[Mapping[CreditMatch, str]] = MappingProxyType(
|
||||
# count (``detail.minutes``): book-guard's ``detail.minutes`` is a string
|
||||
# and means something else, so this is never read generically.
|
||||
ROW_UNITS: Final[Mapping[CreditMatch, Callable[[dict[str, object]], int | None]]] = (
|
||||
MappingProxyType({tutor_match: tutor_minutes})
|
||||
MappingProxyType({tutor_match: tutor_minutes, focus_match: focus_minutes})
|
||||
)
|
||||
# Which ledger row kinds each matcher is shown; a matcher without an entry
|
||||
# sees only ``credit`` rows. LeetCode also sees ``charge`` rows, so a day
|
||||
|
||||
@ -31,6 +31,7 @@ from datetime import UTC, date, datetime, timedelta
|
||||
from typing import TYPE_CHECKING, Final, Literal
|
||||
|
||||
from earned_time._evidence import credit_history
|
||||
from earned_time._focus import FocusEarner
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -100,11 +101,12 @@ def penalty_start(earner: Earner, first_credit: date | None) -> date | None:
|
||||
(never paid out, or could not check): :func:`confirmed_start` when kuhy
|
||||
set ``confirmed_on`` -- fail closed, deleting a ledger lifts nothing --
|
||||
else ``None``, not penalised. ``None`` for a pure bonus. Never earlier
|
||||
than ``penalty_from``.
|
||||
than ``penalty_from``. A focus earner is never delayed: its first credit
|
||||
comes on the day its penalty starts (0.19.0).
|
||||
"""
|
||||
start = confirmed_start(earner)
|
||||
if start is None:
|
||||
return None
|
||||
if start is None or isinstance(earner, FocusEarner):
|
||||
return start
|
||||
if first_credit is None:
|
||||
return start if earner.confirmed_on is not None else None
|
||||
return max(start, first_credit + _DAY)
|
||||
|
||||
@ -14,6 +14,7 @@ from functools import cache
|
||||
import sys
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._focus import FOCUS_WATCH, with_focus
|
||||
from earned_time._ladder import ANKI_WAIVED_FROM, TUTOR_FROM
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -38,12 +39,14 @@ def earners_for(day: date | None = None) -> tuple[Earner, ...]:
|
||||
"""The registry in force on ``day`` (default today).
|
||||
|
||||
Between ``ANKI_WAIVED_FROM`` and ``TUTOR_FROM`` the old registry without
|
||||
Anki and the anki-backed Automation: neither penalised nor paid.
|
||||
Anki and the anki-backed Automation: neither penalised nor paid. From
|
||||
``TUTOR_FROM`` an active focus project swaps its replaced earners for one
|
||||
``focus`` earner (:mod:`earned_time._focus`, 0.19.0).
|
||||
"""
|
||||
target = day or datetime.now(tz=UTC).astimezone().date()
|
||||
before, tutor = registries()
|
||||
if target >= TUTOR_FROM:
|
||||
return tutor
|
||||
return with_focus(tutor, target)
|
||||
if target >= ANKI_WAIVED_FROM:
|
||||
return _without_waived(before)
|
||||
return before
|
||||
@ -64,4 +67,5 @@ def all_earners() -> tuple[Earner, ...]:
|
||||
seen: dict[Earner, None] = {}
|
||||
for registry in registries():
|
||||
seen.update(dict.fromkeys(registry))
|
||||
seen[FOCUS_WATCH] = None
|
||||
return tuple(seen)
|
||||
|
||||
@ -25,6 +25,7 @@ from datetime import UTC, date, datetime
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from earned_time._focus import FocusEarner
|
||||
from earned_time._ladder import on_ladder, shutdown_ceiling_for
|
||||
from earned_time._leisure import base_minutes, ceiling_minutes, is_leisure
|
||||
from earned_time._maturity import confirmed_start, penalty_start
|
||||
@ -45,6 +46,16 @@ def _today() -> date:
|
||||
return datetime.now(tz=UTC).astimezone().date()
|
||||
|
||||
|
||||
def _gaming_cut(item: Earner) -> int:
|
||||
"""The gaming base cut of a penalised earner: what it can pay.
|
||||
|
||||
A focus earner cuts only what the gates it replaced cost (LeetCode's was free).
|
||||
"""
|
||||
if isinstance(item, FocusEarner):
|
||||
return item.cut_gaming_minutes
|
||||
return item.max_gaming_minutes
|
||||
|
||||
|
||||
def _penalised(
|
||||
item: Earner, day: date, first_credits: Mapping[str, date | None] | None
|
||||
) -> bool:
|
||||
@ -117,7 +128,7 @@ def _base(
|
||||
penalised = [e for e in registry if _penalised(e, target, first_credits)]
|
||||
return Base(
|
||||
gaming_minutes=base_minutes(leisure=on)
|
||||
- sum(e.max_gaming_minutes for e in penalised),
|
||||
- sum(_gaming_cut(e) for e in penalised),
|
||||
shutdown_minutes=_shutdown_floor(target, registry, penalised),
|
||||
)
|
||||
|
||||
|
||||
@ -3,6 +3,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@ -21,6 +23,14 @@ def _no_real_switch_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Non
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _no_real_focus_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Never read the real projects.json: no project is active unless a test says."""
|
||||
monkeypatch.setitem(
|
||||
sys.modules, "focus_project", SimpleNamespace(active=lambda _day: None)
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_file(tmp_path: Path) -> Path:
|
||||
"""The test HMAC key, written where a test can point ``maturity`` at it."""
|
||||
|
||||
248
earned_time/earned_time/tests/test_focus.py
Normal file
248
earned_time/earned_time/tests/test_focus.py
Normal file
@ -0,0 +1,248 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The focus project earner: registry swap, pay, rounding, penalty, fail mode."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
import earned_time
|
||||
from earned_time import (
|
||||
TUTOR_EARNERS,
|
||||
FocusEarner,
|
||||
all_earners,
|
||||
credit_units,
|
||||
earners_for,
|
||||
extra_shutdown_minutes_for,
|
||||
maturity,
|
||||
penalty_start,
|
||||
resolve,
|
||||
shutdown_minutes_for,
|
||||
)
|
||||
from earned_time._focus import focus_earner
|
||||
from earned_time._match import focus_match, focus_minutes
|
||||
from earned_time.tests._maturity_rows import at, ledger_file, signed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
START = date(2026, 11, 2)
|
||||
DAY = START + timedelta(days=1)
|
||||
|
||||
|
||||
def project(**changes: object) -> SimpleNamespace:
|
||||
"""A fake ``focus_project.Project`` (the contract's fields)."""
|
||||
fields: dict[str, object] = {
|
||||
"start": START,
|
||||
"end": START + timedelta(days=3),
|
||||
"replaces": ("leetcode", "reading", "tutor"),
|
||||
"daily_minutes": 120,
|
||||
}
|
||||
return SimpleNamespace(**{**fields, **changes})
|
||||
|
||||
|
||||
def activate(monkeypatch: pytest.MonkeyPatch, active: object) -> None:
|
||||
"""Make ``focus_project.active`` return ``active`` on project days only."""
|
||||
|
||||
def on(day: date) -> object:
|
||||
return active if START <= day <= START + timedelta(days=3) else None
|
||||
|
||||
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=on))
|
||||
|
||||
|
||||
def row(
|
||||
minutes: object, n: int, day: date = DAY, **detail: object
|
||||
) -> dict[str, object]:
|
||||
"""A signed focus credit row ending at noon on ``day``."""
|
||||
return signed(
|
||||
{
|
||||
"kind": "credit",
|
||||
"entry_id": f"hr-{day:%Y%m%d}-m{n}",
|
||||
"day": day.isoformat(),
|
||||
"detail": {"minutes": minutes, "ended_at": at(day), **detail},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
def focus(monkeypatch: pytest.MonkeyPatch, **changes: object) -> FocusEarner:
|
||||
"""Today's focus earner under a fake project."""
|
||||
activate(monkeypatch, project(**changes))
|
||||
(found,) = (e for e in earners_for(DAY) if e.name == "focus")
|
||||
assert isinstance(found, FocusEarner)
|
||||
return found
|
||||
|
||||
|
||||
def test_the_replaced_gates_leave_and_focus_joins(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project())
|
||||
assert [e.name for e in earners_for(DAY)] == ["workout", "focus"]
|
||||
assert earners_for(START - timedelta(days=1)) is TUTOR_EARNERS
|
||||
assert earners_for(START + timedelta(days=4)) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_book_is_a_synonym_and_unreplaced_gates_stay(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project(replaces=("book",)))
|
||||
assert [e.name for e in earners_for(DAY)] == [
|
||||
"workout",
|
||||
"leetcode",
|
||||
"tutor",
|
||||
"focus",
|
||||
]
|
||||
item = earners_for(DAY)[-1]
|
||||
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"changes", [{"replaces": ("anki",)}, {"replaces": ()}, {"daily_minutes": 0}]
|
||||
)
|
||||
def test_a_project_that_changes_nothing_leaves_the_registry(
|
||||
monkeypatch: pytest.MonkeyPatch, changes: dict[str, object]
|
||||
) -> None:
|
||||
activate(monkeypatch, project(**changes))
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_a_full_day_pays_what_the_replaced_gates_paid(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert item.max_units == 120
|
||||
assert (item.gaming_for(120), item.shutdown_for(120, DAY)) == (180, 180)
|
||||
assert (item.gaming_for(999), item.shutdown_for(999, DAY)) == (180, 180)
|
||||
assert (item.max_gaming_minutes, item.gaming_for(-5)) == (180, 0)
|
||||
|
||||
|
||||
def test_pay_is_floored_on_the_summed_minutes(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert [item.gaming_for(n) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
||||
assert [item.shutdown_for(n, DAY) for n in (1, 2, 3, 7)] == [1, 3, 4, 10]
|
||||
|
||||
|
||||
def test_many_small_rows_pay_what_one_row_pays(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
many = ledger_file(tmp_path, [row(1, n) for n in range(1, 8)])
|
||||
one = tmp_path / "one.json"
|
||||
one.write_text(ledger_file(tmp_path, [row(7, 7)]).read_text(encoding="utf-8"))
|
||||
units = credit_units(item, many, key_file, DAY)
|
||||
assert units == credit_units(item, one, key_file, DAY) == 7
|
||||
assert item.gaming_for(units or 0) == 10
|
||||
|
||||
|
||||
def test_resolve_prices_base_terms_and_ceiling(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
activate(monkeypatch, project())
|
||||
nothing = resolve({"workout": 0, "focus": 0}, DAY, leisure=False)
|
||||
assert nothing.base.gaming_minutes == 300 - 120
|
||||
assert nothing.base.shutdown_minutes == 18 * 60
|
||||
half = resolve({"workout": 0, "focus": 60}, DAY, leisure=False)
|
||||
assert half.gaming_minutes == 180 + 90
|
||||
assert half.shutdown_minutes == 18 * 60 + 90
|
||||
full = resolve({"workout": 1, "focus": 120}, DAY, leisure=False)
|
||||
assert full.shutdown_minutes == 23 * 60
|
||||
|
||||
|
||||
def test_a_project_day_is_neutral_against_the_gates_it_replaced(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project())
|
||||
zero = {"workout": 0, "leetcode": 0, "reading": 0, "tutor": 0, "focus": 0}
|
||||
done = {"workout": 1, "leetcode": 1, "reading": 1, "tutor": 60, "focus": 120}
|
||||
for answers in (zero, done):
|
||||
old = resolve(answers, DAY, TUTOR_EARNERS, leisure=False)
|
||||
new = resolve(answers, DAY, leisure=False)
|
||||
assert (new.gaming_minutes, new.shutdown_minutes) == (
|
||||
old.gaming_minutes,
|
||||
old.shutdown_minutes,
|
||||
)
|
||||
|
||||
|
||||
def test_the_penalty_is_not_delayed_by_the_first_credit(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert penalty_start(item, START) == penalty_start(item, None) == START
|
||||
answers = {"workout": 0, "focus": 0}
|
||||
plain = resolve(answers, START, leisure=False)
|
||||
paid = resolve(answers, START, first_credits={"focus": START}, leisure=False)
|
||||
assert paid.base == plain.base
|
||||
assert plain.base.gaming_minutes == 300 - 120
|
||||
|
||||
|
||||
def test_maturity_reads_the_focus_ledger(
|
||||
monkeypatch: pytest.MonkeyPatch, tmp_path: Path, key_file: Path
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
ledger = ledger_file(tmp_path, [row(30, 30, START), row(30, 60, START)])
|
||||
found = maturity(item, ledger, key_file, DAY)
|
||||
assert (found.first_credit, found.penalty_start) == (START, START)
|
||||
|
||||
|
||||
def test_the_first_and_second_minute_follow_the_floor(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
item = focus(monkeypatch)
|
||||
assert shutdown_minutes_for(item, DAY) == 1
|
||||
assert extra_shutdown_minutes_for(item, DAY) == 2
|
||||
assert shutdown_minutes_for(earned_time.ANKI, date(2026, 10, 9)) == 30
|
||||
assert extra_shutdown_minutes_for(earned_time.ANKI, date(2026, 10, 9)) == 0
|
||||
|
||||
|
||||
def test_the_path_unit_watches_the_focus_ledger() -> None:
|
||||
assert earned_time.FOCUS_LEDGER in {e.ledger for e in all_earners()}
|
||||
|
||||
|
||||
def test_focus_pays_only_replaced_gates_that_exist() -> None:
|
||||
item = focus_earner(project(), (earned_time.LEETCODE,), DAY)
|
||||
assert (item.gaming_minutes, item.shutdown_minutes) == (60, 60)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("failure", [ImportError("gone"), ValueError("bad json")])
|
||||
def test_a_broken_focus_project_means_no_project_and_logs_once(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
caplog: pytest.LogCaptureFixture,
|
||||
failure: Exception,
|
||||
) -> None:
|
||||
def broken(_day: date) -> object:
|
||||
raise failure
|
||||
|
||||
monkeypatch.setitem(sys.modules, "focus_project", SimpleNamespace(active=broken))
|
||||
monkeypatch.setattr("earned_time._focus._warned", set())
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
assert sum("behaving as if no project" in m for m in caplog.messages) == 1
|
||||
|
||||
|
||||
def test_a_missing_package_is_no_project(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
monkeypatch.setitem(sys.modules, "focus_project", None)
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
|
||||
|
||||
def test_rows_count_by_ended_at_then_by_day(caplog: pytest.LogCaptureFixture) -> None:
|
||||
window = earned_time.day_window(DAY)
|
||||
assert focus_match(row(5, 5), window)
|
||||
assert not focus_match(row(5, 5, START), window)
|
||||
nostamp = signed(
|
||||
{"kind": "credit", "day": DAY.isoformat(), "detail": {"minutes": 5}}
|
||||
)
|
||||
assert focus_match(nostamp, window)
|
||||
assert not focus_match({**nostamp, "day": START.isoformat()}, window)
|
||||
with caplog.at_level(logging.WARNING):
|
||||
assert not focus_match(row(0, 1), window)
|
||||
assert "bad minutes" in caplog.text
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, -1, True, 1.5, "5", None])
|
||||
def test_minutes_must_be_a_positive_int(bad: object) -> None:
|
||||
assert focus_minutes({"detail": {"minutes": bad}}) is None
|
||||
assert focus_minutes({"detail": "x"}) is None
|
||||
assert focus_minutes({"detail": {"minutes": 4}}) == 4
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.18.0"
|
||||
version = "0.19.0"
|
||||
description = "Penalty-then-reward registry: what every gate app earns in gaming time and shutdown time"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = []
|
||||
|
||||
Loading…
Reference in New Issue
Block a user