mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 18:13:49 +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
206 lines
6.9 KiB
Python
206 lines
6.9 KiB
Python
"""Tests for the playtime block's mount discovery and stub handling."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import subprocess
|
|
from typing import TYPE_CHECKING
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
from steam_backlog_enforcer import _playtime_block as blk
|
|
from steam_backlog_enforcer._playtime_block import (
|
|
_ensure_stub,
|
|
_mount_bin,
|
|
_umount_bin,
|
|
block_targets,
|
|
mounted_targets,
|
|
mounts_are_visible,
|
|
)
|
|
from steam_backlog_enforcer._playtime_mountinfo import (
|
|
_mountpoints,
|
|
_unescape_mountinfo,
|
|
)
|
|
from steam_backlog_enforcer._playtime_run import (
|
|
_run,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
PKG = "steam_backlog_enforcer._playtime_block"
|
|
|
|
|
|
def _mountinfo_line(mount_point: str) -> str:
|
|
return f"36 25 0:32 / {mount_point} rw,relatime shared:5 - tmpfs tmpfs rw"
|
|
|
|
|
|
def _write_mountinfo(tmp_path: Path, points: list[str]) -> None:
|
|
(tmp_path / "mountinfo").write_text(
|
|
"\n".join(_mountinfo_line(p) for p in points) + "\n" if points else "",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
|
|
class TestUnescapeMountinfo:
|
|
def test_space(self) -> None:
|
|
assert _unescape_mountinfo(r"/mnt/my\040dir") == "/mnt/my dir"
|
|
|
|
def test_tab_newline_backslash(self) -> None:
|
|
assert _unescape_mountinfo(r"a\011b") == "a\tb"
|
|
assert _unescape_mountinfo(r"a\012b") == "a\nb"
|
|
assert _unescape_mountinfo(r"a\134b") == "a\\b"
|
|
|
|
def test_plain_string_untouched(self) -> None:
|
|
assert _unescape_mountinfo("/usr/bin/steam") == "/usr/bin/steam"
|
|
|
|
|
|
class TestMountpoints:
|
|
def test_unreadable_file_returns_empty(self, tmp_path: Path) -> None:
|
|
assert _mountpoints(tmp_path / "does-not-exist") == set()
|
|
|
|
def test_empty_file(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "mi"
|
|
path.write_text("", encoding="utf-8")
|
|
assert _mountpoints(path) == set()
|
|
|
|
def test_skips_short_lines(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "mi"
|
|
path.write_text("1 2 3\n" + _mountinfo_line("/a") + "\n", encoding="utf-8")
|
|
assert {str(p) for p in _mountpoints(path)} == {"/a"}
|
|
|
|
def test_decodes_escapes(self, tmp_path: Path) -> None:
|
|
path = tmp_path / "mi"
|
|
path.write_text(_mountinfo_line(r"/mnt/a\040b") + "\n", encoding="utf-8")
|
|
assert {str(p) for p in _mountpoints(path)} == {"/mnt/a b"}
|
|
|
|
|
|
class TestMountedTargets:
|
|
def test_none_mounted(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, [])
|
|
assert mounted_targets() == set()
|
|
|
|
def test_ignores_unrelated_mounts(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, ["/", "/home", "/etc/hosts"])
|
|
assert mounted_targets() == set()
|
|
|
|
def test_detects_a_target(self, tmp_path: Path) -> None:
|
|
target = block_targets()[0]
|
|
_write_mountinfo(tmp_path, ["/", str(target)])
|
|
assert mounted_targets() == {target}
|
|
|
|
def test_detects_all_targets(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, [str(t) for t in block_targets()])
|
|
assert mounted_targets() == set(block_targets())
|
|
|
|
|
|
class TestMountsAreVisible:
|
|
def test_true_when_nothing_mounted(self, tmp_path: Path) -> None:
|
|
_write_mountinfo(tmp_path, [])
|
|
assert mounts_are_visible() is True
|
|
|
|
def test_true_when_init_sees_the_mount(self, tmp_path: Path) -> None:
|
|
target = block_targets()[0]
|
|
_write_mountinfo(tmp_path, [str(target)])
|
|
(tmp_path / "init_mountinfo").write_text(
|
|
_mountinfo_line(str(target)) + "\n", encoding="utf-8"
|
|
)
|
|
assert mounts_are_visible() is True
|
|
|
|
def test_false_when_init_cannot_see_it(self, tmp_path: Path) -> None:
|
|
"""A private mount namespace would look exactly like this."""
|
|
target = block_targets()[0]
|
|
_write_mountinfo(tmp_path, [str(target)])
|
|
(tmp_path / "init_mountinfo").write_text(
|
|
_mountinfo_line("/unrelated") + "\n", encoding="utf-8"
|
|
)
|
|
assert mounts_are_visible() is False
|
|
|
|
|
|
class TestEnsureStub:
|
|
def test_writes_executable_stub(self, tmp_path: Path) -> None:
|
|
assert _ensure_stub() is True
|
|
stub = tmp_path / "run" / "gaming-blocked"
|
|
assert stub.exists()
|
|
assert stub.stat().st_mode & 0o111
|
|
assert "Gaming blocked" in stub.read_text(encoding="utf-8")
|
|
|
|
def test_idempotent(self, tmp_path: Path) -> None:
|
|
assert _ensure_stub() is True
|
|
assert _ensure_stub() is True
|
|
|
|
def test_returns_false_on_oserror(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_block.Path.mkdir",
|
|
side_effect=OSError("boom"),
|
|
):
|
|
assert _ensure_stub() is False
|
|
|
|
|
|
class TestRun:
|
|
def test_true_on_zero_exit(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_run.subprocess.run",
|
|
return_value=MagicMock(returncode=0),
|
|
) as mock_run:
|
|
assert _run(["mount", "--bind", "a", "b"]) is True
|
|
mock_run.assert_called_once()
|
|
|
|
def test_false_on_nonzero_exit(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_run.subprocess.run",
|
|
return_value=MagicMock(returncode=1),
|
|
):
|
|
assert _run(["mount"]) is False
|
|
|
|
def test_false_on_oserror(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_run.subprocess.run",
|
|
side_effect=OSError("nope"),
|
|
):
|
|
assert _run(["mount"]) is False
|
|
|
|
def test_false_on_timeout(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_run.subprocess.run",
|
|
side_effect=subprocess.TimeoutExpired("mount", 15),
|
|
):
|
|
assert _run(["mount"]) is False
|
|
|
|
|
|
class TestBinaryResolution:
|
|
def test_mount_bin_found(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_block.shutil.which",
|
|
return_value="/bin/mount",
|
|
):
|
|
assert _mount_bin() == "/bin/mount"
|
|
|
|
def test_mount_bin_fallback(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_block.shutil.which", return_value=None
|
|
):
|
|
assert _mount_bin() == "/usr/bin/mount"
|
|
|
|
def test_umount_bin_found(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_block.shutil.which",
|
|
return_value="/bin/umount",
|
|
):
|
|
assert _umount_bin() == "/bin/umount"
|
|
|
|
def test_umount_bin_fallback(self) -> None:
|
|
with patch(
|
|
"steam_backlog_enforcer._playtime_block.shutil.which", return_value=None
|
|
):
|
|
assert _umount_bin() == "/usr/bin/umount"
|
|
|
|
|
|
class TestBlockTargets:
|
|
def test_accessor_returns_the_constant(self) -> None:
|
|
assert block_targets() == blk.BLOCK_TARGETS
|
|
|
|
def test_four_targets_are_isolated_in_tests(self) -> None:
|
|
"""Guards the conftest patch: these must never be the real binaries."""
|
|
assert len(block_targets()) == 4
|
|
for target in block_targets():
|
|
assert not str(target).startswith("/usr/")
|