From 8398ca482333e66959be08aa1975e0863fe8c712 Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 2 Nov 2026 08:23:37 +0100 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy --- earned_time/README.md | 70 +++--- earned_time/earned_time/__init__.py | 3 + earned_time/earned_time/_cli.py | 10 +- earned_time/earned_time/_focus.py | 109 +++++++++ earned_time/earned_time/_ladder.py | 6 +- earned_time/earned_time/_match.py | 27 ++- earned_time/earned_time/_maturity.py | 8 +- earned_time/earned_time/_registry.py | 8 +- earned_time/earned_time/_resolve.py | 13 +- earned_time/earned_time/tests/conftest.py | 10 + earned_time/earned_time/tests/test_focus.py | 248 ++++++++++++++++++++ earned_time/pyproject.toml | 2 +- 12 files changed, 467 insertions(+), 47 deletions(-) create mode 100644 earned_time/earned_time/_focus.py create mode 100644 earned_time/earned_time/tests/test_focus.py diff --git a/earned_time/README.md b/earned_time/README.md index 2400ba6..8297f0b 100644 --- a/earned_time/README.md +++ b/earned_time/README.md @@ -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` `--m`, `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`. diff --git a/earned_time/earned_time/__init__.py b/earned_time/earned_time/__init__.py index 5434d4a..071588e 100644 --- a/earned_time/earned_time/__init__.py +++ b/earned_time/earned_time/__init__.py @@ -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", diff --git a/earned_time/earned_time/_cli.py b/earned_time/earned_time/_cli.py index 9e2ebf6..1b2040e 100644 --- a/earned_time/earned_time/_cli.py +++ b/earned_time/earned_time/_cli.py @@ -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)) diff --git a/earned_time/earned_time/_focus.py b/earned_time/earned_time/_focus.py new file mode 100644 index 0000000..8a274e7 --- /dev/null +++ b/earned_time/earned_time/_focus.py @@ -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)) diff --git a/earned_time/earned_time/_ladder.py b/earned_time/earned_time/_ladder.py index f3372f5..d5742b7 100644 --- a/earned_time/earned_time/_ladder.py +++ b/earned_time/earned_time/_ladder.py @@ -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: diff --git a/earned_time/earned_time/_match.py b/earned_time/earned_time/_match.py index b380372..66d2c4e 100644 --- a/earned_time/earned_time/_match.py +++ b/earned_time/earned_time/_match.py @@ -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 diff --git a/earned_time/earned_time/_maturity.py b/earned_time/earned_time/_maturity.py index 53fc9d1..789090d 100644 --- a/earned_time/earned_time/_maturity.py +++ b/earned_time/earned_time/_maturity.py @@ -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) diff --git a/earned_time/earned_time/_registry.py b/earned_time/earned_time/_registry.py index 904d398..316af48 100644 --- a/earned_time/earned_time/_registry.py +++ b/earned_time/earned_time/_registry.py @@ -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) diff --git a/earned_time/earned_time/_resolve.py b/earned_time/earned_time/_resolve.py index 7c257ad..0022892 100644 --- a/earned_time/earned_time/_resolve.py +++ b/earned_time/earned_time/_resolve.py @@ -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), ) diff --git a/earned_time/earned_time/tests/conftest.py b/earned_time/earned_time/tests/conftest.py index 29bfa4b..23c88d3 100644 --- a/earned_time/earned_time/tests/conftest.py +++ b/earned_time/earned_time/tests/conftest.py @@ -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.""" diff --git a/earned_time/earned_time/tests/test_focus.py b/earned_time/earned_time/tests/test_focus.py new file mode 100644 index 0000000..35b7a43 --- /dev/null +++ b/earned_time/earned_time/tests/test_focus.py @@ -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 diff --git a/earned_time/pyproject.toml b/earned_time/pyproject.toml index 31679bb..4d5b7d5 100644 --- a/earned_time/pyproject.toml +++ b/earned_time/pyproject.toml @@ -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 = []