mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 19:13:49 +01:00
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
212 lines
8.1 KiB
Python
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()
|