steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime_policy.py
2026-10-10 19:15:11 +02:00

212 lines
8.1 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 dataclasses import replace
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", cutoff),
("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_cutoff.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_cutoff.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_cutoff.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_cutoff.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_cutoff.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_cutoff.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."""
spent = 8 * 3600 - 3000.0
state = PlaytimeState(day_key=TODAY, seconds=spent, warned_seconds=[3600])
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile"),
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
):
out = _policy(state, _rules(), now=NOW)
assert out.warned_seconds == [3600]
assert out.blocked_at == 0.0
def test_a_raise_before_the_cutoff_rearms_warnings(self) -> None:
"""All four fired at a 5h budget, then it rose to 8h (2026-10-04).
No block ever engaged, so the release path never ran; without the
re-arm the new end of the day would come with no warning at all.
"""
state = PlaytimeState(
day_key=TODAY, seconds=5 * 3600.0, warned_seconds=[3600, 1800, 600, 300]
)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile"),
patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify,
):
out = _policy(state, _rules(), now=NOW)
mock_notify.assert_not_called()
drained = _policy(
replace(out, seconds=8 * 3600 - 3500.0), _rules(), now=NOW
)
assert out.warned_seconds == []
assert drained.warned_seconds == [3600]
mock_notify.assert_called_once()