steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime_cutoff.py
Krzysztof kuhy Rudnicki a09f08701a playtime: drop the Fri-Mon carry-over; log budget on change only
A Sunday ran past the 6.0h the i3 bar showed: 2h47m of Saturday's
leftover was carried into the budget the cutoff used, and the 3-second
"Gaming budget" journal line omitted it. Unspent time is now dropped at
06:00 every day; load_state takes a legacy ledger's share back out of
today's high-water mark. The budget line logs only on change (was 25k
lines/day), fired warnings are logged, and budget.jsonl records carry
budget_seconds.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015PrwCnQouKTF4EUMENMxnG
2026-10-18 21:37:29 +02:00

246 lines
9.0 KiB
Python

"""Tests for the main CLI: the warn/cutoff/kill escalation."""
from contextlib import ExitStack
from datetime import datetime, timedelta, timezone
import logging
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer._playtime_cutoff import (
_begin_cutoff,
_kill_set,
_sustain_block,
_warn,
reconcile_for,
)
from steam_backlog_enforcer._playtime_notify import (
notify_desktop_user,
)
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 TestBeginCutoff:
def test_asks_steam_to_close_before_masking_anything(self) -> None:
"""Masking makes `steam -shutdown` a no-op, so order is load-bearing."""
state = PlaytimeState(day_key=TODAY, seconds=60.0)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec,
patch(
"steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown"
) as mock_shutdown,
patch(
"steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"
) as mock_kill,
patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify,
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value={7}
),
):
out = _begin_cutoff(state, _rules(demo=True), now=NOW)
mock_shutdown.assert_called_once()
mock_rec.assert_not_called()
mock_kill.assert_called_once_with({7}, force=False)
mock_notify.assert_called_once()
assert out.blocked_at == NOW.timestamp()
def test_demo_says_out_loud_that_it_will_not_mask(
self, caplog: pytest.LogCaptureFixture
) -> None:
state = PlaytimeState(day_key=TODAY, seconds=60.0)
with (
patch("steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown"),
patch("steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"),
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value=set()
),
):
_begin_cutoff(state, _rules(demo=True), now=NOW)
assert "only the daemon does that" in caplog.text
caplog.clear()
_begin_cutoff(state, _rules(), now=NOW)
assert "only the daemon" not in caplog.text
class TestReconcileFor:
def test_production_drives_the_mounts(self) -> None:
with patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as rec:
reconcile_for(_rules(), should_block=True)
rec.assert_called_once_with(should_block=True)
@pytest.mark.parametrize("should_block", [True, False])
def test_demo_never_touches_them(self, *, should_block: bool) -> None:
with patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as rec:
reconcile_for(_rules(demo=True), should_block=should_block)
rec.assert_not_called()
class TestSustainBlock:
def _run(
self, elapsed: float, *, demo: bool = False
) -> tuple[MagicMock, MagicMock]:
state = PlaytimeState(
day_key=TODAY, seconds=10**6, blocked_at=NOW.timestamp() - elapsed
)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec,
patch(
"steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"
) as mock_kill,
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value={7}
),
):
_sustain_block(state, _rules(demo=demo), now=NOW)
return mock_rec, mock_kill
def test_grace_period_defers_the_mount(self) -> None:
mock_rec, mock_kill = self._run(1.0)
mock_rec.assert_not_called()
mock_kill.assert_called_once_with({7}, force=False)
def test_mounts_once_the_grace_has_passed(self) -> None:
mock_rec, _ = self._run(3.0)
mock_rec.assert_called_once_with(should_block=True)
def test_escalates_to_sigkill(self) -> None:
_, mock_kill = self._run(31.0)
mock_kill.assert_called_once_with({7}, force=True)
def test_no_escalation_just_before_the_threshold(self) -> None:
_, mock_kill = self._run(29.0)
mock_kill.assert_called_once_with({7}, force=False)
def test_demo_never_mounts_but_keeps_killing(self) -> None:
mock_rec, mock_kill = self._run(5.0, demo=True)
mock_rec.assert_not_called()
mock_kill.assert_called_once()
def test_demo_escalates_sooner(self) -> None:
_, mock_kill = self._run(11.0, demo=True)
mock_kill.assert_called_once_with({7}, force=True)
def test_state_is_unchanged(self) -> None:
state = PlaytimeState(day_key=TODAY, seconds=99.0, blocked_at=1.0)
with (
patch("steam_backlog_enforcer._playtime_cutoff.reconcile"),
patch("steam_backlog_enforcer._playtime_cutoff.kill_gaming_processes"),
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value=set()
),
):
out = _sustain_block(state, _rules(), now=NOW)
assert out is state
class TestKillSet:
def test_is_wider_than_the_budget_predicate(self) -> None:
"""A Lutris Wine game is invisible to both budget matchers."""
with (
patch(
"steam_backlog_enforcer._playtime_cutoff.qualifying_pids",
return_value={1},
),
patch(
"steam_backlog_enforcer._playtime_cutoff.steam_and_launcher_pids",
return_value={2},
),
):
assert _kill_set(_rules()) == {1, 2}
class TestWarn:
def test_records_the_threshold(self) -> None:
state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600])
with patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify:
out = _warn(state, _rules())
assert out.warned_seconds == [3600, 1800, 600, 300]
assert "5 minutes" in mock_notify.call_args.args[1]
def test_a_fired_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None:
state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600])
with (
caplog.at_level(logging.INFO),
patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"),
):
_warn(state, _rules())
assert "5 minutes" in caplog.text
def test_no_op_when_nothing_is_due(self) -> None:
state = PlaytimeState(seconds=0.0)
with patch(
"steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"
) as mock_notify:
out = _warn(state, _rules())
mock_notify.assert_not_called()
assert out is state
class TestNotifyDesktopUser:
def test_routes_through_the_desktop_session(self) -> None:
"""notify-send as root with no DBUS address never reaches the user."""
with (
patch(
"steam_backlog_enforcer._playtime_notify._resolve_desktop_user",
return_value="kuhy",
),
patch("steam_backlog_enforcer._playtime_notify._run_as_user") as mock_run,
):
notify_desktop_user("T", "B")
cmd, user = mock_run.call_args.args
assert cmd[0] == "notify-send"
assert user == "kuhy"
def test_swallows_errors(self) -> None:
with (
patch(
"steam_backlog_enforcer._playtime_notify._resolve_desktop_user",
return_value="kuhy",
),
patch(
"steam_backlog_enforcer._playtime_notify._run_as_user",
side_effect=OSError("no session"),
),
):
notify_desktop_user("T", "B")