mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 21:53:48 +01:00
Each of these was one file doing several jobs, so they are split along what they actually do rather than at arbitrary line offsets: - _total_block: the lock file and the status derived from it stay in _total_block; making Steam *not installed* moves to _total_block_purge, making it *unreachable* to _total_block_hosts / _total_block_iptables. The generic pacman verbs (owner, installed, remove) become _pacman, since nothing about them is total-block-specific. Domain and process name tables become data modules. - _playtime: budget state and persistence (_playtime_state), pure transitions (_playtime_budget), cmdline process detection (_playtime_procs), the warn/cutoff/kill ladder (_playtime_cutoff), desktop notifications (_playtime_notify), process-tree walking and killing (_playtime_kill), mountinfo parsing (_playtime_mountinfo) and the shared privileged-command helper (_playtime_run). - _mcp: read-only tools (_mcp_query) split from mutating ones (_mcp_actions, _mcp_gaming_time), so "what can this server change?" is answerable from one file. _mcp keeps the server object and entry point. No re-export shims: callers import from the module that defines the name. That keeps a stale patch target loud (AttributeError) instead of letting it pass while intercepting nothing. `ruff check --select F821,F401` is run after every extraction -- an earlier mechanical attempt moved functions without the module-level names they referenced, which imported fine and only failed when called (349 errors), so it was reverted and redone by hand. The autouse fixtures in conftest are now imported by name and held by __all__; ruff --unsafe-fixes had deleted them as unused, since pytest resolves fixtures by name. 1324 tests pass at 100.00% coverage in the clean venv; `main status` is byte-identical to a worktree at HEAD. 49 -> 39 files over the cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
184 lines
7.0 KiB
Python
184 lines
7.0 KiB
Python
"""Tests for the main CLI: cmdline-based process detection."""
|
|
|
|
from datetime import datetime, timedelta, timezone
|
|
import os
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
from steam_backlog_enforcer._playtime_notify import (
|
|
_humanise,
|
|
)
|
|
from steam_backlog_enforcer._playtime_procs import (
|
|
_match_cmdline,
|
|
get_pids_by_cmdline_names,
|
|
qualifying_pids,
|
|
)
|
|
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)
|
|
INTERVAL = 3.0
|
|
|
|
|
|
def _state_ticked(seconds_ago: float, **kwargs: object) -> PlaytimeState:
|
|
return PlaytimeState(last_tick_at=NOW.timestamp() - seconds_ago, **kwargs)
|
|
|
|
|
|
class TestMatchCmdline:
|
|
def _proc(self, tmp_path: Path, pid: int, argv: list[str]) -> Path:
|
|
entry = tmp_path / "playtime_proc" / str(pid)
|
|
entry.mkdir(parents=True, exist_ok=True)
|
|
(entry / "cmdline").write_bytes(
|
|
b"\x00".join(a.encode() for a in argv) + b"\x00"
|
|
)
|
|
return entry
|
|
|
|
def test_direct_argv0_match(self, tmp_path: Path) -> None:
|
|
entry = self._proc(tmp_path, 10, ["/usr/bin/lutris", "--foo"])
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) == "lutris"
|
|
|
|
def test_interpreter_falls_through_to_argv1(self, tmp_path: Path) -> None:
|
|
"""The real case: /usr/bin/lutris is a python3 script."""
|
|
entry = self._proc(tmp_path, 11, ["/usr/bin/python3", "/usr/bin/lutris"])
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) == "lutris"
|
|
|
|
def test_interpreter_with_no_argv1(self, tmp_path: Path) -> None:
|
|
entry = self._proc(tmp_path, 12, ["python3"])
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) is None
|
|
|
|
def test_interpreter_running_something_else(self, tmp_path: Path) -> None:
|
|
entry = self._proc(tmp_path, 13, ["python3", "/usr/bin/other"])
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) is None
|
|
|
|
def test_non_interpreter_no_match(self, tmp_path: Path) -> None:
|
|
entry = self._proc(tmp_path, 14, ["/usr/bin/firefox"])
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) is None
|
|
|
|
def test_empty_cmdline(self, tmp_path: Path) -> None:
|
|
entry = tmp_path / "playtime_proc" / "15"
|
|
entry.mkdir(parents=True, exist_ok=True)
|
|
(entry / "cmdline").write_bytes(b"")
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) is None
|
|
|
|
def test_unreadable_cmdline(self, tmp_path: Path) -> None:
|
|
entry = tmp_path / "playtime_proc" / "16"
|
|
entry.mkdir(parents=True, exist_ok=True)
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) is None
|
|
|
|
def test_undecodable_bytes_do_not_raise(self, tmp_path: Path) -> None:
|
|
entry = tmp_path / "playtime_proc" / "17"
|
|
entry.mkdir(parents=True, exist_ok=True)
|
|
(entry / "cmdline").write_bytes(b"\xff\xfe\x00")
|
|
assert _match_cmdline(entry, frozenset({"lutris"})) is None
|
|
|
|
|
|
class TestGetPidsByCmdlineNames:
|
|
def _proc(self, tmp_path: Path, pid: int, argv: list[str]) -> None:
|
|
entry = tmp_path / "playtime_proc" / str(pid)
|
|
entry.mkdir(parents=True, exist_ok=True)
|
|
(entry / "cmdline").write_bytes(
|
|
b"\x00".join(a.encode() for a in argv) + b"\x00"
|
|
)
|
|
|
|
def test_finds_matches(self, tmp_path: Path) -> None:
|
|
self._proc(tmp_path, 100, ["python3", "/usr/bin/lutris"])
|
|
self._proc(tmp_path, 101, ["/usr/bin/firefox"])
|
|
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {100: "lutris"}
|
|
|
|
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
|
|
(tmp_path / "playtime_proc" / "cpuinfo").touch()
|
|
(tmp_path / "playtime_proc" / "self").mkdir(exist_ok=True)
|
|
self._proc(tmp_path, 102, ["python3", "/usr/bin/lutris"])
|
|
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {102: "lutris"}
|
|
|
|
def test_never_matches_our_own_pid(self, tmp_path: Path) -> None:
|
|
"""The daemon is itself python3 - it must not count as a launcher."""
|
|
self._proc(tmp_path, os.getpid(), ["python3", "/usr/bin/lutris"])
|
|
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {}
|
|
|
|
def test_empty_proc(self) -> None:
|
|
assert get_pids_by_cmdline_names(frozenset({"lutris"})) == {}
|
|
|
|
|
|
class TestQualifyingPids:
|
|
def test_steam_games_only_when_launchers_are_off(self) -> None:
|
|
rules = rules_for(Config(count_launcher_processes=False), demo=False)
|
|
with (
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
|
|
return_value={5: 440, 6: 0},
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_pids_by_process_names"
|
|
) as mock_comm,
|
|
):
|
|
assert qualifying_pids(rules) == {5}
|
|
mock_comm.assert_not_called()
|
|
|
|
def test_steam_client_appid_zero_does_not_count(self) -> None:
|
|
"""Browsing the store is not gaming."""
|
|
rules = rules_for(Config(count_launcher_processes=False), demo=False)
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
|
|
return_value={9: 0},
|
|
):
|
|
assert qualifying_pids(rules) == set()
|
|
|
|
def test_unions_launchers_from_both_scanners(self) -> None:
|
|
rules = rules_for(Config(count_launcher_processes=True), demo=False)
|
|
with (
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
|
|
return_value={5: 440},
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_pids_by_process_names",
|
|
return_value={7: "heroic"},
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_pids_by_cmdline_names",
|
|
return_value={8: "lutris"},
|
|
),
|
|
):
|
|
assert qualifying_pids(rules) == {5, 7, 8}
|
|
|
|
def test_no_processes_at_all(self) -> None:
|
|
rules = rules_for(Config(), demo=False)
|
|
with (
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_running_steam_game_pids",
|
|
return_value={},
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_pids_by_process_names",
|
|
return_value={},
|
|
),
|
|
patch(
|
|
"steam_backlog_enforcer._playtime_procs.get_pids_by_cmdline_names",
|
|
return_value={},
|
|
),
|
|
):
|
|
assert qualifying_pids(rules) == set()
|
|
|
|
|
|
class TestHumanise:
|
|
def test_seconds(self) -> None:
|
|
assert _humanise(30) == "30 seconds"
|
|
|
|
def test_exact_minute_boundary(self) -> None:
|
|
assert _humanise(60) == "1 minutes"
|
|
|
|
def test_minutes(self) -> None:
|
|
assert _humanise(300) == "5 minutes"
|
|
|
|
def test_one_hour_is_special_cased(self) -> None:
|
|
assert _humanise(3600) == "1 hour"
|
|
|
|
def test_thirty_minutes(self) -> None:
|
|
assert _humanise(1800) == "30 minutes"
|