mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
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:
parent
0ffc773f52
commit
bc02f83e7e
@ -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.
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
78
steam_backlog_enforcer/tests/test_budget_resolve_leisure.py
Normal file
78
steam_backlog_enforcer/tests/test_budget_resolve_leisure.py
Normal 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]
|
||||
Loading…
Reference in New Issue
Block a user