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 __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import datetime from datetime import date, datetime
import logging import logging
from typing import TYPE_CHECKING, Final from typing import TYPE_CHECKING, Final
import earned_time import earned_time
from steam_backlog_enforcer._gaming_days import gaming_day_key
from steam_backlog_enforcer._ledger_earners import ( from steam_backlog_enforcer._ledger_earners import (
first_credits, first_credits,
ledger_answer, 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 from steam_backlog_enforcer._workout_budget import workout_logged_today
if TYPE_CHECKING: if TYPE_CHECKING:
from datetime import date
from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.config import Config
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -68,6 +67,9 @@ _SECONDS_PER_MINUTE: Final = 60.0
_SECONDS_PER_HOUR: Final = 3600.0 _SECONDS_PER_HOUR: Final = 3600.0
# The last reason logged, so a long-lived daemon logs a budget only on change. # The last reason logged, so a long-lived daemon logs a budget only on change.
_last_logged: Final[dict[str, str]] = {} _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). # How each earner reads in the reason string: (earned, missed, unknown).
# An earner registered later without an entry here gets a generic phrase. # An earner registered later without an entry here gets a generic phrase.
@ -126,7 +128,11 @@ def _price(
*, *,
leisure: bool, leisure: bool,
) -> earned_time.Resolution: ) -> 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: if first_paid is None:
return earned_time.resolve(answers, today, registry, leisure=leisure) return earned_time.resolve(answers, today, registry, leisure=leisure)
return earned_time.resolve( return earned_time.resolve(
@ -168,6 +174,16 @@ def _answers(
return 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: def resolve_budget(config: Config) -> BudgetResolution:
"""Return today's gaming budget, and what earned it. """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 registry is passed explicitly: _answers iterated this same tuple, so
# the earners asked and the earners summed can never differ. # 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) registry = registry_for(today)
answers = _answers(config, registry) answers = _answers(config, registry)
# Maturity only delays a penalty: a gate that never paid out costs nothing. # Maturity only delays a penalty: a gate that never paid out costs nothing.
first_paid = first_credits(registry, today) 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. # 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 # 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. # 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 ``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 real clock would change its answer on that day, so every such test pins the
date on one side of the cut. date on one side of the cut.
The module asks ``datetime.now().astimezone().date()``, so that chain is what The module asks ``datetime.now().astimezone()``, so that chain is what the
the pin answers. pin answers (at noon, so the gaming day is the same date).
""" """
from __future__ import annotations from __future__ import annotations
from datetime import timedelta from datetime import datetime, time, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch 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. A patch context manager replacing the module's ``datetime`` binding.
""" """
clock = MagicMock() 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) 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. """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: The switch file ``/etc/leisure-watch/leisure_from`` is read per tick by
by ``earned_time`` itself (``is_leisure``, looked up at call time from ``earned_time`` itself (``is_leisure``, looked up at call time from
``earned_time._leisure``) and by the enforcer's gate, which passes its own ``earned_time._leisure``), by the enforcer's billing gate and by its budget
constant explicitly. Both point at an absent file under ``tmp_path``, so no 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 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`. Phase 2 on by writing a date to :func:`switch_file`.
@ -34,6 +35,7 @@ _PROC_NAME = "leisure_proc"
_SWITCH_READERS = ( _SWITCH_READERS = (
"earned_time._leisure.LEISURE_FROM_PATH", "earned_time._leisure.LEISURE_FROM_PATH",
"steam_backlog_enforcer._leisure_mode.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. # Can never exist (/dev/null is not a directory): always the parked date.
_NO_SWITCH = Path(os.devnull) / _SWITCH_NAME _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]