mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 20:07:18 +01:00
earned_time 0.19.1: per-day focus target
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
earned_time pre-commit / pre-commit (push) Waiting to run
earned_time tests / test (push) Waiting to run
file length / file-length (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
palette drift / palette (push) Waiting to run
repo-contract / repo contract (push) Waiting to run
shared gate tests / gate unit tests (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WzxdWYcmik6APnHfc9gEVy
This commit is contained in:
parent
d45b1086c3
commit
c98da93339
@ -193,7 +193,7 @@ on its `day` only if `detail.declared_at` predates that day (else logged, no).
|
||||
|
||||
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,
|
||||
(`max_units` = the day's `daily_on`) 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`:
|
||||
|
||||
@ -61,6 +61,12 @@ def _active(day: date) -> Any: # noqa: ANN401
|
||||
return None
|
||||
|
||||
|
||||
def daily_on(project: Any, day: date) -> int: # noqa: ANN401
|
||||
"""``day``'s target: ``project.daily_on(day)`` (a later edit never re-prices it)."""
|
||||
pick = getattr(project, "daily_on", None)
|
||||
return int(pick(day) if pick is not None else project.daily_minutes)
|
||||
|
||||
|
||||
def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner: # noqa: ANN401
|
||||
"""The ``focus`` earner paying what ``dropped`` paid, over ``project``."""
|
||||
return FocusEarner(
|
||||
@ -74,7 +80,7 @@ def focus_earner(project: Any, dropped: tuple[Earner, ...], day: date) -> Earner
|
||||
shutdown_units_minutes(e, e.max_units or 1, day) for e in dropped
|
||||
),
|
||||
kind="counted",
|
||||
max_units=int(project.daily_minutes),
|
||||
max_units=daily_on(project, day),
|
||||
penalty_from=project.start,
|
||||
confirmed_on=project.start - timedelta(days=1),
|
||||
ledger=FOCUS_LEDGER,
|
||||
@ -103,7 +109,7 @@ def with_focus(registry: tuple[Earner, ...], day: date) -> tuple[Earner, ...]:
|
||||
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:
|
||||
if not dropped or daily_on(project, day) < 1:
|
||||
return registry
|
||||
kept = tuple(e for e in registry if e not in dropped)
|
||||
return (*kept, focus_earner(project, dropped, day))
|
||||
|
||||
60
earned_time/earned_time/tests/_focus_fakes.py
Normal file
60
earned_time/earned_time/tests/_focus_fakes.py
Normal file
@ -0,0 +1,60 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Fakes for the focus tests: a fake ``focus_project`` and signed focus rows."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from earned_time import FocusEarner, earners_for
|
||||
from earned_time.tests._maturity_rows import at, signed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import pytest
|
||||
|
||||
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
|
||||
@ -1,12 +1,9 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""The focus project earner: registry swap, pay, rounding, penalty, fail mode."""
|
||||
"""The focus project earner: registry swap, pay, rounding, penalty."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
@ -14,7 +11,6 @@ import pytest
|
||||
import earned_time
|
||||
from earned_time import (
|
||||
TUTOR_EARNERS,
|
||||
FocusEarner,
|
||||
all_earners,
|
||||
credit_units,
|
||||
earners_for,
|
||||
@ -25,57 +21,12 @@ from earned_time import (
|
||||
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
|
||||
from earned_time.tests._focus_fakes import DAY, START, activate, focus, project, row
|
||||
from earned_time.tests._maturity_rows import ledger_file
|
||||
|
||||
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,
|
||||
@ -203,46 +154,3 @@ def test_the_path_unit_watches_the_focus_ledger() -> None:
|
||||
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
|
||||
|
||||
82
earned_time/earned_time/tests/test_focus_edges.py
Normal file
82
earned_time/earned_time/tests/test_focus_edges.py
Normal file
@ -0,0 +1,82 @@
|
||||
# Copyright (c) 2026 Krzysztof Rudnicki
|
||||
"""Focus project: fail mode, row matching, per-day target."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, timedelta
|
||||
import logging
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
|
||||
from earned_time import TUTOR_EARNERS, day_window, earners_for
|
||||
from earned_time._match import focus_match, focus_minutes
|
||||
from earned_time.tests._focus_fakes import DAY, START, activate, project, row
|
||||
from earned_time.tests._maturity_rows import signed
|
||||
|
||||
|
||||
@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 = 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
|
||||
|
||||
|
||||
def edited(day: date) -> int:
|
||||
"""A target cut from 120 to 60 minutes after ``DAY``."""
|
||||
return 120 if day <= DAY else 60
|
||||
|
||||
|
||||
def test_a_target_edit_never_reprices_past_days(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project(daily_on=edited, daily_minutes=60))
|
||||
before = earners_for(DAY)[-1]
|
||||
after = earners_for(DAY + timedelta(days=1))[-1]
|
||||
assert (before.max_units, before.gaming_for(60)) == (120, 90)
|
||||
assert (after.max_units, after.gaming_for(60)) == (60, 180)
|
||||
assert (before.shutdown_for(60, DAY), after.shutdown_for(30, DAY)) == (90, 90)
|
||||
|
||||
|
||||
def test_a_zero_target_on_a_day_changes_nothing(
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
) -> None:
|
||||
activate(monkeypatch, project(daily_on=lambda _day: 0))
|
||||
assert earners_for(DAY) is TUTOR_EARNERS
|
||||
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "earned-time"
|
||||
version = "0.19.0"
|
||||
version = "0.19.1"
|
||||
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