budget: price the enforced budget by the 06:00 gaming day's leisure switch

earned_time 0.16.0 made resolve(leisure=False) force pre-leisure pricing,
so the enforced call passing False would have stayed 5 h/8 h after
go-live. It now passes leisure_active(gaming day) explicitly (its own
redirectable LEISURE_FROM_PATH): the go-live gaming day is 7 h/10 h, and
00:00-05:59 on the go-live date stays pre-leisure, matching billing. The
shadow resolve keeps leisure=True.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N
This commit is contained in:
Krzysztof Rudnicki 2026-10-19 17:09:33 +02:00
parent 0ffc773f52
commit bc02f83e7e
4 changed files with 113 additions and 14 deletions

View File

@ -41,12 +41,13 @@ resolving a second time.
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from datetime import date, datetime
import logging
from typing import TYPE_CHECKING, Final
import earned_time
from steam_backlog_enforcer._gaming_days import gaming_day_key
from steam_backlog_enforcer._ledger_earners import (
first_credits,
ledger_answer,
@ -58,8 +59,6 @@ from steam_backlog_enforcer._reading_bonus import read_today
from steam_backlog_enforcer._workout_budget import workout_logged_today
if TYPE_CHECKING:
from datetime import date
from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__)
@ -68,6 +67,9 @@ _SECONDS_PER_MINUTE: Final = 60.0
_SECONDS_PER_HOUR: Final = 3600.0
# The last reason logged, so a long-lived daemon logs a budget only on change.
_last_logged: Final[dict[str, str]] = {}
# Passed explicitly: ``leisure_active``'s default is bound at import, so this
# module constant is what a test redirects (as ``_leisure_mode`` does).
LEISURE_FROM_PATH: Final = earned_time.LEISURE_FROM_PATH
# How each earner reads in the reason string: (earned, missed, unknown).
# An earner registered later without an entry here gets a generic phrase.
@ -126,7 +128,11 @@ def _price(
*,
leisure: bool,
) -> earned_time.Resolution:
"""Price one set of answers; ``leisure=False`` is exactly the old call."""
"""Price one set of answers: ``leisure`` True = leisure, False = pre-leisure.
Always explicit (earned_time 0.16.0): ``False`` forces pre-leisure pricing,
it no longer means "by the switch file".
"""
if first_paid is None:
return earned_time.resolve(answers, today, registry, leisure=leisure)
return earned_time.resolve(
@ -168,6 +174,16 @@ def _answers(
return answers
def leisure_priced(now: datetime) -> bool:
"""Whether the 06:00 gaming day holding *now* is priced as leisure.
The same question billing asks (``_leisure_mode.leisure_on`` of the billed
day), so 00:00-05:59 on the go-live date stays pre-leisure, like its bill.
"""
day = date.fromisoformat(gaming_day_key(now))
return bool(earned_time.leisure_active(day, LEISURE_FROM_PATH))
def resolve_budget(config: Config) -> BudgetResolution:
"""Return today's gaming budget, and what earned it.
@ -181,12 +197,13 @@ def resolve_budget(config: Config) -> BudgetResolution:
"""
# The registry is passed explicitly: _answers iterated this same tuple, so
# the earners asked and the earners summed can never differ.
today = datetime.now().astimezone().date()
now = datetime.now().astimezone()
today = now.date()
registry = registry_for(today)
answers = _answers(config, registry)
# Maturity only delays a penalty: a gate that never paid out costs nothing.
first_paid = first_credits(registry, today)
day = _price(answers, today, registry, first_paid, leisure=False)
day = _price(answers, today, registry, first_paid, leisure=leisure_priced(now))
# The same answers, asked once: the shadow can never see a different day.
# A missing answer was already warned about above; to earned_time it is
# the same as None, so spell it out rather than warn a second time.

View File

@ -5,13 +5,13 @@ on: ``READING.penalty_from``, delayed by maturity (0.6.0) to the day after
``confirmed_on`` when no credit is on record. A budget test that reads the
real clock would change its answer on that day, so every such test pins the
date on one side of the cut.
The module asks ``datetime.now().astimezone().date()``, so that chain is what
the pin answers.
The module asks ``datetime.now().astimezone()``, so that chain is what the
pin answers (at noon, so the gaming day is the same date).
"""
from __future__ import annotations
from datetime import timedelta
from datetime import datetime, time, timedelta
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
@ -40,5 +40,7 @@ def pin_today(day: date) -> AbstractContextManager[object]:
A patch context manager replacing the module's ``datetime`` binding.
"""
clock = MagicMock()
clock.now.return_value.astimezone.return_value.date.return_value = day
# A real aware noon: the module also takes the 06:00 gaming day from it.
noon = datetime.combine(day, time(12)).astimezone()
clock.now.return_value.astimezone.return_value = noon
return patch("steam_backlog_enforcer._budget_resolve.datetime", clock)

View File

@ -1,9 +1,10 @@
"""Autouse isolation of the Phase-2 leisure switch and the leisure kill scan.
The switch file ``/etc/leisure-watch/leisure_from`` is read twice per tick:
by ``earned_time`` itself (``is_leisure``, looked up at call time from
``earned_time._leisure``) and by the enforcer's gate, which passes its own
constant explicitly. Both point at an absent file under ``tmp_path``, so no
The switch file ``/etc/leisure-watch/leisure_from`` is read per tick by
``earned_time`` itself (``is_leisure``, looked up at call time from
``earned_time._leisure``), by the enforcer's billing gate and by its budget
pricing, which pass their own constants explicitly. All point at an absent
file under ``tmp_path``, so no
test ever reads the real one and every test starts dormant. A test turns
Phase 2 on by writing a date to :func:`switch_file`.
@ -34,6 +35,7 @@ _PROC_NAME = "leisure_proc"
_SWITCH_READERS = (
"earned_time._leisure.LEISURE_FROM_PATH",
"steam_backlog_enforcer._leisure_mode.LEISURE_FROM_PATH",
"steam_backlog_enforcer._budget_resolve.LEISURE_FROM_PATH",
)
# Can never exist (/dev/null is not a directory): always the parked date.
_NO_SWITCH = Path(os.devnull) / _SWITCH_NAME

View File

@ -0,0 +1,78 @@
"""The enforced budget is priced by the 06:00 gaming day, like billing.
earned_time 0.16.0: ``resolve(leisure=False)`` forces pre-leisure pricing, so
the enforcer asks ``leisure_active`` for the gaming day itself; 00:00-05:59 on
the go-live date still belongs to the day before.
"""
from __future__ import annotations
from datetime import datetime, time, timedelta
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import earned_time
import pytest
from steam_backlog_enforcer._budget_resolve import resolve_budget
from steam_backlog_enforcer.config import Config
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT
from steam_backlog_enforcer.tests._isolate_leisure import switch_file
if TYPE_CHECKING:
from pathlib import Path
_HOUR = 3600.0
def _at(hour: int) -> MagicMock:
"""A ``datetime`` stand-in whose ``now().astimezone()`` is AFTER_CUT at *hour*."""
clock = MagicMock()
clock.now.return_value.astimezone.return_value = datetime.combine(
AFTER_CUT, time(hour)
).astimezone()
return clock
def _resolve(hour: int) -> tuple[float, list[object]]:
"""Enforced base seconds at *hour*, and every ``leisure=`` resolve was given."""
with (
patch("steam_backlog_enforcer._budget_resolve.datetime", _at(hour)),
patch.object(earned_time, "resolve", wraps=earned_time.resolve) as spy,
):
base = resolve_budget(Config()).base_seconds
return base, [c.kwargs["leisure"] for c in spy.call_args_list]
@pytest.fixture
def dormant(tmp_path: Path) -> float:
"""The dormant base at noon, then the switch dated the go-live day."""
base = _resolve(12)[0]
switch_file(tmp_path).write_text(AFTER_CUT.isoformat() + "\n", encoding="ascii")
return base
def test_dormant_prices_pre_leisure_with_a_leisure_shadow() -> None:
assert _resolve(12)[1] == [False, True]
@pytest.mark.parametrize("hour", [6, 12, 23])
def test_the_go_live_gaming_day_is_priced_as_leisure(dormant: float, hour: int) -> None:
base, leisure = _resolve(hour)
assert leisure == [True, True]
assert base - dormant == 2 * _HOUR
@pytest.mark.parametrize("hour", [0, 3, 5])
def test_before_06_on_the_go_live_date_stays_pre_leisure(
dormant: float, hour: int
) -> None:
base, leisure = _resolve(hour)
assert leisure == [False, True]
assert base == dormant
def test_a_switch_dated_tomorrow_is_dormant_today(tmp_path: Path) -> None:
tomorrow = (AFTER_CUT + timedelta(days=1)).isoformat()
switch_file(tmp_path).write_text(tomorrow + "\n", encoding="ascii")
assert _resolve(12)[1] == [False, True]