steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime_tick.py
Krzysztof kuhy Rudnicki 63321f16cc
Some checks are pending
dependency freshness / dependencies on newest stable (push) Waiting to run
md-naming / markdown naming convention (push) Waiting to run
pre-commit / pre-commit (push) Waiting to run
Tests / test (push) Waiting to run
Pay an hour of gaming budget for a LeetCode solve
The budget was binary: 8h with a counted workout, 6h without. There was no
base/bonus structure anywhere -- resolve_budget_seconds returned one of two
absolute config values, and the "2h bonus" was emergent arithmetic (8 - 6) that
appeared in no line of code. A second earner could not be bolted onto that
shape without coupling the two.

It is now a sum: 5h floor + 2h workout + 1h LeetCode, so a day is worth 5, 6, 7
or 8 hours. Note the deliberate tightening -- a workout-only day drops 8h to
7h, and a day that earns nothing drops 6h to 5h.

The two earners are read independently and added. That is what "must not
interfere with the workout" means in code: an unreadable LeetCode ledger leaves
a workout day at 7h, not 5h, and there is a test asserting exactly that.

LeetCode is read from leetcode-guard's ledger directly -- it answers even when
leetcode-guard is not running, which is most of the day -- and falls back to its
loopback endpoint. Credits are signature-checked; _leetcode_ledger reproduces
gatelock's canonicalisation in eight lines because gatelock lives in the user's
site-packages and this daemon runs as root, and a pinned vector fails loudly if
that ever drifts.

Failing to get an answer grants nothing AND raises an incident: desktop
notification, warning log, and a line in leetcode_bonus_incidents.log
(gitignored, so it never becomes an uncommitted change), rate-limited to once
per reason per day. A silently missing hour looks exactly like an unearned one.

resolve_budget_seconds is *renamed*, not wrapped: two functions would recreate
the second resolution seam this module's docstring warns about. rules_for calls
it once and carries the breakdown on PlaytimeRules, so /api/budget reports why
today is what it is without resolving again.

Known limitation, accepted: leetcode-guard only writes credits from its lock
window's poll loop, so a voluntary extra solve after the day is settled never
reaches the ledger.

Verified live against real services -- all four cases (5/6/7/8h), the endpoint
fallback, the fail-closed path with its notification, and the rate limit.
1782 tests, 100% branch coverage.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Fp6zWH9zW1AkGdDyKjrbi8
2026-08-30 15:22:18 +02:00

227 lines
8.4 KiB
Python

"""Tests for the playtime tick state machine and cutoff sequence."""
from contextlib import ExitStack
from datetime import (
datetime,
timedelta,
timezone,
)
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._playtime import (
_state_or_recover,
playtime_tick,
)
from steam_backlog_enforcer._playtime_state import (
PlaytimeState,
load_state,
rules_for,
save_state,
)
from steam_backlog_enforcer.config import Config
from steam_backlog_enforcer.tests._fake_session import fake_session
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 TestStateOrRecover:
def test_loads_existing_state(self) -> None:
save_state(PlaytimeState(day_key=TODAY, seconds=42.0), demo=False)
with patch(f"{PKG}.mounted_targets", return_value=set()):
out = _state_or_recover(_rules(), now=NOW)
assert out.seconds == 42.0
def test_fresh_state_when_nothing_stored_and_nothing_mounted(self) -> None:
with patch(f"{PKG}.mounted_targets", return_value=set()):
out = _state_or_recover(_rules(), now=NOW)
assert out.seconds == 0.0
assert out.is_blocked() is False
assert out.day_key == TODAY
def test_fails_closed_when_state_is_gone_but_mounts_remain(self) -> None:
"""Deleting the state file must not be a way to lift the block."""
rules = _rules(
base_gaming_seconds=100, workout_bonus_seconds=0, leetcode_bonus_seconds=0
)
with patch(f"{PKG}.mounted_targets", return_value={"/usr/bin/steam"}):
out = _state_or_recover(rules, now=NOW)
assert out.seconds == 100.0
assert out.is_blocked() is True
def test_fails_closed_on_corrupt_state(self) -> None:
from steam_backlog_enforcer._playtime_state import state_path
state_path(demo=False).write_text("{bad", encoding="utf-8")
with patch(f"{PKG}.mounted_targets", return_value={"/usr/bin/steam"}):
out = _state_or_recover(
_rules(
base_gaming_seconds=50,
workout_bonus_seconds=0,
leetcode_bonus_seconds=0,
),
now=NOW,
)
assert out.is_blocked() is True
class TestPlaytimeTick:
def test_accumulates_while_a_game_runs(self, quiet_tick: dict) -> None:
save_state(
PlaytimeState(day_key=TODAY, last_tick_at=NOW.timestamp() - 3.0),
demo=False,
)
with (
patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=fake_session())
assert load_state(demo=False).seconds == 3.0
def test_records_the_day_in_the_history(self, quiet_tick: dict) -> None:
del quiet_tick
save_state(
PlaytimeState(day_key=TODAY, last_tick_at=NOW.timestamp() - 3.0),
demo=False,
)
session = fake_session()
with (
patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=session)
assert session.history.recorded == [(TODAY, 3.0)]
def test_records_the_day_even_under_a_total_block(self, quiet_tick: dict) -> None:
"""The counter still needs a history point while the block is up."""
del quiet_tick
save_state(
PlaytimeState(
day_key=TODAY, seconds=5.0, last_tick_at=NOW.timestamp() - 3.0
),
demo=False,
)
session = fake_session()
with (
patch(f"{PKG}.is_total_block_active", return_value=True),
patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=session)
assert session.history.recorded == [(TODAY, 5.0)]
def test_total_block_releases_and_stops_accruing(self, quiet_tick: dict) -> None:
"""Our mounts would make the total block's `pacman -R steam` fail."""
save_state(
PlaytimeState(
day_key=TODAY, seconds=5.0, last_tick_at=NOW.timestamp() - 3.0
),
demo=False,
)
with (
patch(f"{PKG}.is_total_block_active", return_value=True),
patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=fake_session())
quiet_tick["reconcile"].assert_called_once_with(should_block=False)
assert load_state(demo=False).seconds == 5.0
def test_rollover_clears_a_stale_block(self, quiet_tick: dict) -> None:
save_state(
PlaytimeState(
day_key="2026-07-26",
seconds=10**6,
blocked_at=1.0,
last_tick_at=NOW.timestamp() - 3.0,
),
demo=False,
)
with (
patch(f"{PKG}.qualifying_pids", return_value={}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=fake_session())
stored = load_state(demo=False)
assert stored.day_key == TODAY
assert stored.seconds == 0.0
assert stored.is_blocked() is False
quiet_tick["reconcile"].assert_called_with(should_block=False)
def test_cutoff_engages_at_the_budget(self, quiet_tick: dict) -> None:
save_state(
PlaytimeState(
day_key=TODAY, seconds=59.0, last_tick_at=NOW.timestamp() - 3.0
),
demo=True,
)
with (
patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}),
patch(
"steam_backlog_enforcer._playtime_cutoff._kill_set", return_value={7}
),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=fake_session(), demo=True)
stored = load_state(demo=True)
assert stored.is_blocked() is True
quiet_tick["request_steam_shutdown"].assert_called_once()
def test_demo_uses_a_separate_state_file(self, quiet_tick: dict) -> None:
"""A demo run must never consume the real day's budget."""
with (
patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=fake_session(), demo=True)
assert load_state(demo=True) is not None
assert load_state(demo=False) is None
def test_first_tick_writes_state(self, quiet_tick: dict) -> None:
with (
patch(f"{PKG}.qualifying_pids", return_value={}),
patch(f"{PKG}.datetime") as mock_dt,
):
mock_dt.now.return_value = NOW
playtime_tick(Config(), interval=3.0, session=fake_session())
stored = load_state(demo=False)
assert stored is not None
assert stored.day_key == TODAY