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:
Krzysztof Rudnicki 2026-11-02 08:23:37 +01:00
parent 087ed1c400
commit 8398ca4823
12 changed files with 467 additions and 47 deletions

View File

@ -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`.

View File

@ -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",

View File

@ -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))

View 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))

View File

@ -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:

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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),
)

View File

@ -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."""

View 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

View File

@ -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 = []