From bc02f83e7edb9a6f466cae285c16f6fdb02a725d Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Mon, 19 Oct 2026 17:09:33 +0200 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01Dsh4Znwh2KnC7q6vbGpX1N --- steam_backlog_enforcer/_budget_resolve.py | 29 +++++-- steam_backlog_enforcer/tests/_budget_dates.py | 10 ++- .../tests/_isolate_leisure.py | 10 ++- .../tests/test_budget_resolve_leisure.py | 78 +++++++++++++++++++ 4 files changed, 113 insertions(+), 14 deletions(-) create mode 100644 steam_backlog_enforcer/tests/test_budget_resolve_leisure.py diff --git a/steam_backlog_enforcer/_budget_resolve.py b/steam_backlog_enforcer/_budget_resolve.py index 1ff3879..31e6167 100644 --- a/steam_backlog_enforcer/_budget_resolve.py +++ b/steam_backlog_enforcer/_budget_resolve.py @@ -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. diff --git a/steam_backlog_enforcer/tests/_budget_dates.py b/steam_backlog_enforcer/tests/_budget_dates.py index 74c19b4..9fabfae 100644 --- a/steam_backlog_enforcer/tests/_budget_dates.py +++ b/steam_backlog_enforcer/tests/_budget_dates.py @@ -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) diff --git a/steam_backlog_enforcer/tests/_isolate_leisure.py b/steam_backlog_enforcer/tests/_isolate_leisure.py index 4860f2f..71bbfe9 100644 --- a/steam_backlog_enforcer/tests/_isolate_leisure.py +++ b/steam_backlog_enforcer/tests/_isolate_leisure.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py b/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py new file mode 100644 index 0000000..fed0325 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py @@ -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]