mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:53:48 +01:00
8h is now earned rather than given. A gaming day with a counted workout logged in screen-locker gets daily_gaming_seconds; a day without gets the new unearned_gaming_seconds floor. Skipping a workout costs two hours the same day, instead of nothing until the weekly lock fires. The fact comes from screen-locker's GET /api/status, which reports only workout_today -- the hour values live here. Neither repo can drift from the other about what a workout day is: that flag is derived from count_day_credits, the same rule the locker enforces with. Every way of not getting an answer applies the floor, logged at warning with the URL and the concrete error. Failing open would let the coupling be defeated by stopping one user service, and a silent 8h would be indistinguishable from an earned one. resolve_budget_seconds is called from rules_for rather than at each call site, because the daemon holds one Config for its whole life while _budget_view reloads it per HTTP request. Resolved at only one of those and the web UI would report a budget the daemon was not enforcing -- the exact silent divergence this codebase is built to prevent. A budget that rises mid-day now releases the block properly. _release_after_raise clears blocked_at, which otherwise only roll_over reset and which would leave the UI reporting "blocked" while gaming worked, and clears warned_seconds, which is keyed on seconds *remaining* and would otherwise let the extra two hours arrive with no warnings at all. Known and accepted (2026-08-29): the budget can DROP mid-day if the resolved answer changes under a day already in progress, re-pricing time already spent and cutting off on the next tick with no warning. The module docstring says so plainly rather than claiming a monotonicity the code does not implement; the fix, if it ever bites, is a persisted granted_budget_seconds. Tests get an autouse stub for the lookup. Without it every test that builds rules makes a real loopback request, and the suite would pass in the morning and fail in the evening depending on whether the machine's owner had trained. test_budget_view builds its RULES at import time, before fixtures apply, so it stubs the resolver explicitly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Fcwu78wk17FWMHbbfbyicc
185 lines
7.0 KiB
Python
185 lines
7.0 KiB
Python
"""Tests for the budget policy decision — warn, cut off, sustain.
|
|
|
|
Split out of ``test_playtime_tick.py`` to hold both files inside the
|
|
250-line cap. ``_policy`` is a pure decision over state and rules; the tick
|
|
tests exercise the loop that feeds it.
|
|
"""
|
|
|
|
from contextlib import ExitStack
|
|
from datetime import (
|
|
datetime,
|
|
timedelta,
|
|
timezone,
|
|
)
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
from steam_backlog_enforcer._playtime import (
|
|
_policy,
|
|
)
|
|
from steam_backlog_enforcer._playtime_state import (
|
|
PlaytimeState,
|
|
rules_for,
|
|
)
|
|
from steam_backlog_enforcer.config import Config
|
|
|
|
PKG = "steam_backlog_enforcer._playtime"
|
|
LOCAL = timezone(timedelta(hours=2))
|
|
NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL)
|
|
TODAY = "2026-07-27"
|
|
|
|
|
|
@pytest.fixture
|
|
def quiet_tick() -> object:
|
|
"""Neutralise every side effect the tick can produce."""
|
|
with ExitStack() as stack:
|
|
# Each name is patched in the module that *resolves* it, not in the
|
|
# package that re-exports it -- otherwise the patch never bites.
|
|
cutoff = "steam_backlog_enforcer._playtime_cutoff"
|
|
mocks = {
|
|
name: stack.enter_context(patch(f"{where}.{name}"))
|
|
for name, where in (
|
|
("reconcile", PKG),
|
|
("request_steam_shutdown", cutoff),
|
|
("kill_gaming_processes", cutoff),
|
|
("notify_desktop_user", cutoff),
|
|
("mounted_targets", PKG),
|
|
)
|
|
}
|
|
mocks["mounted_targets"].return_value = set()
|
|
stack.enter_context(patch(f"{PKG}.is_total_block_active", return_value=False))
|
|
yield mocks
|
|
|
|
|
|
def _rules(*, demo: bool = False, **cfg: object) -> object:
|
|
return rules_for(Config(**cfg), demo=demo)
|
|
|
|
|
|
class TestPolicyBelowBudget:
|
|
def test_releases_and_warns(self) -> None:
|
|
state = PlaytimeState(day_key=TODAY, seconds=8 * 3600 - 3600)
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile") as mock_rec,
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
|
|
) as mock_notify,
|
|
):
|
|
out = _policy(state, _rules(), now=NOW)
|
|
mock_rec.assert_called_once_with(should_block=False)
|
|
mock_notify.assert_called_once()
|
|
assert out.warned_seconds == [3600]
|
|
|
|
def test_no_warning_when_far_from_budget(self) -> None:
|
|
state = PlaytimeState(day_key=TODAY, seconds=0.0)
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile"),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
|
|
) as mock_notify,
|
|
):
|
|
out = _policy(state, _rules(), now=NOW)
|
|
mock_notify.assert_not_called()
|
|
assert out.warned_seconds == []
|
|
|
|
|
|
class TestPolicyAtOrOverBudget:
|
|
def test_engages_the_cutoff_when_not_yet_blocked(self) -> None:
|
|
state = PlaytimeState(day_key=TODAY, seconds=10**6)
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime._begin_cutoff") as mock_begin,
|
|
patch("steam_backlog_enforcer._playtime._sustain_block") as mock_sustain,
|
|
):
|
|
_policy(state, _rules(), now=NOW)
|
|
mock_begin.assert_called_once()
|
|
mock_sustain.assert_not_called()
|
|
|
|
def test_sustains_the_block_once_engaged(self) -> None:
|
|
state = PlaytimeState(day_key=TODAY, seconds=10**6, blocked_at=1.0)
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime._begin_cutoff") as mock_begin,
|
|
patch("steam_backlog_enforcer._playtime._sustain_block") as mock_sustain,
|
|
):
|
|
_policy(state, _rules(), now=NOW)
|
|
mock_begin.assert_not_called()
|
|
mock_sustain.assert_called_once()
|
|
|
|
|
|
class TestPolicyEnforcementDisabled:
|
|
def test_releases_but_does_not_block(self) -> None:
|
|
"""Disabling must never come to mean 'blocked forever'."""
|
|
state = PlaytimeState(day_key=TODAY, seconds=10**6)
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile") as mock_rec,
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown"
|
|
) as mock_shutdown,
|
|
):
|
|
out = _policy(state, _rules(playtime_enforcement=False), now=NOW)
|
|
mock_rec.assert_called_once_with(should_block=False)
|
|
mock_shutdown.assert_not_called()
|
|
assert out.is_blocked() is False
|
|
|
|
|
|
class TestPolicyBudgetRoseMidDay:
|
|
"""Logging a workout raises the budget past what has already been spent.
|
|
|
|
Before the workout coupling the budget could not move within a day, so
|
|
``blocked_at`` and ``warned_seconds`` were only ever reset by the 06:00
|
|
rollover. Both now have to come down with the mounts.
|
|
"""
|
|
|
|
def _blocked_at_six_hours(self) -> PlaytimeState:
|
|
"""Return state that hit the unearned 6h budget and got cut off."""
|
|
return PlaytimeState(
|
|
day_key=TODAY,
|
|
seconds=6 * 3600,
|
|
blocked_at=1.0,
|
|
warned_seconds=[3600, 1800, 600, 300],
|
|
)
|
|
|
|
def test_the_block_is_released(self) -> None:
|
|
"""reconcile is asked to unmount, which is what re-enables gaming."""
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile") as mock_rec,
|
|
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
|
|
):
|
|
_policy(self._blocked_at_six_hours(), _rules(), now=NOW)
|
|
mock_rec.assert_called_once_with(should_block=False)
|
|
|
|
def test_blocked_at_is_cleared(self) -> None:
|
|
"""Otherwise the UI reports "blocked" while gaming works fine."""
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile"),
|
|
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
|
|
):
|
|
out = _policy(self._blocked_at_six_hours(), _rules(), now=NOW)
|
|
assert out.is_blocked() is False
|
|
|
|
def test_warnings_are_re_armed(self) -> None:
|
|
"""Thresholds key on seconds *remaining*, so a raise re-crosses them.
|
|
|
|
Left alone, the extra two hours would arrive with no warning at all.
|
|
"""
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile"),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
|
|
) as mock_notify,
|
|
):
|
|
out = _policy(self._blocked_at_six_hours(), _rules(), now=NOW)
|
|
# Two hours remain, so nothing is due yet -- but the record is clear,
|
|
# so the 1h/30m/10m/5m warnings will fire again as it drains.
|
|
assert out.warned_seconds == []
|
|
mock_notify.assert_not_called()
|
|
|
|
def test_an_unblocked_day_is_left_alone(self) -> None:
|
|
"""The release path must not fire on an ordinary under-budget tick."""
|
|
state = PlaytimeState(day_key=TODAY, seconds=100.0, warned_seconds=[3600])
|
|
with (
|
|
patch("steam_backlog_enforcer._playtime.reconcile"),
|
|
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
|
|
):
|
|
out = _policy(state, _rules(), now=NOW)
|
|
assert out.warned_seconds == [3600]
|