utils/earned_time/earned_time/tests/test_focus_edges.py
Krzysztof kuhy Rudnicki ba4dd58545
Some checks failed
dependency freshness / dependencies on newest stable (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
earned_time pre-commit / pre-commit (push) Has been cancelled
earned_time tests / test (push) Has been cancelled
leetcode-coach pre-commit / pre-commit (push) Has been cancelled
leetcode-coach tests / test (push) Has been cancelled
earned_time 0.21.0: focus day pays 240 gaming
kuhy 2026-11-02: keep the 180 base cut, pay 2 gaming min per focus min
(240 at 120/120), so a full focus day is 8 h again; an idle day stays 2 h.
FocusEarner.cut_gaming_minutes holds the cut (_resolve._gaming_cut) so the
pay (cut + pure bonuses again) can exceed it. Shutdown unchanged. Carry
rows (focus_project 0.6.0, <project>-<YYYYMMDD>-carry) pay like own rows;
test added. Failure modes unchanged: focus_project unusable = no project
(fail-open, logged); unreadable ledger = None, earns nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01WzxdWYGW5NZL5uVpnqmNKo
2026-11-02 15:22:10 +01:00

83 lines
3.0 KiB
Python

# 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, 120)
assert (after.max_units, after.gaming_for(60)) == (60, 240)
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