steam-backlog-enforcer/steam_backlog_enforcer/tests/test_playtime_block.py
Krzysztof kuhy Rudnicki b5b4b35c07 Split total-block, playtime and MCP by responsibility
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
2026-08-23 20:52:08 +02:00

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/")