steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime.py
Krzysztof kuhy Rudnicki 0c05d71008 Add a daily gaming-time budget with a binary-level cutoff
Accumulates time spent in Steam games and other launchers, and once the
daily budget (default 8h) is spent, shuts Steam down, kills the games, and
makes them unlaunchable until the next 06:00.

The gaming day starts at 06:00 local rather than midnight so a late session
counts against the day it began in; rollover is checked lazily against a
stored day key, so it survives suspend, reboot and daemon restarts with no
timer of its own. Elapsed time comes from wall-clock deltas clamped to two
tick intervals, which keeps suspends, missed ticks and NTP steps honest.

The block is a read-only bind mount of a refusal stub over all four launcher
entry points -- /usr/bin/steam is only a two-line exec wrapper, so masking it
alone leaves bin_steam.sh, Steam's own steam.sh and lutris live. A bind mount
mutates no bytes, umount restores exactly, and a reboot clears it
unconditionally; chmod/rename would leave Steam broken if the daemon died
mid-block. /proc/self/mountinfo is the authority on what is masked, never the
saved state, which is what makes a stale block release even if the state file
was deleted. The mount is deferred one tick past the cutoff so
`steam -shutdown` still works and games close cleanly.

Launchers are matched by cmdline, not comm: /usr/bin/lutris is a python3
script, so the kernel records its comm as "python3" and the existing scan
never sees it. The kill set is wider than the budget predicate, walking down
from each launcher, because a Lutris-launched Wine game carries no SteamAppId.

State is chattr +i'd -- ~/.config is user-writable, so root ownership alone
would not stop `rm` from resetting the counter -- and a missing state file
while mounts are live fails closed rather than lifting the block.

Also fixes a pre-existing bug: do_enforce returned when no game was assigned,
exiting the process. Since the budget is accounted for from inside that loop,
it silently stopped enforcing whenever a game was finished but not rescanned.

Verified on the desktop with a real game: AoE2 counted 18 processes, tripped
at the 60s demo budget, all processes killed, all four paths masked and
refusing, then gaming-unblock restored them byte-identical with
`pacman -Qkk steam lutris` reporting 0 altered files.

1322 tests pass at 100.00% branch coverage; pacman validated the ALPM hook.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01QbEaXapjMSkxyzgvbdkUFb
2026-08-04 20:00:08 +02:00

198 lines
7.3 KiB
Python

"""Tests for playtime day keys, state persistence and rule resolution."""
from __future__ import annotations
from dataclasses import FrozenInstanceError
from datetime import datetime, timedelta, timezone
import json
from unittest.mock import patch
import pytest
from steam_backlog_enforcer._playtime import (
PlaytimeState,
gaming_day_key,
load_state,
rules_for,
save_state,
state_path,
)
from steam_backlog_enforcer.config import Config
PKG = "steam_backlog_enforcer._playtime"
LOCAL = timezone(timedelta(hours=2))
def _at(year: int, month: int, day: int, hour: int, minute: int = 0) -> datetime:
return datetime(year, month, day, hour, minute, tzinfo=LOCAL)
class TestGamingDayKey:
def test_just_before_the_boundary_is_the_previous_day(self) -> None:
assert gaming_day_key(_at(2026, 7, 27, 5, 59)) == "2026-07-26"
def test_exactly_at_the_boundary_starts_the_new_day(self) -> None:
assert gaming_day_key(_at(2026, 7, 27, 6, 0)) == "2026-07-27"
def test_evening_is_the_same_day(self) -> None:
assert gaming_day_key(_at(2026, 7, 27, 23, 30)) == "2026-07-27"
def test_after_midnight_belongs_to_the_day_it_started(self) -> None:
assert gaming_day_key(_at(2026, 7, 28, 1, 15)) == "2026-07-27"
def test_exact_midnight(self) -> None:
assert gaming_day_key(_at(2026, 7, 28, 0, 0)) == "2026-07-27"
def test_month_boundary(self) -> None:
assert gaming_day_key(_at(2026, 8, 1, 3, 0)) == "2026-07-31"
def test_dst_transition_day_still_yields_a_key(self) -> None:
"""The gaming day is 23h or 25h across DST; the budget is not rescaled."""
warsaw_spring_forward = datetime(
2026, 3, 29, 12, 0, tzinfo=timezone(timedelta(hours=2))
)
assert gaming_day_key(warsaw_spring_forward) == "2026-03-29"
class TestStatePath:
def test_production_and_demo_differ(self) -> None:
assert state_path(demo=False) != state_path(demo=True)
def test_demo_path_is_named_demo(self) -> None:
assert "demo" in state_path(demo=True).name
class TestPlaytimeState:
def test_not_blocked_by_default(self) -> None:
assert PlaytimeState().is_blocked() is False
def test_blocked_when_stamped(self) -> None:
assert PlaytimeState(blocked_at=1.0).is_blocked() is True
def test_is_blocked_is_not_serialised(self) -> None:
"""A derived field would be persisted and could drift from blocked_at."""
assert "is_blocked" not in PlaytimeState().__dict__
class TestSaveLoadRoundTrip:
def test_round_trip(self) -> None:
original = PlaytimeState(
day_key="2026-07-27",
seconds=123.5,
last_tick_at=99.0,
blocked_at=42.0,
warned_seconds=[600, 300],
)
save_state(original, demo=False)
loaded = load_state(demo=False)
assert loaded == original
def test_demo_and_production_are_independent(self) -> None:
save_state(PlaytimeState(day_key="prod", seconds=10.0), demo=False)
save_state(PlaytimeState(day_key="demo", seconds=20.0), demo=True)
assert load_state(demo=False).day_key == "prod"
assert load_state(demo=True).day_key == "demo"
def test_missing_file_is_none(self) -> None:
assert load_state(demo=False) is None
def test_corrupt_json_is_none(self) -> None:
state_path(demo=False).write_text("{not json", encoding="utf-8")
assert load_state(demo=False) is None
def test_unreadable_file_is_none(self) -> None:
save_state(PlaytimeState(day_key="x"), demo=False)
with patch(f"{PKG}.Path.read_text", side_effect=OSError("nope")):
assert load_state(demo=False) is None
def test_wrong_schema_version_is_none(self) -> None:
state_path(demo=False).write_text(
json.dumps({"schema_version": 99, "day_key": "x"}), encoding="utf-8"
)
assert load_state(demo=False) is None
def test_non_dict_json_is_none(self) -> None:
state_path(demo=False).write_text("[1, 2, 3]", encoding="utf-8")
assert load_state(demo=False) is None
def test_unknown_keys_are_dropped(self) -> None:
state_path(demo=False).write_text(
json.dumps({"schema_version": 1, "day_key": "d", "bogus": 1}),
encoding="utf-8",
)
loaded = load_state(demo=False)
assert loaded is not None
assert loaded.day_key == "d"
class TestSaveStateImmutability:
def test_production_save_unlocks_then_relocks(self) -> None:
"""rename(2) onto an immutable file is EPERM, so the unlock is required."""
with (
patch(f"{PKG}.unlock_for_write") as mock_unlock,
patch(f"{PKG}._try_set_immutable") as mock_lock,
):
save_state(PlaytimeState(day_key="x"), demo=False)
mock_unlock.assert_called_once_with(state_path(demo=False))
mock_lock.assert_called_once_with(state_path(demo=False), immutable=True)
def test_demo_save_is_left_mutable(self) -> None:
"""An immutable demo file could not be deleted during cleanup."""
with (
patch(f"{PKG}.unlock_for_write") as mock_unlock,
patch(f"{PKG}._try_set_immutable") as mock_lock,
):
save_state(PlaytimeState(day_key="x"), demo=True)
mock_unlock.assert_not_called()
mock_lock.assert_not_called()
class TestRulesFor:
def test_production_budget_comes_from_config(self) -> None:
rules = rules_for(Config(daily_gaming_seconds=100), demo=False)
assert rules.budget_seconds == 100.0
assert rules.demo is False
def test_demo_budget_is_sixty_seconds(self) -> None:
rules = rules_for(Config(daily_gaming_seconds=28800), demo=True)
assert rules.budget_seconds == 60.0
assert rules.demo is True
def test_demo_warns_in_seconds_not_minutes(self) -> None:
assert rules_for(Config(), demo=True).warn_at == (30, 10)
assert rules_for(Config(), demo=False).warn_at == (3600, 1800, 600, 300)
def test_demo_escalates_to_sigkill_sooner(self) -> None:
assert rules_for(Config(), demo=True).sigkill_after == 10.0
assert rules_for(Config(), demo=False).sigkill_after == 30.0
def test_launcher_toggle_is_carried_through(self) -> None:
assert (
rules_for(
Config(count_launcher_processes=False), demo=False
).count_launchers
is False
)
assert (
rules_for(Config(count_launcher_processes=True), demo=False).count_launchers
is True
)
def test_enforcement_toggle_is_carried_through(self) -> None:
assert (
rules_for(Config(playtime_enforcement=False), demo=False).enforcement
is False
)
def test_demo_uses_the_same_qualifying_predicate(self) -> None:
"""Demo may differ in budget and warnings only."""
prod = rules_for(Config(count_launcher_processes=True), demo=False)
demo = rules_for(Config(count_launcher_processes=True), demo=True)
assert prod.count_launchers == demo.count_launchers
def test_rules_are_frozen(self) -> None:
"""Frozen so the decision helpers stay pure and cheap to reason about."""
rules = rules_for(Config(), demo=False)
with pytest.raises(FrozenInstanceError):
rules.budget_seconds = 1.0