mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 15:07:58 +01:00
Pylint gate covers tests, no suppressions
Tests are now linted by the 10.00 gate (ignore-paths dropped), with no per-test disables. CtlServer/DaemonControl members the tests drive are public (renamed, behaviour unchanged); SteamAPIClient._get takes its params explicitly, as every caller already did. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e452b0fcd
commit
d2565416aa
@ -138,14 +138,10 @@ persistent = true
|
||||
jobs = 1
|
||||
py-version = "3.14"
|
||||
ignore = [".venv", "__pycache__"]
|
||||
# Test suites intentionally use patterns (protected-access, fixture-arg
|
||||
# shadowing) that don't apply to source code; ruff's per-file-ignores carry
|
||||
# the equivalent exemptions, so tests stay linted -- by ruff. This is the same
|
||||
# source-only pylint scope wake-alarm / diet-guard / testsAndMisc use. It has
|
||||
# to be `ignore-paths`, not `ignore = ["tests"]`: `ignore` only matches the
|
||||
# basename of what pylint is *given*, and pre-commit hands it explicit file
|
||||
# paths, so a directory name in `ignore` never fires under the hook.
|
||||
ignore-paths = ["^(.*/)?tests/.*$"]
|
||||
# Tests are linted like the package (kuhy, 2026-11-10): no ignore-paths, no
|
||||
# per-test disables. A private name a test needs is imported directly, and a
|
||||
# fixture that would shadow its own name is declared with
|
||||
# ``@pytest.fixture(name=...)``.
|
||||
ignore-patterns = [".*\\.pyi$"]
|
||||
|
||||
[tool.pylint.basic]
|
||||
|
||||
@ -39,7 +39,7 @@ class DaemonControl:
|
||||
server: The running socket server, or ``None`` when inert.
|
||||
"""
|
||||
self.ctx = ctx
|
||||
self._server = server
|
||||
self.server = server
|
||||
|
||||
@property
|
||||
def tick_lock(self) -> threading.Lock:
|
||||
@ -74,8 +74,8 @@ class DaemonControl:
|
||||
|
||||
def close(self) -> None:
|
||||
"""Stop serving and remove the socket file."""
|
||||
if self._server is not None:
|
||||
self._server.stop()
|
||||
if self.server is not None:
|
||||
self.server.stop()
|
||||
|
||||
|
||||
def _is_supervised() -> bool:
|
||||
|
||||
@ -81,8 +81,8 @@ class CtlServer:
|
||||
self._gid = desktop_gid
|
||||
self._path = path
|
||||
self._stop = threading.Event()
|
||||
self._slots = threading.BoundedSemaphore(MAX_CONNECTIONS)
|
||||
self._sock: socket.socket | None = None
|
||||
self.slots = threading.BoundedSemaphore(MAX_CONNECTIONS)
|
||||
self.sock: socket.socket | None = None
|
||||
|
||||
def start(self) -> bool:
|
||||
"""Bind the socket and start accepting. Never raises.
|
||||
@ -109,23 +109,23 @@ class CtlServer:
|
||||
except OSError:
|
||||
logger.exception("Cannot start the control socket at %s", self._path)
|
||||
return False
|
||||
self._sock = sock
|
||||
threading.Thread(target=self._serve, name="ctl-accept", daemon=True).start()
|
||||
self.sock = sock
|
||||
threading.Thread(target=self.serve, name="ctl-accept", daemon=True).start()
|
||||
logger.info("Control socket listening on %s", self._path)
|
||||
return True
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Stop accepting and remove the socket file."""
|
||||
self._stop.set()
|
||||
if self._sock is not None:
|
||||
self._sock.close()
|
||||
self._sock = None
|
||||
if self.sock is not None:
|
||||
self.sock.close()
|
||||
self.sock = None
|
||||
self._path.unlink(missing_ok=True)
|
||||
|
||||
def _serve(self) -> None:
|
||||
def serve(self) -> None:
|
||||
"""Accept loop; survives anything but being told to stop."""
|
||||
while not self._stop.is_set():
|
||||
sock = self._sock
|
||||
sock = self.sock
|
||||
if sock is None:
|
||||
return
|
||||
try:
|
||||
@ -138,9 +138,9 @@ class CtlServer:
|
||||
logger.exception("Control socket accept failed")
|
||||
time.sleep(_ACCEPT_POLL_SECONDS)
|
||||
continue
|
||||
self._admit(conn)
|
||||
self.admit(conn)
|
||||
|
||||
def _admit(self, conn: socket.socket) -> None:
|
||||
def admit(self, conn: socket.socket) -> None:
|
||||
"""Authenticate *conn* and hand it to a worker, or close it silently."""
|
||||
try:
|
||||
uid = peer_uid(conn)
|
||||
@ -151,33 +151,33 @@ class CtlServer:
|
||||
logger.warning("Control socket: refused a connection from uid %d", uid)
|
||||
conn.close()
|
||||
return
|
||||
if not self._take_slot():
|
||||
if not self.take_slot():
|
||||
logger.warning("Control socket: too many connections, dropping one")
|
||||
conn.close()
|
||||
return
|
||||
threading.Thread(
|
||||
target=self._handle, args=(conn, uid), name="ctl-conn", daemon=True
|
||||
target=self.handle, args=(conn, uid), name="ctl-conn", daemon=True
|
||||
).start()
|
||||
|
||||
def _take_slot(self) -> bool:
|
||||
"""Claim a connection slot without blocking; ``_handle`` releases it.
|
||||
def take_slot(self) -> bool:
|
||||
"""Claim a connection slot without blocking; ``handle`` releases it.
|
||||
|
||||
No ``with``: the slot is released by the worker thread, not here.
|
||||
"""
|
||||
return self._slots.acquire(blocking=False)
|
||||
return self.slots.acquire(blocking=False)
|
||||
|
||||
def _handle(self, conn: socket.socket, uid: int) -> None:
|
||||
def handle(self, conn: socket.socket, uid: int) -> None:
|
||||
"""Serve one connection, always releasing its slot and socket."""
|
||||
try:
|
||||
line = read_line(conn)
|
||||
if line is not None:
|
||||
self._write(conn, self._dispatch(line, uid))
|
||||
self._write(conn, self.dispatch(line, uid))
|
||||
# Catch-all on purpose: a dying connection must not kill its thread.
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.debug("Control connection dropped", exc_info=True)
|
||||
finally:
|
||||
conn.close()
|
||||
self._slots.release()
|
||||
self.slots.release()
|
||||
|
||||
@staticmethod
|
||||
def _write(conn: socket.socket, payload: bytes) -> None:
|
||||
@ -185,10 +185,10 @@ class CtlServer:
|
||||
conn.settimeout(WRITE_TIMEOUT_SECONDS)
|
||||
conn.sendall(payload)
|
||||
|
||||
def _dispatch(self, line: bytes, uid: int) -> bytes:
|
||||
def dispatch(self, line: bytes, uid: int) -> bytes:
|
||||
"""Turn a request line into a response line. Never raises."""
|
||||
try:
|
||||
return ok_response(self._run(line, uid))
|
||||
return ok_response(self.run(line, uid))
|
||||
except CtlError as exc:
|
||||
logger.info("Control socket: refused (%s)", exc.code)
|
||||
return error_response(exc)
|
||||
@ -197,7 +197,7 @@ class CtlServer:
|
||||
logger.exception("Control socket op crashed")
|
||||
return error_response(CtlError(OP_FAILED, "internal error"))
|
||||
|
||||
def _run(self, line: bytes, uid: int) -> dict[str, object]:
|
||||
def run(self, line: bytes, uid: int) -> dict[str, object]:
|
||||
"""Validate a request line and run its op.
|
||||
|
||||
Raises:
|
||||
|
||||
@ -83,6 +83,10 @@ class _AuthInfo:
|
||||
hp_val: str = ""
|
||||
|
||||
|
||||
_LEGACY_UNKNOWN_COUNTS = dict.fromkeys(("polls", "count_comp"), 0)
|
||||
_LEGACY_UNKNOWN_HOURS = dict.fromkeys(("rush_hours", "leisure_100h"), -1)
|
||||
|
||||
|
||||
def _read_raw_cache() -> dict[int, dict[str, Any]]:
|
||||
"""Read the persistent HLTB cache, normalizing legacy float entries.
|
||||
|
||||
@ -126,10 +130,8 @@ def _read_raw_cache() -> dict[int, dict[str, Any]]:
|
||||
try:
|
||||
out[aid] = {
|
||||
"hours": float(v),
|
||||
"polls": 0,
|
||||
"count_comp": 0,
|
||||
"rush_hours": -1,
|
||||
"leisure_100h": -1,
|
||||
**_LEGACY_UNKNOWN_COUNTS,
|
||||
**_LEGACY_UNKNOWN_HOURS,
|
||||
"hltb_game_id": 0,
|
||||
}
|
||||
except TypeError, ValueError:
|
||||
|
||||
@ -32,6 +32,20 @@ if TYPE_CHECKING:
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _game_from_achievements(
|
||||
app_id: int, name: str, playtime: int, achievements: list[AchievementInfo]
|
||||
) -> GameInfo:
|
||||
"""Build a GameInfo whose counts are derived from its achievement list."""
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
total_achievements=len(achievements),
|
||||
unlocked_achievements=sum(1 for a in achievements if a.achieved),
|
||||
playtime_minutes=playtime,
|
||||
achievements=achievements,
|
||||
)
|
||||
|
||||
|
||||
class SteamAPIError(Exception):
|
||||
"""Raised when the Steam API returns an error."""
|
||||
|
||||
@ -72,11 +86,9 @@ class SteamAPIClient:
|
||||
return
|
||||
time.sleep(0.06)
|
||||
|
||||
def _get(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
"""Rate-limited GET request."""
|
||||
def _get(self, url: str, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Rate-limited GET request; adds the API key to ``params``."""
|
||||
self._rate_limit()
|
||||
if params is None:
|
||||
params = {}
|
||||
params["key"] = self.api_key
|
||||
try:
|
||||
resp = self.session.get(url, params=params, timeout=30)
|
||||
@ -151,17 +163,8 @@ class SteamAPIClient:
|
||||
return None
|
||||
|
||||
name = game_dict.get("name", f"Unknown ({app_id})")
|
||||
total = len(achievements)
|
||||
unlocked = sum(1 for a in achievements if a.achieved)
|
||||
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
total_achievements=total,
|
||||
unlocked_achievements=unlocked,
|
||||
playtime_minutes=game_dict.get("playtime_forever", 0),
|
||||
achievements=achievements,
|
||||
)
|
||||
playtime = game_dict.get("playtime_forever", 0)
|
||||
return _game_from_achievements(app_id, name, playtime, achievements)
|
||||
|
||||
def build_game_list(
|
||||
self,
|
||||
@ -238,13 +241,4 @@ class SteamAPIClient:
|
||||
achievements = self.get_achievement_details(app_id)
|
||||
if not achievements:
|
||||
return None
|
||||
total = len(achievements)
|
||||
unlocked = sum(1 for a in achievements if a.achieved)
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
total_achievements=total,
|
||||
unlocked_achievements=unlocked,
|
||||
playtime_minutes=playtime,
|
||||
achievements=achievements,
|
||||
)
|
||||
return _game_from_achievements(app_id, name, playtime, achievements)
|
||||
|
||||
42
steam_backlog_enforcer/tests/_budget_earners.py
Normal file
42
steam_backlog_enforcer/tests/_budget_earners.py
Normal file
@ -0,0 +1,42 @@
|
||||
"""Shared seams for the budget tests: the earner patch targets and a resolver.
|
||||
|
||||
Each earner is overridden where ``resolve_budget`` reads it, so a test can fix
|
||||
the workout, LeetCode and reading answers without any network or ledger.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._budget_resolve import BudgetResolution, resolve_budget
|
||||
from steam_backlog_enforcer.config import Config
|
||||
|
||||
WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
|
||||
LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
|
||||
READING = "steam_backlog_enforcer._budget_resolve.read_today"
|
||||
|
||||
HOUR = 3600.0
|
||||
|
||||
|
||||
def resolve_with(
|
||||
*,
|
||||
workout: bool,
|
||||
leetcode: bool,
|
||||
reading: bool | None,
|
||||
) -> BudgetResolution:
|
||||
"""Resolve the default config's budget with all three earners fixed.
|
||||
|
||||
Args:
|
||||
workout: Whether today's workout happened.
|
||||
leetcode: Whether today's LeetCode solve happened.
|
||||
reading: Whether today's reading happened (None: unknown).
|
||||
|
||||
Returns:
|
||||
The resolved budget.
|
||||
"""
|
||||
with (
|
||||
patch(WORKOUT, return_value=workout),
|
||||
patch(LEETCODE, return_value=leetcode),
|
||||
patch(READING, return_value=reading),
|
||||
):
|
||||
return resolve_budget(Config())
|
||||
142
steam_backlog_enforcer/tests/_cmd_done_cases.py
Normal file
142
steam_backlog_enforcer/tests/_cmd_done_cases.py
Normal file
@ -0,0 +1,142 @@
|
||||
"""Shared ``cmd_done`` cases, run against each public import path of it.
|
||||
|
||||
``main.cmd_done`` re-exports ``_cmd_done_finalize.cmd_done``; each test module
|
||||
subclasses :class:`CmdDoneCases` and binds :meth:`run_done` to its import.
|
||||
"""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done_finalize"
|
||||
|
||||
|
||||
class CmdDoneCases:
|
||||
"""Verdict and HLTB-estimate cases for cmd_done (not collected itself)."""
|
||||
|
||||
def run_done(self, config: Config, state: State) -> None:
|
||||
"""Call the cmd_done under test."""
|
||||
raise NotImplementedError
|
||||
|
||||
def test_no_game_assigned(self) -> None:
|
||||
"""With no assigned game cmd_done says so and stops."""
|
||||
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
|
||||
self.run_done(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_fetch_fails(self) -> None:
|
||||
"""A failed Steam refresh ends cmd_done without raising."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = None
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
):
|
||||
self.run_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_not_complete_enforces(self) -> None:
|
||||
"""An incomplete game goes back to enforcement."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
self.run_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_complete_finalizes(self) -> None:
|
||||
"""A fully completed game is finalised."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
|
||||
):
|
||||
self.run_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_final.assert_called_once()
|
||||
|
||||
def test_hltb_cache_miss_fetches(self) -> None:
|
||||
"""A game missing from the HLTB cache gets its time fetched."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={1: 15.0},
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
self.run_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_hltb_negative_no_display(self) -> None:
|
||||
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
self.run_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_reassign_returns_true(self) -> None:
|
||||
"""A completed game is finalised even with a long HLTB estimate."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion"),
|
||||
):
|
||||
self.run_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
37
steam_backlog_enforcer/tests/_cmd_playtime_patches.py
Normal file
37
steam_backlog_enforcer/tests/_cmd_playtime_patches.py
Normal file
@ -0,0 +1,37 @@
|
||||
"""Shared patches for the ``_cmd_playtime`` CLI handler tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
PKG = "steam_backlog_enforcer._cmd_playtime"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CliMocks:
|
||||
"""The echo and mount-release mocks a handler test asserts on."""
|
||||
|
||||
echo: MagicMock
|
||||
release: MagicMock
|
||||
|
||||
|
||||
def echoed(mock_echo: MagicMock) -> str:
|
||||
"""Join everything the handler echoed into one searchable string."""
|
||||
return " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cli_as(euid: int | None) -> Iterator[CliMocks]:
|
||||
"""Run a handler with echo and release_block mocked, as ``euid`` if given."""
|
||||
with ExitStack() as stack:
|
||||
if euid is not None:
|
||||
stack.enter_context(patch(f"{PKG}.os.geteuid", return_value=euid))
|
||||
echo = stack.enter_context(patch(f"{PKG}._echo"))
|
||||
release = stack.enter_context(patch(f"{PKG}.release_block"))
|
||||
yield CliMocks(echo=echo, release=release)
|
||||
23
steam_backlog_enforcer/tests/_config_paths.py
Normal file
23
steam_backlog_enforcer/tests/_config_paths.py
Normal file
@ -0,0 +1,23 @@
|
||||
"""Shared redirect of the config module's on-disk paths for the config tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@contextmanager
|
||||
def config_file_under(tmp_path: Path) -> Iterator[Path]:
|
||||
"""Point CONFIG_DIR at ``tmp_path/cfg`` and yield its (unwritten) config.json."""
|
||||
config_dir = tmp_path / "cfg"
|
||||
config_file = config_dir / "config.json"
|
||||
with (
|
||||
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
||||
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
||||
):
|
||||
yield config_file
|
||||
84
steam_backlog_enforcer/tests/_desktop_launch_patches.py
Normal file
84
steam_backlog_enforcer/tests/_desktop_launch_patches.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Shared helpers for the desktop-session and Steam-launch tests.
|
||||
|
||||
The desktop/launch test files each patched the same ``_steam_launch``
|
||||
seams one ``patch(...)`` at a time; this module holds that boilerplate once.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env"
|
||||
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
|
||||
STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process"
|
||||
|
||||
|
||||
def env_value(args: list[str], name: str) -> str | None:
|
||||
"""Return the value assigned to *name* in a list of ``VAR=value`` args."""
|
||||
prefix = f"{name}="
|
||||
for arg in args:
|
||||
if arg.startswith(prefix):
|
||||
return arg[len(prefix) :]
|
||||
return None
|
||||
|
||||
|
||||
@contextmanager
|
||||
def launch_patches(
|
||||
*bare: str,
|
||||
extra: dict[str, object] | None = None,
|
||||
**returns: object,
|
||||
) -> Iterator[dict[str, MagicMock]]:
|
||||
"""Patch ``_steam_launch`` attributes for the duration of the block.
|
||||
|
||||
*bare* names are patched with a default mock, *returns* names with a mock
|
||||
returning the given value, and *extra* maps full dotted targets in other
|
||||
modules to return values. Yields the mocks keyed by name or target.
|
||||
"""
|
||||
targets: list[tuple[str, str, dict[str, object]]] = [
|
||||
(name, f"{STEAM_LAUNCH_PKG}.{name}", {}) for name in bare
|
||||
]
|
||||
targets += [
|
||||
(name, f"{STEAM_LAUNCH_PKG}.{name}", {"return_value": value})
|
||||
for name, value in returns.items()
|
||||
]
|
||||
targets += [
|
||||
(target, target, {"return_value": value})
|
||||
for target, value in (extra or {}).items()
|
||||
]
|
||||
with ExitStack() as stack:
|
||||
yield {
|
||||
key: stack.enter_context(patch(target, **kwargs))
|
||||
for key, target, kwargs in targets
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def fresh_launch_patches(
|
||||
*bare: str,
|
||||
cdp_ready: bool = True,
|
||||
collections_ready: bool | None = True,
|
||||
extra: dict[str, object] | None = None,
|
||||
**returns: object,
|
||||
) -> Iterator[dict[str, MagicMock]]:
|
||||
"""Patch a Steam with no debug port that gets launched and waited on.
|
||||
|
||||
``_launch_steam_with_debug`` is always patched (bare); the CDP and
|
||||
collections waits return *cdp_ready* / *collections_ready*; a
|
||||
*collections_ready* of ``None`` leaves that wait unpatched.
|
||||
"""
|
||||
if collections_ready is not None:
|
||||
returns["_wait_for_collections_ready"] = collections_ready
|
||||
with launch_patches(
|
||||
"_launch_steam_with_debug",
|
||||
*bare,
|
||||
extra=extra,
|
||||
_steam_has_debug_port=False,
|
||||
_wait_for_cdp_ready=cdp_ready,
|
||||
**returns,
|
||||
) as mocks:
|
||||
yield mocks
|
||||
43
steam_backlog_enforcer/tests/_enforce_loop_once.py
Normal file
43
steam_backlog_enforcer/tests/_enforce_loop_once.py
Normal file
@ -0,0 +1,43 @@
|
||||
"""Shared patch block that runs do_enforce for exactly one loop iteration.
|
||||
|
||||
Several do_enforce tests need the same scaffolding: a fixed reloaded state,
|
||||
an iteration that raises KeyboardInterrupt to end the loop, and no real file
|
||||
wake. Keeping it here means each test only spells out what it is about.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer.config import State
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
PKG = "steam_backlog_enforcer._enforce_loop"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LoopMocks:
|
||||
"""Mocks a single-iteration do_enforce run exposes to assertions."""
|
||||
|
||||
echo: MagicMock
|
||||
iteration: MagicMock
|
||||
|
||||
|
||||
@contextmanager
|
||||
def one_iteration(state: State) -> Iterator[LoopMocks]:
|
||||
"""Patch do_enforce so it reloads ``state`` and stops after one iteration."""
|
||||
with (
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
) as iteration,
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
):
|
||||
yield LoopMocks(echo=echo, iteration=iteration)
|
||||
@ -89,11 +89,14 @@ def property_writer(
|
||||
_bytes_after: object,
|
||||
prop_ref: object,
|
||||
) -> int:
|
||||
nitems_ref._obj.value = nitems # type-erased ctypes byref holder
|
||||
# cast() reads the address a byref() holder carries, so the write lands
|
||||
# in the caller's variable exactly as libX11's would.
|
||||
ctypes.cast(nitems_ref, ctypes.POINTER(ctypes.c_ulong)).contents.value = nitems
|
||||
if not null:
|
||||
holder = ctypes.c_ulong(value)
|
||||
keepalive.append(holder)
|
||||
prop_ref._obj.contents = holder
|
||||
out = ctypes.cast(prop_ref, ctypes.POINTER(ctypes.POINTER(ctypes.c_ulong)))
|
||||
out.contents.contents = holder
|
||||
return status
|
||||
|
||||
return impl
|
||||
|
||||
27
steam_backlog_enforcer/tests/_fast_install_excerpt.py
Normal file
27
steam_backlog_enforcer/tests/_fast_install_excerpt.py
Normal file
@ -0,0 +1,27 @@
|
||||
"""The recorded installer log excerpt shared by the fast-install progress tests.
|
||||
|
||||
The fixture is a verbatim excerpt of a real Baldur's Gate 3 run (the per-file
|
||||
lines of the big depots trimmed). It is stored with LF endings and written
|
||||
back with CRLF, which is what the installer's pty actually produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
MOD = "steam_backlog_enforcer._fast_install_progress"
|
||||
EXCERPT = (Path(__file__).parent / "fast_install_log_excerpt.txt").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
LINES = EXCERPT.splitlines()
|
||||
|
||||
|
||||
def upto(marker: str, *, extra: int = 0) -> str:
|
||||
"""The excerpt through the line equal to *marker*, else the first it starts.
|
||||
|
||||
Exact first: ``Downloading depot 228989`` is also the start of the earlier
|
||||
``Downloading depot 228989 manifest``.
|
||||
"""
|
||||
exact = [i for i, line in enumerate(LINES) if line == marker]
|
||||
index = (exact or [i for i, ln in enumerate(LINES) if ln.startswith(marker)])[0]
|
||||
return "".join(f"{line}\r\n" for line in LINES[: index + 1 + extra])
|
||||
21
steam_backlog_enforcer/tests/_file_wake_timing.py
Normal file
21
steam_backlog_enforcer/tests/_file_wake_timing.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Shared timing knobs and a timed wait for the _file_wake test modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer._file_wake import FileWake
|
||||
|
||||
PKG = "steam_backlog_enforcer._file_wake"
|
||||
# Far above real latency so CI cannot flake; elapsed asserts pin "early".
|
||||
LONG = 5.0
|
||||
SHORT = 0.05
|
||||
|
||||
|
||||
def timed_wait(watcher: FileWake, timeout: float) -> tuple[bool, float]:
|
||||
"""Run ``watcher.wait`` and measure how long it took."""
|
||||
start = time.monotonic()
|
||||
woke = watcher.wait(timeout)
|
||||
return woke, time.monotonic() - start
|
||||
17
steam_backlog_enforcer/tests/_half_done_game.py
Normal file
17
steam_backlog_enforcer/tests/_half_done_game.py
Normal file
@ -0,0 +1,17 @@
|
||||
"""Shared builder for a half-completed, hour-played game in the pick prompt tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
|
||||
def half_done_game(app_id: int, name: str, hours: float = -1) -> GameInfo:
|
||||
"""Return a game with 5 of 10 achievements, an hour played, ``hours`` to 100 %."""
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
completionist_hours=hours,
|
||||
)
|
||||
62
steam_backlog_enforcer/tests/_hltb_leisure_runs.py
Normal file
62
steam_backlog_enforcer/tests/_hltb_leisure_runs.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Shared builders and runner for the ``_fetch_leisure_times`` tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
from steam_backlog_enforcer._hltb_detail import _fetch_leisure_times
|
||||
from steam_backlog_enforcer._hltb_types import HLTBResult
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer._hltb_types import ProgressCb, _HLTBExtras
|
||||
|
||||
|
||||
def tf2_result(hltb_game_id: int = 12345) -> HLTBResult:
|
||||
"""Return the TF2 search hit (50 h estimate) the leisure tests refine."""
|
||||
return HLTBResult(
|
||||
app_id=440,
|
||||
game_name="TF2",
|
||||
completionist_hours=50.0,
|
||||
similarity=1.0,
|
||||
hltb_game_id=hltb_game_id,
|
||||
)
|
||||
|
||||
|
||||
def helltaker_result() -> HLTBResult:
|
||||
"""Return the Helltaker search hit whose DLC the leisure tests add up."""
|
||||
return HLTBResult(
|
||||
app_id=1289310,
|
||||
game_name="Helltaker",
|
||||
completionist_hours=1.0,
|
||||
similarity=1.0,
|
||||
hltb_game_id=78118,
|
||||
)
|
||||
|
||||
|
||||
def run_leisure(
|
||||
results: list[HLTBResult],
|
||||
detail: object = None,
|
||||
*,
|
||||
details: list[object] | None = None,
|
||||
progress_cb: ProgressCb | None = None,
|
||||
extras: _HLTBExtras | None = None,
|
||||
) -> dict[int, float]:
|
||||
"""Run ``_fetch_leisure_times`` with each detail page answering ``detail``.
|
||||
|
||||
``details`` instead answers successive detail fetches in order. Returns
|
||||
the hours cache the run filled.
|
||||
"""
|
||||
cache: dict[int, float] = {}
|
||||
# A side_effect list, when given, wins over return_value.
|
||||
with patch(
|
||||
"steam_backlog_enforcer._hltb_detail._fetch_detail_one",
|
||||
new_callable=AsyncMock,
|
||||
return_value=detail,
|
||||
side_effect=details,
|
||||
):
|
||||
asyncio.run(
|
||||
_fetch_leisure_times(results, cache, {}, progress_cb, extras=extras)
|
||||
)
|
||||
return cache
|
||||
119
steam_backlog_enforcer/tests/_hltb_search_fakes.py
Normal file
119
steam_backlog_enforcer/tests/_hltb_search_fakes.py
Normal file
@ -0,0 +1,119 @@
|
||||
"""Shared fake aiohttp response, search context and runners for HLTB search tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING, Any, Self
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer._hltb_search import (
|
||||
_fetch_batch,
|
||||
_search_one,
|
||||
_SearchCtx,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
from steam_backlog_enforcer._hltb_types import HLTBResult, _AuthInfo
|
||||
|
||||
|
||||
_SEARCH = "steam_backlog_enforcer._hltb_search"
|
||||
|
||||
|
||||
class FakeResponse:
|
||||
"""Async context manager mimicking aiohttp response."""
|
||||
|
||||
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
|
||||
"""Store the canned status and JSON body (``None`` = empty body)."""
|
||||
self.status = status
|
||||
self._json_data = json_data or {}
|
||||
|
||||
async def __aenter__(self) -> Self:
|
||||
"""Enter."""
|
||||
return self
|
||||
|
||||
async def __aexit__(self, *args: object) -> None:
|
||||
"""Exit."""
|
||||
|
||||
async def json(self) -> dict[str, Any]:
|
||||
"""Return the canned JSON body."""
|
||||
return self._json_data
|
||||
|
||||
|
||||
def make_session(resp: FakeResponse) -> MagicMock:
|
||||
"""Return a fake session whose every POST answers ``resp``."""
|
||||
session = MagicMock()
|
||||
session.post.return_value = resp
|
||||
return session
|
||||
|
||||
|
||||
def make_ctx(
|
||||
session: MagicMock,
|
||||
*,
|
||||
cache: dict[int, float] | None = None,
|
||||
progress_cb: Callable[..., object] | None = None,
|
||||
counter: dict[str, int] | None = None,
|
||||
) -> _SearchCtx:
|
||||
"""Return a one-game search context over ``session``."""
|
||||
return _SearchCtx(
|
||||
session=session,
|
||||
search_url="https://example.com/search",
|
||||
headers={},
|
||||
cache=cache if cache is not None else {},
|
||||
counter=counter if counter is not None else {"done": 0, "found": 0},
|
||||
total=1,
|
||||
progress_cb=progress_cb,
|
||||
)
|
||||
|
||||
|
||||
def hit(
|
||||
name: str, comp_100: int, game_id: int, *, alias: str = "", **extra: object
|
||||
) -> dict[str, Any]:
|
||||
"""Return a search body holding one HLTB entry."""
|
||||
entry = {
|
||||
"game_name": name,
|
||||
"game_alias": alias,
|
||||
"comp_100": comp_100,
|
||||
"game_id": game_id,
|
||||
}
|
||||
return {"data": [{**entry, **extra}]}
|
||||
|
||||
|
||||
def run_search(
|
||||
ctx: _SearchCtx, app_id: int = 440, name: str = "TF2"
|
||||
) -> HLTBResult | None:
|
||||
"""Run ``_search_one`` for one game on ``ctx``."""
|
||||
return asyncio.run(_search_one(asyncio.Semaphore(1), ctx, app_id, name))
|
||||
|
||||
|
||||
def search_tf2(body: dict[str, Any]) -> HLTBResult | None:
|
||||
"""Answer a TF2 search with ``body`` (status 200) and return the result."""
|
||||
return run_search(make_ctx(make_session(FakeResponse(200, body))))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def hltb_auth_patched(auth: _AuthInfo | None) -> Iterator[None]:
|
||||
"""Stub the search-URL lookup and make the auth handshake return ``auth``."""
|
||||
with (
|
||||
patch(f"{_SEARCH}._get_hltb_search_url", return_value="https://example.com"),
|
||||
patch(f"{_SEARCH}._get_auth_info", new_callable=AsyncMock, return_value=auth),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
def fetch_tf2_batch(
|
||||
auth: _AuthInfo, search_result: HLTBResult | None
|
||||
) -> list[HLTBResult]:
|
||||
"""Run ``_fetch_batch`` for TF2; every per-game search returns ``search_result``."""
|
||||
with (
|
||||
hltb_auth_patched(auth),
|
||||
patch(
|
||||
f"{_SEARCH}._search_one",
|
||||
new_callable=AsyncMock,
|
||||
return_value=search_result,
|
||||
),
|
||||
patch(f"{_SEARCH}._fetch_leisure_times", new_callable=AsyncMock),
|
||||
):
|
||||
return asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
|
||||
@ -22,6 +22,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _bonus_incident
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
@ -38,8 +40,6 @@ def _isolate_incidents(tmp_path: Path) -> Iterator[None]:
|
||||
None, with the log redirected and the reported-incident memo cleared so
|
||||
one test's rate limit cannot mute the next.
|
||||
"""
|
||||
from steam_backlog_enforcer import _bonus_incident
|
||||
|
||||
_bonus_incident.reset_reported()
|
||||
with patch.object(
|
||||
_bonus_incident, "INCIDENT_LOG", tmp_path / "leetcode_bonus_incidents.log"
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
"""Shared builders for the LeetCode ledger tests.
|
||||
|
||||
Split out of ``test_leetcode_ledger.py`` to keep every file inside the 250-line
|
||||
cap. The ``key_file`` fixture is imported by name into each test module, which
|
||||
is what registers it there.
|
||||
cap. The ``key_file`` fixture (``fixture_key_file``) is imported by name into
|
||||
each test module, which is what registers it there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -78,8 +78,8 @@ def write_ledger(path: Path, entries: list[dict[str, Any]]) -> Path:
|
||||
return ledger
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key_file(tmp_path: Path) -> Iterator[Path]:
|
||||
@pytest.fixture(name="key_file")
|
||||
def fixture_key_file(tmp_path: Path) -> Iterator[Path]:
|
||||
"""Point the module at a throwaway signing key.
|
||||
|
||||
Args:
|
||||
|
||||
68
steam_backlog_enforcer/tests/_leisure_kill_world.py
Normal file
68
steam_backlog_enforcer/tests/_leisure_kill_world.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Shared world for the leisure-kill tests: a fake /proc, facts and a log stub."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from leisure_watch.facts import Focus, Now, Reason
|
||||
|
||||
from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState
|
||||
from steam_backlog_enforcer._shadow import ShadowVerdict
|
||||
from steam_backlog_enforcer.tests._isolate_leisure import leisure_proc
|
||||
from steam_backlog_enforcer.tests._leisure_fake_proc import FakeProc, add_proc
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
HELIUM = "/opt/helium-browser-bin/helium"
|
||||
# Enforced budget rules with 100 s allowed; RULES also bills the leisure budget.
|
||||
BASE_RULES = PlaytimeRules(
|
||||
budget_seconds=100.0,
|
||||
warn_at=(),
|
||||
sigkill_after=30.0,
|
||||
count_launchers=False,
|
||||
enforcement=True,
|
||||
demo=False,
|
||||
)
|
||||
RULES = replace(BASE_RULES, leisure=True)
|
||||
SPENT = PlaytimeState(day_key="2026-11-02", seconds=100.0)
|
||||
|
||||
|
||||
class StubLog:
|
||||
"""Audit log double that keeps every recorded event in order."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
"""Start with no records."""
|
||||
self.records: list[tuple[str, dict[str, object]]] = []
|
||||
|
||||
def record(self, event: str, **fields: object) -> None:
|
||||
"""Remember one event and its fields."""
|
||||
self.records.append((event, fields))
|
||||
|
||||
|
||||
def now_with(*reasons: Reason) -> Now:
|
||||
"""Build a leisure ``now.json`` fact carrying ``reasons``."""
|
||||
return Now(
|
||||
ts=0.0,
|
||||
seq=1,
|
||||
state="leisure",
|
||||
paused_by=None,
|
||||
reasons=reasons,
|
||||
focus=Focus("Helium", "", None, idle=False),
|
||||
)
|
||||
|
||||
|
||||
def verdict_of(fact: Now | None, paused: str | None = None) -> ShadowVerdict:
|
||||
"""Wrap a fact (or its absence) in a shadow verdict."""
|
||||
return ShadowVerdict(paused_by=paused, keys=("k",), fact=fact)
|
||||
|
||||
|
||||
def build_proc(tmp_path: Path) -> Path:
|
||||
"""Fill the isolated /proc with mpv, two browsers and a root-owned app."""
|
||||
root = leisure_proc(tmp_path)
|
||||
add_proc(root, FakeProc(10, exe="/usr/bin/mpv"))
|
||||
add_proc(root, FakeProc(20, exe=HELIUM, comm="helium"))
|
||||
add_proc(root, FakeProc(30, exe="/usr/bin/chromium", comm="chromium"))
|
||||
add_proc(root, FakeProc(40, exe="/usr/bin/vesktop", uid=0))
|
||||
return root
|
||||
45
steam_backlog_enforcer/tests/_main_dispatch_world.py
Normal file
45
steam_backlog_enforcer/tests/_main_dispatch_world.py
Normal file
@ -0,0 +1,45 @@
|
||||
"""Shared patch bundles for the ``main`` dispatch and ``status`` test modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
SHARED = "steam_backlog_enforcer.main._shared"
|
||||
STATUS = "steam_backlog_enforcer.main.status"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cli_run(argv: list[str], *, total_block: bool) -> Iterator[None]:
|
||||
"""Run ``main`` on ``argv`` with stub config/state and a fixed total block."""
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{SHARED}.is_total_block_active", return_value=total_block),
|
||||
):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def status_stubs(*, stub_completion: bool) -> Iterator[MagicMock]:
|
||||
"""Stub the store and install lookups ``cmd_status`` makes; yield its echo.
|
||||
|
||||
``stub_completion`` also stubs ``report_completion`` to report nothing.
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch(f"{STATUS}.is_store_blocked", return_value=False))
|
||||
stack.enter_context(patch(f"{STATUS}.get_installed_games", return_value=[]))
|
||||
if stub_completion:
|
||||
stack.enter_context(patch(f"{STATUS}.report_completion", return_value=[]))
|
||||
# cmd_status resolves _echo from its own module, not the package.
|
||||
yield stack.enter_context(patch(f"{STATUS}._echo"))
|
||||
103
steam_backlog_enforcer/tests/_main_patches.py
Normal file
103
steam_backlog_enforcer/tests/_main_patches.py
Normal file
@ -0,0 +1,103 @@
|
||||
"""Shared patch bundles for the ``main`` CLI test modules.
|
||||
|
||||
The pick, pick-manual and done tests all stub the same library-hider calls
|
||||
and the same confirmed-pick prompt; keeping the bundles here stops each
|
||||
module from carrying its own copy of the ``with`` block.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer.config import State
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
|
||||
OWNED_IDS = "steam_backlog_enforcer.library_hider.get_all_owned_app_ids"
|
||||
HIDE_OTHERS = "steam_backlog_enforcer.library_hider.try_hide_other_games"
|
||||
# The hide-skip line is printed by the shared hide helper, not the command.
|
||||
HIDER_ECHO = "steam_backlog_enforcer.library_hider._echo"
|
||||
PICKS_PKG = "steam_backlog_enforcer.main.picks"
|
||||
|
||||
|
||||
@contextmanager
|
||||
def library_hider(
|
||||
owned: Sequence[int],
|
||||
hide: tuple[int, str | None] | None = None,
|
||||
) -> Iterator[MagicMock | None]:
|
||||
"""Patch the owned-ids lookup and, when ``hide`` is given, the hide call.
|
||||
|
||||
Args:
|
||||
owned: App ids the owned-games lookup returns.
|
||||
hide: Return value for ``try_hide_other_games``; None leaves it real.
|
||||
|
||||
Yields:
|
||||
The ``try_hide_other_games`` mock, or None when it was not patched.
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(patch(OWNED_IDS, return_value=list(owned)))
|
||||
hide_mock = None
|
||||
if hide is not None:
|
||||
hide_mock = stack.enter_context(patch(HIDE_OTHERS, return_value=hide))
|
||||
yield hide_mock
|
||||
|
||||
|
||||
def assign_next(app_id: int | None, name: str = "") -> Callable[..., None]:
|
||||
"""Build a ``pick_next_game`` side effect that assigns one game.
|
||||
|
||||
Args:
|
||||
app_id: App id to assign (None clears the assignment).
|
||||
name: Game name to assign; empty leaves the name untouched.
|
||||
|
||||
Returns:
|
||||
A callable with ``pick_next_game``'s signature.
|
||||
"""
|
||||
|
||||
def _assign(_games: object, s: State, _c: object, **_kwargs: object) -> None:
|
||||
s.current_app_id = app_id
|
||||
if name:
|
||||
s.current_game_name = name
|
||||
|
||||
return _assign
|
||||
|
||||
|
||||
@contextmanager
|
||||
def confirmed_pick(
|
||||
*,
|
||||
owned: Sequence[int] = (),
|
||||
installed: bool = True,
|
||||
uninstall: int | None = 0,
|
||||
answers: Sequence[str] = ("lock in Skyrim SE",),
|
||||
) -> Iterator[dict[str, Any]]:
|
||||
"""Patch everything a confirmed ``pick-manual`` of Skyrim SE touches.
|
||||
|
||||
Args:
|
||||
owned: App ids the owned-games lookup returns.
|
||||
installed: What ``is_game_fully_installed`` reports.
|
||||
uninstall: ``uninstall_other_games`` return; None keeps a bare mock.
|
||||
answers: Successive ``input()`` answers (one means a fixed answer).
|
||||
|
||||
Yields:
|
||||
The mocks by name: echo, save, uninstall.
|
||||
"""
|
||||
uninstall_kwargs = {} if uninstall is None else {"return_value": uninstall}
|
||||
input_kwargs: dict[str, Any] = (
|
||||
{"return_value": answers[0]}
|
||||
if len(answers) == 1
|
||||
else {"side_effect": list(answers)}
|
||||
)
|
||||
with ExitStack() as stack:
|
||||
enter = stack.enter_context
|
||||
enter(patch(f"{PICKS_PKG}._resolve_game_name", return_value="Skyrim SE"))
|
||||
mocks = {"echo": enter(patch(f"{PICKS_PKG}._echo"))}
|
||||
enter(patch("builtins.input", **input_kwargs))
|
||||
mocks["save"] = enter(patch.object(State, "save"))
|
||||
mocks["uninstall"] = enter(
|
||||
patch(f"{PICKS_PKG}.uninstall_other_games", **uninstall_kwargs)
|
||||
)
|
||||
enter(patch(f"{PICKS_PKG}.is_game_fully_installed", return_value=installed))
|
||||
enter(patch(OWNED_IDS, return_value=list(owned)))
|
||||
yield mocks
|
||||
19
steam_backlog_enforcer/tests/_manual_pick_rows.py
Normal file
19
steam_backlog_enforcer/tests/_manual_pick_rows.py
Normal file
@ -0,0 +1,19 @@
|
||||
"""Shared manual-pick row builders for the ``_actions`` core tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
|
||||
def iso_days_ago(days: float) -> str:
|
||||
"""Return the UTC ISO timestamp ``days`` days before now."""
|
||||
return (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def pick_row(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
|
||||
"""Return one ``State.manual_picks`` entry started ``days_ago`` days ago."""
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"game_name": name,
|
||||
"started_at": iso_days_ago(days_ago),
|
||||
}
|
||||
21
steam_backlog_enforcer/tests/_no_approved_exceptions.py
Normal file
21
steam_backlog_enforcer/tests/_no_approved_exceptions.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Shared reset: no approved uninstall exceptions for the zero-achievement tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
def no_approved_exceptions() -> Iterator[None]:
|
||||
"""Report no approved exceptions, so only the zero-achievement rule protects.
|
||||
|
||||
A generator for ``yield from`` inside a module's autouse fixture.
|
||||
"""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.game_uninstall.get_approved_exception_ids",
|
||||
return_value=frozenset(),
|
||||
):
|
||||
yield
|
||||
@ -18,11 +18,9 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import (
|
||||
_fast_install,
|
||||
_fast_install_cmd,
|
||||
_fast_install_progress,
|
||||
)
|
||||
from steam_backlog_enforcer import _fast_install_cmd
|
||||
from steam_backlog_enforcer._fast_install import _FAILED, _RUNS
|
||||
from steam_backlog_enforcer._fast_install_progress import _FAILURES, _TRACKERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -41,10 +39,10 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
"""Hide the real installer, fake the /proc scan, reset run bookkeeping."""
|
||||
fake_steamapps = tmp_path / "steamapps"
|
||||
fake_steamapps.mkdir(exist_ok=True)
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
_fast_install_progress._TRACKERS.clear()
|
||||
_fast_install_progress._FAILURES.clear()
|
||||
_RUNS.clear()
|
||||
_FAILED.clear()
|
||||
_TRACKERS.clear()
|
||||
_FAILURES.clear()
|
||||
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
|
||||
patches += [
|
||||
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
|
||||
@ -60,7 +58,7 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
finally:
|
||||
for p in reversed(patches):
|
||||
p.stop()
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
_fast_install_progress._TRACKERS.clear()
|
||||
_fast_install_progress._FAILURES.clear()
|
||||
_RUNS.clear()
|
||||
_FAILED.clear()
|
||||
_TRACKERS.clear()
|
||||
_FAILURES.clear()
|
||||
|
||||
@ -43,7 +43,7 @@ _REAL_PROC = Path("/proc")
|
||||
# Captured at import: _no_subprocess patches ``subprocess.Popen`` on the shared
|
||||
# module for every test, so the name alone would resolve to its MagicMock.
|
||||
# Guarding the class itself also covers code that bound it before that.
|
||||
_REAL_POPEN = subprocess.Popen
|
||||
_RealPopen = subprocess.Popen
|
||||
_MAX_ANCESTRY = 64
|
||||
_DENIED_TOOLS = frozenset(
|
||||
{
|
||||
@ -122,7 +122,7 @@ def _no_real_effects() -> Iterator[list[str]]:
|
||||
blocked: list[str] = []
|
||||
spawned: set[int] = set()
|
||||
real_kill, real_killpg = os.kill, os.killpg
|
||||
real_popen_init: Callable[..., None] = _REAL_POPEN.__init__
|
||||
real_popen_init: Callable[..., None] = _RealPopen.__init__
|
||||
|
||||
def _refuse(what: str) -> RealEffectError:
|
||||
blocked.append(what)
|
||||
@ -162,7 +162,7 @@ def _no_real_effects() -> Iterator[list[str]]:
|
||||
with (
|
||||
patch.object(os, "kill", guarded_kill),
|
||||
patch.object(os, "killpg", guarded_killpg),
|
||||
patch.object(_REAL_POPEN, "__init__", guarded_popen_init),
|
||||
patch.object(_RealPopen, "__init__", guarded_popen_init),
|
||||
# The demo loop never ends on its own; a test reaching it unpatched
|
||||
# must fail at once rather than hang (test_enforce_demo patches it).
|
||||
patch("steam_backlog_enforcer._enforce_demo.playtime_tick", tripwire),
|
||||
|
||||
49
steam_backlog_enforcer/tests/_piano_earner.py
Normal file
49
steam_backlog_enforcer/tests/_piano_earner.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Shared stand-in earner (``piano``) and patch targets for the ledger-earner tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import earned_time
|
||||
|
||||
from steam_backlog_enforcer import _ledger_earners, _leetcode_bonus, _workout_budget
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
|
||||
LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
|
||||
ANSWER = "steam_backlog_enforcer._budget_resolve.ledger_answer"
|
||||
HOUR = 3600.0
|
||||
|
||||
|
||||
def any_credit(_row: dict[str, object], _window: tuple[float, float]) -> bool:
|
||||
"""Count every verified credit: the reader's own dating is earned_time's."""
|
||||
return True
|
||||
|
||||
|
||||
# A hypothetical earner: the real registry already holds anki, so the stand-in
|
||||
# needs a name no gate uses.
|
||||
PIANO = earned_time.Earner(
|
||||
name="piano",
|
||||
label="Piano",
|
||||
gaming_minutes=30,
|
||||
shutdown_minutes=30,
|
||||
ledger=".local/share/piano_guard/ledger.json",
|
||||
match=any_credit,
|
||||
)
|
||||
|
||||
|
||||
def _reset_earner_caches() -> None:
|
||||
"""Empty every earner memo resolve_budget consults."""
|
||||
for module in (_ledger_earners, _workout_budget, _leetcode_bonus):
|
||||
module.reset_cache()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def earner_caches_cleared() -> Iterator[None]:
|
||||
"""Keep every earner memo from leaking answers into or out of the block."""
|
||||
_reset_earner_caches()
|
||||
yield
|
||||
_reset_earner_caches()
|
||||
47
steam_backlog_enforcer/tests/_pick_completion_helpers.py
Normal file
47
steam_backlog_enforcer/tests/_pick_completion_helpers.py
Normal file
@ -0,0 +1,47 @@
|
||||
"""Shared builders for the manual-pick completion tests."""
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from steam_backlog_enforcer.steam_api import AchievementInfo, GameInfo
|
||||
|
||||
|
||||
def game(app_id: int, unlocked: int, total: int, *, new: bool = False) -> GameInfo:
|
||||
"""Build a GameInfo with the given achievement progress.
|
||||
|
||||
Args:
|
||||
app_id: Steam application id.
|
||||
unlocked: Unlocked achievement count.
|
||||
total: Total achievement count.
|
||||
new: Whether one of the unlocks happened just now (after any pick).
|
||||
|
||||
Returns:
|
||||
A GameInfo carrying that progress.
|
||||
"""
|
||||
now = int(datetime.now(UTC).timestamp())
|
||||
achievements = [
|
||||
AchievementInfo(f"a{i}", f"Ach{i}", i < unlocked, now if new and i == 0 else 1)
|
||||
for i in range(total)
|
||||
]
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name="G",
|
||||
total_achievements=total,
|
||||
unlocked_achievements=unlocked,
|
||||
playtime_minutes=0,
|
||||
achievements=achievements,
|
||||
)
|
||||
|
||||
|
||||
def client_returning(*games: GameInfo | None) -> MagicMock:
|
||||
"""Build a fake SteamAPIClient yielding *games* in order.
|
||||
|
||||
Args:
|
||||
games: What each ``refresh_single_game`` call should return.
|
||||
|
||||
Returns:
|
||||
A MagicMock standing in for SteamAPIClient.
|
||||
"""
|
||||
fake = MagicMock()
|
||||
fake.refresh_single_game.side_effect = list(games)
|
||||
return fake
|
||||
21
steam_backlog_enforcer/tests/_playtime_history_files.py
Normal file
21
steam_backlog_enforcer/tests/_playtime_history_files.py
Normal file
@ -0,0 +1,21 @@
|
||||
"""Readers for the playtime history file shared by its test modules."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from steam_backlog_enforcer import _playtime_history as history_mod
|
||||
|
||||
|
||||
def raw() -> dict[str, Any]:
|
||||
"""The history file as stored, read through the (patched) module path."""
|
||||
data: dict[str, Any] = json.loads(
|
||||
history_mod.HISTORY_FILE.read_text(encoding="utf-8")
|
||||
)
|
||||
return data
|
||||
|
||||
|
||||
def stored() -> dict[str, float]:
|
||||
"""Day totals, with the per-game breakdown unwrapped away."""
|
||||
return {day: entry["seconds"] for day, entry in raw()["days"].items()}
|
||||
130
steam_backlog_enforcer/tests/_polls_helpers.py
Normal file
130
steam_backlog_enforcer/tests/_polls_helpers.py
Normal file
@ -0,0 +1,130 @@
|
||||
"""Shared builders and patches for the HLTB poll-count tracking tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer._cmd_done import _report_assigned_confidence
|
||||
from steam_backlog_enforcer.config import State
|
||||
from steam_backlog_enforcer.scanning import _report_poll_confidence
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
TYPES = "steam_backlog_enforcer._hltb_types"
|
||||
CMD = "steam_backlog_enforcer._cmd_done"
|
||||
SCAN = "steam_backlog_enforcer.scanning"
|
||||
SCANCONF = "steam_backlog_enforcer._scanning_confidence"
|
||||
POLLS = "steam_backlog_enforcer._polls_reporting"
|
||||
FETCH_CONFIDENCE = "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence"
|
||||
|
||||
|
||||
def poll_state(finished: list[int], current: int | None = None) -> State:
|
||||
"""Return a state with the given finished games and current pick."""
|
||||
s = State()
|
||||
s.finished_app_ids = list(finished)
|
||||
s.current_app_id = current
|
||||
s.current_game_name = ""
|
||||
return s
|
||||
|
||||
|
||||
def make_game(
|
||||
app_id: int,
|
||||
name: str,
|
||||
*,
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
polls: tuple[int, int] = (0, 0),
|
||||
) -> GameInfo:
|
||||
"""Return an unplayed GameInfo; ``polls`` is (comp_100_count, count_comp)."""
|
||||
comp_100_count, count_comp = polls
|
||||
return GameInfo(
|
||||
app_id,
|
||||
name,
|
||||
total,
|
||||
unlocked,
|
||||
playtime_minutes=0,
|
||||
comp_100_count=comp_100_count,
|
||||
count_comp=count_comp,
|
||||
)
|
||||
|
||||
|
||||
def write_cache(tmp_path: Path, data: dict[str, object]) -> Path:
|
||||
"""Write ``data`` as the HLTB cache file under ``tmp_path`` and return it."""
|
||||
cache_file = tmp_path / "hltb_cache.json"
|
||||
cache_file.write_text(json.dumps(data), encoding="utf-8")
|
||||
return cache_file
|
||||
|
||||
|
||||
def filling_fetch(
|
||||
hours: float, polls_n: int, count_comp_n: int = 0
|
||||
) -> Callable[..., list[object]]:
|
||||
"""Return a fake HLTB search that writes the same row for every game asked."""
|
||||
|
||||
def fake_fetch(
|
||||
games: list[tuple[int, str]],
|
||||
cache: dict[int, float],
|
||||
polls: dict[int, int],
|
||||
count_comp: dict[int, int],
|
||||
**_kw: object,
|
||||
) -> list[object]:
|
||||
"""Write into the caches it is handed."""
|
||||
for aid, _name in games:
|
||||
cache[aid], polls[aid], count_comp[aid] = hours, polls_n, count_comp_n
|
||||
return []
|
||||
|
||||
return fake_fetch
|
||||
|
||||
|
||||
@contextmanager
|
||||
def hltb_cache_patched(
|
||||
cache_file: Path, tmp_path: Path, fetch: Callable[..., list[object]]
|
||||
) -> Iterator[MagicMock]:
|
||||
"""Point the HLTB cache at ``cache_file``; route confidence fetches to ``fetch``."""
|
||||
with (
|
||||
patch(f"{TYPES}.HLTB_CACHE_FILE", cache_file),
|
||||
patch(f"{TYPES}.CONFIG_DIR", tmp_path),
|
||||
patch(FETCH_CONFIDENCE, side_effect=fetch) as mock_fetch,
|
||||
):
|
||||
yield mock_fetch
|
||||
|
||||
|
||||
@contextmanager
|
||||
def echo_sink(target: str, echoed: list[str]) -> Iterator[MagicMock]:
|
||||
"""Patch ``target._echo`` so each echoed first arg lands in ``echoed``."""
|
||||
with patch(
|
||||
f"{target}._echo", side_effect=lambda *a, **_: echoed.append(a[0])
|
||||
) as mock_echo:
|
||||
yield mock_echo
|
||||
|
||||
|
||||
def poll_confidence_echoes(
|
||||
chosen: GameInfo, games: list[GameInfo], polls: dict[int, int], state: State
|
||||
) -> list[str]:
|
||||
"""Run ``_report_poll_confidence`` with backfilled ``polls``; return its echoes."""
|
||||
echoed: list[str] = []
|
||||
with (
|
||||
patch(f"{POLLS}._backfill_polls_for_finished", return_value=polls),
|
||||
echo_sink(POLLS, echoed),
|
||||
):
|
||||
_report_poll_confidence(chosen, games, state)
|
||||
return echoed
|
||||
|
||||
|
||||
def assigned_confidence_echoes(
|
||||
polls: dict[int, int], snapshot: list[dict[str, object]], state: State
|
||||
) -> list[str]:
|
||||
"""Run ``_report_assigned_confidence`` for app 1; return its echoes."""
|
||||
echoed: list[str] = []
|
||||
with (
|
||||
patch(f"{CMD}._backfill_polls_for_finished", return_value=polls),
|
||||
patch(f"{CMD}.load_snapshot", return_value=snapshot),
|
||||
echo_sink(CMD, echoed),
|
||||
):
|
||||
_report_assigned_confidence(1, state)
|
||||
return echoed
|
||||
35
steam_backlog_enforcer/tests/_protondb_cache.py
Normal file
35
steam_backlog_enforcer/tests/_protondb_cache.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Shared ProtonDB cache-file redirection for the protondb tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
PKG = "steam_backlog_enforcer.protondb"
|
||||
|
||||
|
||||
def write_gold_cache(tmp_path: Path) -> Path:
|
||||
"""Write a cache file rating app 440 gold and return its path."""
|
||||
cache_file = tmp_path / "protondb_cache.json"
|
||||
cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
|
||||
return cache_file
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cache_at(cache_file: Path) -> Iterator[None]:
|
||||
"""Point the ProtonDB cache at ``cache_file`` for reads only."""
|
||||
with patch(f"{PKG}.PROTONDB_CACHE_FILE", cache_file):
|
||||
yield
|
||||
|
||||
|
||||
@contextmanager
|
||||
def writable_cache_at(cache_file: Path, config_dir: Path) -> Iterator[None]:
|
||||
"""Point the cache file and its config dir at test paths so saves land there."""
|
||||
with cache_at(cache_file), patch(f"{PKG}.CONFIG_DIR", config_dir):
|
||||
yield
|
||||
123
steam_backlog_enforcer/tests/_scanning_games.py
Normal file
123
steam_backlog_enforcer/tests/_scanning_games.py
Normal file
@ -0,0 +1,123 @@
|
||||
"""Shared builders and patch sets for the pick_next_game / scanning tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.scanning import pick_next_game
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator, Sequence
|
||||
|
||||
_PKG = "steam_backlog_enforcer"
|
||||
_ASSIGN = f"{_PKG}._scanning_assign"
|
||||
|
||||
|
||||
def make_game(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
hours: float = -1,
|
||||
) -> GameInfo:
|
||||
"""Build a game that passes the HLTB confidence floor (3 polls, 15 runs)."""
|
||||
return GameInfo(
|
||||
app_id,
|
||||
name,
|
||||
total_achievements=total,
|
||||
unlocked_achievements=unlocked,
|
||||
playtime_minutes=60,
|
||||
completionist_hours=hours,
|
||||
comp_100_count=3,
|
||||
count_comp=15,
|
||||
)
|
||||
|
||||
|
||||
def make_config(**overrides: bool) -> Config:
|
||||
"""Build the minimal Config the scanning tests run against."""
|
||||
return Config(steam_api_key="k", steam_id="i", **overrides)
|
||||
|
||||
|
||||
def first_candidate(candidates: list[GameInfo]) -> GameInfo | None:
|
||||
"""Stand in for ProtonDB: every candidate is playable, take the first."""
|
||||
return candidates[0] if candidates else None
|
||||
|
||||
|
||||
def echo_into(echoed: list[str]) -> Callable[..., None]:
|
||||
"""Return an ``_echo`` replacement that records each first argument."""
|
||||
return lambda *a, **_: echoed.append(a[0])
|
||||
|
||||
|
||||
@dataclass
|
||||
class PickMocks:
|
||||
"""Mocks started by :func:`pick_env`, keyed by what they replace."""
|
||||
|
||||
pick: MagicMock
|
||||
uninstall: MagicMock
|
||||
install: MagicMock
|
||||
|
||||
|
||||
@contextmanager
|
||||
def pick_env(
|
||||
*,
|
||||
echo: Sequence[str] = ("scanning",),
|
||||
echoed: list[str] | None = None,
|
||||
installed: bool = True,
|
||||
answers: str | list[str] | None = "1",
|
||||
playable: Callable[[list[GameInfo]], GameInfo | None] = first_candidate,
|
||||
) -> Iterator[PickMocks]:
|
||||
"""Patch everything pick_next_game touches outside the decision itself.
|
||||
|
||||
``echo`` names the modules (relative to the package) whose ``_echo`` is
|
||||
replaced; with ``echoed`` set they record into it, else they are silenced.
|
||||
``install_game`` and ``uninstall_other_games`` (0 games removed) are
|
||||
always mocked so no test touches Steam. ``answers=None`` leaves
|
||||
``input`` alone (the ``on_select`` path never prompts).
|
||||
"""
|
||||
with ExitStack() as stack:
|
||||
pick = stack.enter_context(
|
||||
patch(
|
||||
f"{_PKG}._scanning_candidates._pick_playable_candidate",
|
||||
side_effect=playable,
|
||||
)
|
||||
)
|
||||
for module in echo:
|
||||
target = f"{_PKG}.{module}._echo"
|
||||
if echoed is None:
|
||||
stack.enter_context(patch(target))
|
||||
else:
|
||||
stack.enter_context(patch(target, side_effect=echo_into(echoed)))
|
||||
stack.enter_context(
|
||||
patch(f"{_ASSIGN}.is_game_fully_installed", return_value=installed)
|
||||
)
|
||||
mocks = PickMocks(
|
||||
pick=pick,
|
||||
uninstall=stack.enter_context(
|
||||
patch(f"{_ASSIGN}.uninstall_other_games", return_value=0)
|
||||
),
|
||||
install=stack.enter_context(patch(f"{_ASSIGN}.install_game")),
|
||||
)
|
||||
if isinstance(answers, str):
|
||||
stack.enter_context(patch("builtins.input", return_value=answers))
|
||||
elif answers is not None:
|
||||
stack.enter_context(patch("builtins.input", side_effect=answers))
|
||||
yield mocks
|
||||
|
||||
|
||||
def pick_without_batch_refresh(games: list[GameInfo]) -> State:
|
||||
"""Pick from ``games`` and assert the batch confidence refresh never ran."""
|
||||
state = State()
|
||||
with (
|
||||
patch(
|
||||
f"{_PKG}._scanning_confidence._refresh_candidate_confidence_batch"
|
||||
) as mock_refresh_batch,
|
||||
pick_env(),
|
||||
):
|
||||
pick_next_game(games, state, make_config())
|
||||
mock_refresh_batch.assert_not_called()
|
||||
return state
|
||||
83
steam_backlog_enforcer/tests/_stats_cmd_env.py
Normal file
83
steam_backlog_enforcer/tests/_stats_cmd_env.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""Shared snapshot builders and the patch set the ``cmd_stats`` tests run under."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._stats import _GameTimes
|
||||
from steam_backlog_enforcer.config import Config
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
from steam_backlog_enforcer.tests._scanning_games import echo_into
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
STATS = "steam_backlog_enforcer._stats"
|
||||
|
||||
|
||||
def stats_config() -> Config:
|
||||
"""Build the minimal Config cmd_stats runs against."""
|
||||
return Config(steam_api_key="k", steam_id="i")
|
||||
|
||||
|
||||
def snapshot_game(app_id: int = 1, hours: float = 20.0) -> dict[str, object]:
|
||||
"""Build one unstarted, confidently-timed game as stored in the snapshot."""
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"name": f"Game{app_id}",
|
||||
"total_achievements": 10,
|
||||
"unlocked_achievements": 0,
|
||||
"playtime_minutes": 60,
|
||||
"completionist_hours": hours,
|
||||
"comp_100_count": 5,
|
||||
"count_comp": 20,
|
||||
}
|
||||
|
||||
|
||||
def times_entry(
|
||||
snapshot: dict[str, object], rush: float = 15.0, leisure: float = 25.0
|
||||
) -> _GameTimes:
|
||||
"""Wrap a snapshot game as a qualifying entry worth 20 h worst-case."""
|
||||
return _GameTimes(
|
||||
game=GameInfo.from_snapshot(snapshot),
|
||||
worst_hours=20.0,
|
||||
rush_hours=rush,
|
||||
leisure_100h=leisure,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def cmd_stats_env(
|
||||
snapshot: list[dict[str, object]],
|
||||
filtered: object,
|
||||
echoed: list[str] | None = None,
|
||||
**extra: dict[str, object],
|
||||
) -> Iterator[None]:
|
||||
"""Patch cmd_stats's collaborators around ``snapshot``.
|
||||
|
||||
``filtered`` is what ``_filter_qualifying_games`` returns, or a callable
|
||||
used as its side effect. ``_echo`` records into ``echoed`` when given.
|
||||
Each ``extra`` item patches ``_stats.<name>`` with those keyword arguments,
|
||||
replacing the default patch of the same name.
|
||||
"""
|
||||
key = "side_effect" if callable(filtered) else "return_value"
|
||||
patches: dict[str, dict[str, object]] = {
|
||||
"_filter_qualifying_games": {key: filtered},
|
||||
"_ensure_completed_rush_data": {"return_value": False},
|
||||
"_print_player_speed_scenario": {},
|
||||
"_echo": {} if echoed is None else {"side_effect": echo_into(echoed)},
|
||||
"_print_pace_scenario": {},
|
||||
"_print_scenario": {},
|
||||
}
|
||||
patches.update(extra)
|
||||
with ExitStack() as stack:
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot
|
||||
)
|
||||
)
|
||||
for name, kwargs in patches.items():
|
||||
stack.enter_context(patch(f"{STATS}.{name}", **kwargs))
|
||||
yield
|
||||
83
steam_backlog_enforcer/tests/_stats_games.py
Normal file
83
steam_backlog_enforcer/tests/_stats_games.py
Normal file
@ -0,0 +1,83 @@
|
||||
"""Shared game and rating builders for the ``_stats`` tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._stats import _filter_qualifying_games
|
||||
from steam_backlog_enforcer.protondb import ProtonDBRating
|
||||
from steam_backlog_enforcer.tests._scanning_games import (
|
||||
make_game as make_scanning_game,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from steam_backlog_enforcer._stats import _GameTimes
|
||||
from steam_backlog_enforcer.config import State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
_GATHER = "steam_backlog_enforcer._stats_gathering"
|
||||
|
||||
|
||||
def make_game(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
hours: float = 10.0,
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
) -> GameInfo:
|
||||
"""Build a game with confident HLTB data (5 polls, 20 runs)."""
|
||||
game = make_scanning_game(app_id, name, total, unlocked, hours)
|
||||
game.comp_100_count = 5
|
||||
game.count_comp = 20
|
||||
return game
|
||||
|
||||
|
||||
def unplayable_rating(app_id: int) -> ProtonDBRating:
|
||||
"""Build a ProtonDB rating that marks the game unplayable on Linux."""
|
||||
return ProtonDBRating(app_id=app_id, tier="borked")
|
||||
|
||||
|
||||
@dataclass
|
||||
class FilterInputs:
|
||||
"""What _filter_qualifying_games reads from the HLTB caches and ProtonDB."""
|
||||
|
||||
rush_cache: dict[int, float] = field(default_factory=dict)
|
||||
leisure_cache: dict[int, float] = field(default_factory=dict)
|
||||
game_id_cache: dict[int, int] = field(default_factory=dict)
|
||||
fail_reasons: list[str] = field(default_factory=list)
|
||||
ratings: dict[int, ProtonDBRating] = field(default_factory=dict)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def filter_env(inputs: FilterInputs | None = None) -> Iterator[MagicMock]:
|
||||
"""Feed _filter_qualifying_games ``inputs``; yield the ProtonDB fetch mock."""
|
||||
given = inputs or FilterInputs()
|
||||
with (
|
||||
patch(f"{_GATHER}.load_hltb_rush_cache", return_value=given.rush_cache),
|
||||
patch(
|
||||
f"{_GATHER}.load_hltb_leisure_100h_cache",
|
||||
return_value=given.leisure_cache,
|
||||
),
|
||||
patch(f"{_GATHER}.load_hltb_game_id_cache", return_value=given.game_id_cache),
|
||||
patch(f"{_GATHER}._apply_cached_confidence_to_candidates"),
|
||||
patch(f"{_GATHER}._refresh_candidate_confidence_batch"),
|
||||
patch(f"{_GATHER}._confidence_fail_reasons", return_value=given.fail_reasons),
|
||||
patch(
|
||||
f"{_GATHER}.fetch_protondb_ratings", return_value=given.ratings
|
||||
) as proton,
|
||||
):
|
||||
yield proton
|
||||
|
||||
|
||||
def run_filter(
|
||||
games: list[GameInfo], state: State, inputs: FilterInputs | None = None
|
||||
) -> tuple[list[_GameTimes], int, int, int]:
|
||||
"""Run _filter_qualifying_games under :func:`filter_env`."""
|
||||
with filter_env(inputs):
|
||||
return _filter_qualifying_games(games, state)
|
||||
75
steam_backlog_enforcer/tests/_web_dataset_factories.py
Normal file
75
steam_backlog_enforcer/tests/_web_dataset_factories.py
Normal file
@ -0,0 +1,75 @@
|
||||
"""Shared GameInfo/WebGame/HLTB-cache builders for the _web_dataset tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import replace
|
||||
from typing import Any
|
||||
|
||||
from steam_backlog_enforcer._web_dataset import WebGame
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
|
||||
def _gi(**over: object) -> GameInfo:
|
||||
"""Build a GameInfo with field overrides."""
|
||||
base = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=0,
|
||||
playtime_minutes=60,
|
||||
completionist_hours=20.0,
|
||||
comp_100_count=5,
|
||||
count_comp=20,
|
||||
)
|
||||
return replace(base, **over)
|
||||
|
||||
|
||||
def _wg(**over: object) -> WebGame:
|
||||
"""Build a WebGame with field overrides."""
|
||||
base = WebGame(
|
||||
app_id=1,
|
||||
name="Game1",
|
||||
completion_pct=0.0,
|
||||
playtime_minutes=60,
|
||||
rush_hours=10.0,
|
||||
leisure_hours=20.0,
|
||||
worst_hours=25.0,
|
||||
count_comp=20,
|
||||
comp_100_count=5,
|
||||
hltb_game_id=0,
|
||||
protondb_tier="gold",
|
||||
protondb_trending_tier="gold",
|
||||
protondb_score=0.8,
|
||||
)
|
||||
return replace(base, **over)
|
||||
|
||||
|
||||
def _complete_game(
|
||||
app_id: int = 1,
|
||||
playtime_minutes: int = 600,
|
||||
) -> GameInfo:
|
||||
"""Complete game (100 % achievements, has playtime)."""
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=f"Done{app_id}",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=playtime_minutes,
|
||||
completionist_hours=0.0,
|
||||
comp_100_count=5,
|
||||
count_comp=20,
|
||||
)
|
||||
|
||||
|
||||
def _hltb_cache(rush_hours: float, leisure_100h: float) -> dict[int, dict[str, Any]]:
|
||||
"""HLTB cache holding app 1 (10 h, 5 polls) with the given rush/leisure hours."""
|
||||
return {
|
||||
1: {
|
||||
"hours": 10.0,
|
||||
"polls": 5,
|
||||
"count_comp": 20,
|
||||
"rush_hours": rush_hours,
|
||||
"leisure_100h": leisure_100h,
|
||||
"hltb_game_id": 0,
|
||||
}
|
||||
}
|
||||
25
steam_backlog_enforcer/tests/_web_process_state.py
Normal file
25
steam_backlog_enforcer/tests/_web_process_state.py
Normal file
@ -0,0 +1,25 @@
|
||||
"""Shared per-test reset of the web server's process-wide state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer import _web_process
|
||||
from steam_backlog_enforcer._web_process import _server
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
def clean_process_state() -> Iterator[None]:
|
||||
"""Clear the retiring flag and the server slot, never reporting "stale".
|
||||
|
||||
A generator for ``yield from`` inside a module's autouse fixture, so
|
||||
other edits to the source tree cannot make a test see a stale server.
|
||||
"""
|
||||
_web_process.RETIRING.clear()
|
||||
with patch("steam_backlog_enforcer._web_server.outdated_source", return_value=None):
|
||||
yield
|
||||
_web_process.RETIRING.clear()
|
||||
_server.clear()
|
||||
@ -6,8 +6,8 @@ import json
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer import _achievement_skip as skip
|
||||
from steam_backlog_enforcer import config
|
||||
from steam_backlog_enforcer._achievement_skip import _cache_file
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
@ -21,6 +21,7 @@ NOW = 1_000_000_000.0
|
||||
|
||||
|
||||
def write(path: Path, payload: object) -> None:
|
||||
"""Store *payload* as JSON at *path*, as the real config writers do."""
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
@ -28,12 +29,13 @@ def confirm_zero(*app_ids: int, steam_id: str = STEAM_ID) -> None:
|
||||
"""Make the real cache say Steam answered "no achievements" for these."""
|
||||
write(config.CONFIG_FILE, {"steam_id": STEAM_ID})
|
||||
write(
|
||||
skip._cache_file(),
|
||||
_cache_file(),
|
||||
{"steam_id": steam_id, "checked": {str(a): NOW for a in app_ids}},
|
||||
)
|
||||
|
||||
|
||||
def write_snapshot(*app_ids: int) -> None:
|
||||
"""Make the snapshot list these apps, each with achievements to earn."""
|
||||
write(
|
||||
config.SNAPSHOT_FILE, [{"app_id": a, "total_achievements": 5} for a in app_ids]
|
||||
)
|
||||
@ -50,6 +52,7 @@ def recorder(removed: list[int]) -> Callable[..., bool]:
|
||||
|
||||
|
||||
def js_list(pattern: str, js: str) -> list[int]:
|
||||
"""Pull the JSON app-id list that *pattern* captures out of *js*."""
|
||||
match = re.search(pattern, js)
|
||||
assert match is not None
|
||||
return list(json.loads(match.group(1)))
|
||||
|
||||
@ -10,6 +10,7 @@ import pytest
|
||||
|
||||
from steam_backlog_enforcer import _achievement_skip as skip
|
||||
from steam_backlog_enforcer import config
|
||||
from steam_backlog_enforcer._achievement_skip import _cache_file, _load_checked
|
||||
|
||||
_PKG = "steam_backlog_enforcer._achievement_skip"
|
||||
_NOW = 1_000_000_000.0
|
||||
@ -17,7 +18,7 @@ _STEAM_ID = "sid"
|
||||
|
||||
|
||||
def _store(payload: object) -> None:
|
||||
skip._cache_file().write_text(json.dumps(payload), encoding="utf-8")
|
||||
_cache_file().write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
|
||||
def _game(app_id: int, *, stats: bool = False) -> dict[str, Any]:
|
||||
@ -46,60 +47,82 @@ def _failure(status: int | None, body: object = None) -> Exception:
|
||||
|
||||
|
||||
class TestCacheFile:
|
||||
"""Where the no-achievements cache lives."""
|
||||
|
||||
def test_lives_under_the_config_dir(self) -> None:
|
||||
assert skip._cache_file() == config.CONFIG_DIR / "no_achievements_cache.json"
|
||||
"""The cache sits in the config dir under a fixed name."""
|
||||
assert _cache_file() == config.CONFIG_DIR / "no_achievements_cache.json"
|
||||
|
||||
|
||||
class TestLoadChecked:
|
||||
"""Reading the per-account checked map back from disk."""
|
||||
|
||||
def test_missing_file_is_empty(self) -> None:
|
||||
assert skip._load_checked(_STEAM_ID) == {}
|
||||
"""No cache file means nothing was checked."""
|
||||
assert _load_checked(_STEAM_ID) == {}
|
||||
|
||||
def test_corrupt_file_is_empty(self) -> None:
|
||||
skip._cache_file().write_text("{nope", encoding="utf-8")
|
||||
assert skip._load_checked(_STEAM_ID) == {}
|
||||
"""A corrupt cache file reads as nothing checked."""
|
||||
_cache_file().write_text("{nope", encoding="utf-8")
|
||||
assert _load_checked(_STEAM_ID) == {}
|
||||
|
||||
@pytest.mark.parametrize("payload", [["list"], {"steam_id": "other"}])
|
||||
def test_other_account_or_shape_is_empty(self, payload: object) -> None:
|
||||
"""A cache for another account, or of the wrong shape, reads as empty."""
|
||||
_store(payload)
|
||||
assert skip._load_checked(_STEAM_ID) == {}
|
||||
assert _load_checked(_STEAM_ID) == {}
|
||||
|
||||
def test_skips_unparsable_entries(self) -> None:
|
||||
"""Entries with an unparsable id or timestamp are dropped."""
|
||||
_store({"steam_id": _STEAM_ID, "checked": {"1": 5, "x": 6, "2": None}})
|
||||
assert skip._load_checked(_STEAM_ID) == {1: 5.0}
|
||||
assert _load_checked(_STEAM_ID) == {1: 5.0}
|
||||
|
||||
|
||||
class TestIsNoStatsAnswer:
|
||||
"""Recognising Steam's "this game has no stats" failure."""
|
||||
|
||||
def test_steams_no_stats_answer(self) -> None:
|
||||
"""Steam's 400 with ``success: False`` is the no-stats answer."""
|
||||
body = {"playerstats": {"error": "no stats", "success": False}}
|
||||
assert skip.is_no_stats_answer(_failure(400, body)) is True
|
||||
|
||||
def test_no_response_is_not_an_answer(self) -> None:
|
||||
"""A failure with no HTTP response is not the no-stats answer."""
|
||||
assert skip.is_no_stats_answer(_failure(None)) is False
|
||||
|
||||
def test_other_status_is_not_an_answer(self) -> None:
|
||||
"""A non-400 status is not the no-stats answer."""
|
||||
assert skip.is_no_stats_answer(_failure(500, {})) is False
|
||||
|
||||
def test_unparsable_body_is_not_an_answer(self) -> None:
|
||||
"""A body that is not JSON is not the no-stats answer."""
|
||||
assert skip.is_no_stats_answer(_failure(400, ValueError("html"))) is False
|
||||
|
||||
def test_body_without_failure_flag_is_not_an_answer(self) -> None:
|
||||
"""A body missing the failure flag is not the no-stats answer."""
|
||||
assert skip.is_no_stats_answer(_failure(400, {"playerstats": {}})) is False
|
||||
|
||||
def test_non_dict_playerstats_is_not_an_answer(self) -> None:
|
||||
"""A non-dict ``playerstats`` is not the no-stats answer."""
|
||||
assert skip.is_no_stats_answer(_failure(400, {"playerstats": 3})) is False
|
||||
|
||||
|
||||
class TestSkippableAppIds:
|
||||
"""Choosing which owned games the scan may skip."""
|
||||
|
||||
def test_nothing_recorded_skips_nothing(self) -> None:
|
||||
"""With nothing recorded no game is skipped."""
|
||||
assert skip.skippable_app_ids(_STEAM_ID, [_game(1)], _NOW) == set()
|
||||
|
||||
def test_recent_flagless_game_is_skipped(self) -> None:
|
||||
"""A recently checked game without the stats flag is skipped."""
|
||||
_store({"steam_id": _STEAM_ID, "checked": {"1": _NOW - 10}})
|
||||
with patch(f"{_PKG}.load_snapshot", return_value=None):
|
||||
found = skip.skippable_app_ids(_STEAM_ID, [_game(1)], _NOW)
|
||||
assert found == {1}
|
||||
|
||||
def test_flagged_snapshotted_and_stale_games_are_scanned(self) -> None:
|
||||
"""Flagged, snapshotted and stale games are scanned again."""
|
||||
stale = _NOW - skip.RECHECK_AFTER_SECONDS - 1
|
||||
_store(
|
||||
{
|
||||
@ -114,20 +137,25 @@ class TestSkippableAppIds:
|
||||
|
||||
|
||||
class TestRecordScan:
|
||||
"""Writing scan results back to the cache."""
|
||||
|
||||
def _saved(self) -> dict[str, float]:
|
||||
return json.loads(skip._cache_file().read_text(encoding="utf-8"))["checked"]
|
||||
return json.loads(_cache_file().read_text(encoding="utf-8"))["checked"]
|
||||
|
||||
def test_records_games_that_came_back_empty(self) -> None:
|
||||
"""Games that came back with no achievements are recorded."""
|
||||
skip.record_scan(_STEAM_ID, [_game(1), _game(2)], {2}, _NOW)
|
||||
assert self._saved() == {"1": _NOW}
|
||||
|
||||
def test_forgets_games_that_gained_achievements_or_the_flag(self) -> None:
|
||||
"""Games that gained achievements or the stats flag are forgotten."""
|
||||
_store({"steam_id": _STEAM_ID, "checked": {"1": 5, "2": 5, "3": 5}})
|
||||
fetched = [_game(1), _game(2, stats=True), _game(3)]
|
||||
skip.record_scan(_STEAM_ID, fetched, {1}, _NOW)
|
||||
assert self._saved() == {"3": _NOW}
|
||||
|
||||
def test_a_failed_write_is_swallowed(self) -> None:
|
||||
"""A failed cache write is swallowed and leaves no file."""
|
||||
with patch(f"{_PKG}._atomic_write", side_effect=OSError("disk full")):
|
||||
skip.record_scan(_STEAM_ID, [_game(1)], set(), _NOW)
|
||||
assert not skip._cache_file().exists()
|
||||
assert not _cache_file().exists()
|
||||
|
||||
@ -2,8 +2,6 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _allowed_games
|
||||
@ -13,18 +11,7 @@ from steam_backlog_enforcer._actions import (
|
||||
)
|
||||
from steam_backlog_enforcer._manual_pick_lifecycle import manual_pick_age_days
|
||||
from steam_backlog_enforcer.config import State
|
||||
|
||||
|
||||
def _iso_days_ago(days: float) -> str:
|
||||
return (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"game_name": name,
|
||||
"started_at": _iso_days_ago(days_ago),
|
||||
}
|
||||
from steam_backlog_enforcer.tests._manual_pick_rows import iso_days_ago, pick_row
|
||||
|
||||
|
||||
class TestIsManualPickLocked:
|
||||
@ -52,7 +39,7 @@ class TestIsManualPickLocked:
|
||||
"""Test recent pick is locked."""
|
||||
state = State(
|
||||
manual_picks=[
|
||||
{"app_id": 5, "game_name": "G", "started_at": _iso_days_ago(1)}
|
||||
{"app_id": 5, "game_name": "G", "started_at": iso_days_ago(1)}
|
||||
]
|
||||
)
|
||||
assert is_manual_pick_locked(state) is True
|
||||
@ -64,7 +51,7 @@ class TestIsManualPickLocked:
|
||||
{
|
||||
"app_id": 5,
|
||||
"game_name": "G",
|
||||
"started_at": _iso_days_ago(_allowed_games.MANUAL_LOCK_DAYS + 1),
|
||||
"started_at": iso_days_ago(_allowed_games.MANUAL_LOCK_DAYS + 1),
|
||||
}
|
||||
]
|
||||
)
|
||||
@ -75,8 +62,8 @@ class TestIsManualPickLocked:
|
||||
"""Test one active pick holds the lock for all."""
|
||||
state = State(
|
||||
manual_picks=[
|
||||
{"app_id": 5, "game_name": "Done", "started_at": _iso_days_ago(1)},
|
||||
{"app_id": 6, "game_name": "Live", "started_at": _iso_days_ago(1)},
|
||||
{"app_id": 5, "game_name": "Done", "started_at": iso_days_ago(1)},
|
||||
{"app_id": 6, "game_name": "Live", "started_at": iso_days_ago(1)},
|
||||
],
|
||||
finished_app_ids=[5],
|
||||
)
|
||||
@ -88,7 +75,7 @@ class TestLegacyManualPickMigration:
|
||||
|
||||
def test_legacy_pick_is_migrated_on_load(self) -> None:
|
||||
"""Test legacy pick is migrated on load."""
|
||||
started = _iso_days_ago(1)
|
||||
started = iso_days_ago(1)
|
||||
State(
|
||||
manual_pick_app_id=5,
|
||||
manual_pick_game_name="Legacy",
|
||||
@ -159,7 +146,7 @@ class TestManualPickAgeDays:
|
||||
|
||||
def test_unknown_app_id_returns_none(self) -> None:
|
||||
"""Test unknown app id returns none."""
|
||||
state = State(manual_picks=[_pick(440)])
|
||||
state = State(manual_picks=[pick_row(440)])
|
||||
assert manual_pick_age_days(state, 999) is None
|
||||
|
||||
def test_missing_timestamp_returns_none(self) -> None:
|
||||
@ -178,14 +165,14 @@ class TestManualPickAgeDays:
|
||||
|
||||
def test_fresh_pick_is_almost_zero_days_old(self) -> None:
|
||||
"""Test fresh pick is almost zero days old."""
|
||||
state = State(manual_picks=[_pick(days_ago=0)])
|
||||
state = State(manual_picks=[pick_row(days_ago=0)])
|
||||
age = manual_pick_age_days(state, 440)
|
||||
assert age is not None
|
||||
assert age == pytest.approx(0.0, abs=0.01)
|
||||
|
||||
def test_old_pick_reports_its_age(self) -> None:
|
||||
"""Test old pick reports its age."""
|
||||
state = State(manual_picks=[_pick(days_ago=8)])
|
||||
state = State(manual_picks=[pick_row(days_ago=8)])
|
||||
age = manual_pick_age_days(state, 440)
|
||||
assert age is not None
|
||||
assert age == pytest.approx(8.0, abs=0.01)
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from datetime import UTC, datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._manual_pick_lifecycle import status_payload
|
||||
@ -10,18 +10,6 @@ from steam_backlog_enforcer._total_block import TotalBlockStatus
|
||||
from steam_backlog_enforcer.config import State
|
||||
|
||||
|
||||
def _iso_days_ago(days: float) -> str:
|
||||
return (datetime.now(UTC) - timedelta(days=days)).isoformat()
|
||||
|
||||
|
||||
def _pick(app_id: int = 440, name: str = "TF2", days_ago: float = 1.0) -> dict:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"game_name": name,
|
||||
"started_at": _iso_days_ago(days_ago),
|
||||
}
|
||||
|
||||
|
||||
class TestStatusPayload:
|
||||
"""Tests for Status Payload."""
|
||||
|
||||
|
||||
@ -62,35 +62,48 @@ def assigned_state(app_id: int = 1) -> State:
|
||||
|
||||
|
||||
class TestIsoToEpoch:
|
||||
"""Parsing ISO stamps into epoch seconds."""
|
||||
|
||||
def test_parses(self) -> None:
|
||||
"""A valid ISO stamp becomes its epoch seconds."""
|
||||
assert iso_to_epoch(ASSIGNED.isoformat()) == ASSIGNED.timestamp()
|
||||
|
||||
def test_empty_and_malformed_are_unknown(self) -> None:
|
||||
"""Empty or malformed stamps parse as unknown."""
|
||||
assert iso_to_epoch("") is None
|
||||
assert iso_to_epoch("not a date") is None
|
||||
|
||||
|
||||
class TestNewestUnlockSince:
|
||||
"""Finding the newest unlock after the assignment baseline."""
|
||||
|
||||
def test_picks_the_latest_unlock_after_the_baseline(self) -> None:
|
||||
"""The latest unlock after the baseline is the one returned."""
|
||||
g = game(("old", BEFORE), ("a", AFTER), ("b", LATER))
|
||||
newest = newest_unlock_since(g, ASSIGNED.isoformat())
|
||||
assert newest is not None
|
||||
assert newest.display_name == "b"
|
||||
|
||||
def test_unlocks_before_the_baseline_do_not_count(self) -> None:
|
||||
"""Unlocks from before the baseline do not count."""
|
||||
assert newest_unlock_since(game(("old", BEFORE)), ASSIGNED.isoformat()) is None
|
||||
|
||||
def test_unknown_baseline_never_counts(self) -> None:
|
||||
"""An unknown baseline never counts any unlock."""
|
||||
assert newest_unlock_since(game(("a", AFTER)), "") is None
|
||||
|
||||
|
||||
class TestAssignmentBaseline:
|
||||
"""Which timestamp an assignment is measured from."""
|
||||
|
||||
def test_active_pick_uses_its_started_at(self) -> None:
|
||||
"""An active manual pick is measured from its own start."""
|
||||
state = assigned_state()
|
||||
state.manual_picks = [{"app_id": 1, "game_name": "G", "started_at": "P"}]
|
||||
assert assignment_baseline(state, 1) == "P"
|
||||
|
||||
def test_released_pick_falls_back_to_the_assignment(self) -> None:
|
||||
"""A released pick falls back to the assignment stamp."""
|
||||
state = assigned_state()
|
||||
state.manual_picks = [
|
||||
{"app_id": 1, "game_name": "G", "started_at": "P", "released_at": "R"}
|
||||
@ -98,33 +111,43 @@ class TestAssignmentBaseline:
|
||||
assert assignment_baseline(state, 1) == ASSIGNED.isoformat()
|
||||
|
||||
def test_unassigned_game_has_no_baseline(self) -> None:
|
||||
"""A game that is not assigned has no baseline."""
|
||||
assert assignment_baseline(assigned_state(), 2) == ""
|
||||
|
||||
|
||||
class TestReleaseVerdict:
|
||||
"""Deciding whether the current game may be released."""
|
||||
|
||||
def test_new_achievement_releases(self) -> None:
|
||||
"""A new achievement since assignment releases the game."""
|
||||
released, why = release_verdict(assigned_state(), game(("Win", AFTER)))
|
||||
assert released is True
|
||||
assert "NEW ACHIEVEMENT on G: 'Win'" in why
|
||||
|
||||
def test_complete_releases_even_without_a_new_unlock(self) -> None:
|
||||
"""A 100% game releases even without a new unlock."""
|
||||
released, why = release_verdict(assigned_state(), game(("a", BEFORE), total=1))
|
||||
assert released is True
|
||||
assert "100%" in why
|
||||
|
||||
def test_no_new_achievement_keeps_the_game(self) -> None:
|
||||
"""No new achievement keeps the game and names the baseline date."""
|
||||
released, why = release_verdict(assigned_state(), game(("a", BEFORE)))
|
||||
assert released is False
|
||||
assert "No new achievement since 2026-09-20" in why
|
||||
|
||||
def test_no_baseline_is_named_generically(self) -> None:
|
||||
"""Without a baseline the reason names the assignment generically."""
|
||||
released, why = release_verdict(State(), game())
|
||||
assert released is False
|
||||
assert "since its assignment" in why
|
||||
|
||||
|
||||
class TestRecordAssignment:
|
||||
"""Stamping a fresh assignment into state."""
|
||||
|
||||
def test_stamps_current_and_history(self) -> None:
|
||||
"""Assigning stamps the current game, history and enforcement start."""
|
||||
state = State()
|
||||
record_assignment(state, 7, "Seven", at="T")
|
||||
assert (state.current_app_id, state.current_game_name) == (7, "Seven")
|
||||
@ -133,6 +156,7 @@ class TestRecordAssignment:
|
||||
assert state.enforcement_started_at == "T"
|
||||
|
||||
def test_keeps_the_first_enforcement_start(self) -> None:
|
||||
"""A later assignment keeps the first enforcement start."""
|
||||
state = State(enforcement_started_at="FIRST")
|
||||
record_assignment(state, 7, "Seven")
|
||||
assert state.enforcement_started_at == "FIRST"
|
||||
@ -140,16 +164,23 @@ class TestRecordAssignment:
|
||||
|
||||
|
||||
class TestLastAssignedEpoch:
|
||||
"""Reading back when a game was last assigned."""
|
||||
|
||||
def test_never_assigned_sorts_first(self) -> None:
|
||||
"""A never-assigned game reads as epoch zero so it sorts first."""
|
||||
assert last_assigned_epoch(State(), 1) == 0.0
|
||||
|
||||
def test_reads_the_stamp(self) -> None:
|
||||
"""A stored stamp reads back as its epoch seconds."""
|
||||
state = State(last_assigned_at={"1": ASSIGNED.isoformat()})
|
||||
assert last_assigned_epoch(state, 1) == ASSIGNED.timestamp()
|
||||
|
||||
|
||||
class TestMarkFinished:
|
||||
"""Recording a finished game."""
|
||||
|
||||
def test_records_once(self) -> None:
|
||||
"""A game is recorded as finished only once."""
|
||||
state = State()
|
||||
assert mark_finished(state, 7) is True
|
||||
assert mark_finished(state, 7) is False
|
||||
@ -157,7 +188,10 @@ class TestMarkFinished:
|
||||
|
||||
|
||||
class TestReleaseGame:
|
||||
"""Releasing the current game."""
|
||||
|
||||
def test_below_100_is_not_finished_but_cools_down(self) -> None:
|
||||
"""A partial game is released and cooled down, not finished."""
|
||||
state = assigned_state()
|
||||
state.manual_picks = [{"app_id": 1, "game_name": "G", "started_at": "P"}]
|
||||
release_game(state, game(("a", AFTER)))
|
||||
@ -168,11 +202,13 @@ class TestReleaseGame:
|
||||
assert expiry > datetime.now(UTC) + timedelta(days=RELEASE_COOLDOWN_DAYS - 1)
|
||||
|
||||
def test_complete_game_is_finished(self) -> None:
|
||||
"""A 100% game is recorded as finished on release."""
|
||||
state = assigned_state()
|
||||
release_game(state, game(("a", AFTER), total=1))
|
||||
assert state.finished_app_ids == [1]
|
||||
|
||||
def test_keeps_an_earlier_pick_release_time(self) -> None:
|
||||
"""An earlier pick release time is kept, not overwritten."""
|
||||
state = assigned_state()
|
||||
state.manual_picks = [
|
||||
{"app_id": 1, "game_name": "G", "started_at": "P", "released_at": "R"}
|
||||
|
||||
@ -8,7 +8,7 @@ from leisure_watch.procscan import CountedProcess
|
||||
|
||||
from steam_backlog_enforcer import _attribution_labels as labels_mod
|
||||
from steam_backlog_enforcer import _steam_state
|
||||
from steam_backlog_enforcer._attribution_labels import label_for, labels_for
|
||||
from steam_backlog_enforcer._attribution_labels import _app_names, label_for, labels_for
|
||||
|
||||
|
||||
def _manifest(app_id: int, name: str) -> None:
|
||||
@ -23,15 +23,18 @@ class TestLabelFor:
|
||||
"""Resolving each key shape."""
|
||||
|
||||
def setup_method(self) -> None:
|
||||
labels_mod._app_names.clear()
|
||||
"""Start each test with an empty app-name memo."""
|
||||
_app_names.clear()
|
||||
|
||||
def test_reads_a_steam_name_from_the_appmanifest(self) -> None:
|
||||
"""A Steam app key resolves to the name in its appmanifest."""
|
||||
# A running game is by definition installed, so its manifest exists —
|
||||
# which is why this never has to load the 11 MB snapshot.
|
||||
_manifest(475150, "Titan Quest Anniversary Edition")
|
||||
assert label_for("app:475150") == "Titan Quest Anniversary Edition"
|
||||
|
||||
def test_memoises_the_manifest_read(self) -> None:
|
||||
"""A resolved app name is memoised and not re-read from disk."""
|
||||
_manifest(440, "Team Fortress 2")
|
||||
assert label_for("app:440") == "Team Fortress 2"
|
||||
with patch.object(_steam_state, "STEAMAPPS_PATH", None):
|
||||
@ -39,9 +42,11 @@ class TestLabelFor:
|
||||
assert label_for("app:440") == "Team Fortress 2"
|
||||
|
||||
def test_a_missing_manifest_falls_back_to_the_key(self) -> None:
|
||||
"""An app with no manifest keeps its raw key as the label."""
|
||||
assert label_for("app:999999") == "app:999999"
|
||||
|
||||
def test_a_manifest_without_a_name_falls_back_to_the_key(self) -> None:
|
||||
"""A manifest lacking a name keeps the raw key as the label."""
|
||||
_steam_state.STEAMAPPS_PATH.mkdir(parents=True, exist_ok=True)
|
||||
(_steam_state.STEAMAPPS_PATH / "appmanifest_5.acf").write_text(
|
||||
'"AppState"\n{\n\t"appid"\t\t"5"\n}\n', encoding="utf-8"
|
||||
@ -49,9 +54,11 @@ class TestLabelFor:
|
||||
assert label_for("app:5") == "app:5"
|
||||
|
||||
def test_a_non_numeric_app_key_falls_back(self) -> None:
|
||||
"""A non-numeric app id keeps the raw key as the label."""
|
||||
assert label_for("app:nope") == "app:nope"
|
||||
|
||||
def test_a_counted_process_uses_its_configured_label(self) -> None:
|
||||
"""A counted process key uses the label from its config."""
|
||||
with patch.object(
|
||||
labels_mod,
|
||||
"load_counted_processes",
|
||||
@ -62,18 +69,24 @@ class TestLabelFor:
|
||||
assert label_for("proc:osu-lazer") == "osu!lazer"
|
||||
|
||||
def test_an_unknown_counted_process_falls_back(self) -> None:
|
||||
"""An unconfigured counted process keeps the raw key."""
|
||||
with patch.object(labels_mod, "load_counted_processes", return_value=()):
|
||||
assert label_for("proc:gone") == "proc:gone"
|
||||
|
||||
def test_a_launcher_uses_its_process_name(self) -> None:
|
||||
"""A launcher key is labelled with its process name."""
|
||||
assert label_for("launcher:lutris") == "lutris"
|
||||
|
||||
def test_an_unrecognised_key_is_returned_as_is(self) -> None:
|
||||
"""An unknown key shape is returned unchanged."""
|
||||
assert label_for("weird") == "weird"
|
||||
|
||||
|
||||
class TestLabelsFor:
|
||||
"""labels_for maps a batch of keys to labels."""
|
||||
|
||||
def test_resolves_every_key(self) -> None:
|
||||
"""Every key in the batch gets its resolved label."""
|
||||
assert labels_for(["launcher:heroic", "launcher:itch"]) == {
|
||||
"launcher:heroic": "heroic",
|
||||
"launcher:itch": "itch",
|
||||
|
||||
@ -9,6 +9,7 @@ import pytest
|
||||
|
||||
from steam_backlog_enforcer import _backups as backups
|
||||
from steam_backlog_enforcer import config
|
||||
from steam_backlog_enforcer._backups import _backup_dir, _read_backup
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -35,21 +36,29 @@ def _plant(
|
||||
|
||||
|
||||
class TestBackupDir:
|
||||
"""Backup ids are validated before they become a path."""
|
||||
|
||||
@pytest.mark.parametrize("bad", ["", "..", "../x", "20260101T000000Z", "a/b"])
|
||||
def test_refuses_anything_that_is_not_an_id(self, bad: str) -> None:
|
||||
"""Malformed or path-traversal ids are refused."""
|
||||
with pytest.raises(backups.BackupError, match="Not a backup id"):
|
||||
backups._backup_dir(bad)
|
||||
_backup_dir(bad)
|
||||
|
||||
def test_resolves_a_valid_id(self) -> None:
|
||||
assert backups._backup_dir(_OLD_ID) == backups.BACKUPS_DIR / _OLD_ID
|
||||
"""A well-formed id resolves inside the backups directory."""
|
||||
assert _backup_dir(_OLD_ID) == backups.BACKUPS_DIR / _OLD_ID
|
||||
|
||||
|
||||
class TestCreateBackup:
|
||||
"""create_backup snapshots state.json with its reason."""
|
||||
|
||||
def test_no_state_file_means_no_backup(self) -> None:
|
||||
"""Without a state file there is nothing to back up."""
|
||||
assert backups.create_backup("why") is None
|
||||
assert not backups.BACKUPS_DIR.exists()
|
||||
|
||||
def test_copies_state_and_records_why(self) -> None:
|
||||
"""The snapshot copies state.json and records why it was taken."""
|
||||
_write_state()
|
||||
backup = backups.create_backup("before reset")
|
||||
assert backup is not None
|
||||
@ -61,27 +70,37 @@ class TestCreateBackup:
|
||||
|
||||
|
||||
class TestReadBackup:
|
||||
"""_read_backup rejects incomplete or corrupt backup directories."""
|
||||
|
||||
def test_missing_meta_is_unreadable(self) -> None:
|
||||
assert backups._read_backup(_plant(_OLD_ID)) is None
|
||||
"""A backup without meta.json is not listed."""
|
||||
assert _read_backup(_plant(_OLD_ID)) is None
|
||||
|
||||
def test_corrupt_meta_is_unreadable(self) -> None:
|
||||
assert backups._read_backup(_plant(_OLD_ID, meta="{nope")) is None
|
||||
"""A backup whose meta.json is not JSON is not listed."""
|
||||
assert _read_backup(_plant(_OLD_ID, meta="{nope")) is None
|
||||
|
||||
def test_missing_state_is_unreadable(self) -> None:
|
||||
"""A backup without its state.json copy is not listed."""
|
||||
directory = _plant(_OLD_ID, state=None, meta="{}")
|
||||
assert backups._read_backup(directory) is None
|
||||
assert _read_backup(directory) is None
|
||||
|
||||
def test_absent_meta_fields_default_to_empty(self) -> None:
|
||||
found = backups._read_backup(_plant(_OLD_ID, meta="{}"))
|
||||
"""Missing created_at and reason fields default to empty strings."""
|
||||
found = _read_backup(_plant(_OLD_ID, meta="{}"))
|
||||
assert found is not None
|
||||
assert (found.created_at, found.reason) == ("", "")
|
||||
|
||||
|
||||
class TestListBackups:
|
||||
"""list_backups returns readable backups newest first."""
|
||||
|
||||
def test_no_directory_is_empty(self) -> None:
|
||||
"""No backups directory yields an empty list."""
|
||||
assert backups.list_backups() == []
|
||||
|
||||
def test_newest_first_and_skips_junk(self) -> None:
|
||||
"""Backups sort newest first and junk directories are skipped."""
|
||||
_plant(_OLD_ID, meta='{"reason": "old"}')
|
||||
_plant(_NEW_ID, meta='{"reason": "new"}')
|
||||
_plant("20260303T000000Z-cccccc") # incomplete: no meta
|
||||
@ -90,7 +109,10 @@ class TestListBackups:
|
||||
|
||||
|
||||
class TestRestoreBackup:
|
||||
"""restore_backup swaps state.json for the chosen snapshot."""
|
||||
|
||||
def test_replaces_state_and_backs_up_the_old_one(self) -> None:
|
||||
"""Restoring replaces state and snapshots the state it overwrote."""
|
||||
_plant(_OLD_ID, state='{"current_app_id": 7}', meta="{}")
|
||||
_write_state('{"current_app_id": 1}')
|
||||
previous = backups.restore_backup(_OLD_ID)
|
||||
@ -99,12 +121,14 @@ class TestRestoreBackup:
|
||||
assert previous.reason == f"before restoring {_OLD_ID}"
|
||||
|
||||
def test_restoring_over_no_state_has_no_previous(self) -> None:
|
||||
"""Restoring when no state exists leaves no previous backup."""
|
||||
_plant(_OLD_ID, state="{}", meta="{}")
|
||||
assert backups.restore_backup(_OLD_ID) is None
|
||||
assert config.STATE_FILE.read_text(encoding="utf-8") == "{}"
|
||||
|
||||
@pytest.mark.parametrize("state", [None, "{broken"])
|
||||
def test_unreadable_backup_raises_and_keeps_state(self, state: str | None) -> None:
|
||||
"""An unreadable backup raises and leaves current state intact."""
|
||||
_plant(_OLD_ID, state=state, meta="{}")
|
||||
_write_state()
|
||||
with pytest.raises(backups.BackupError, match="unreadable"):
|
||||
@ -112,5 +136,6 @@ class TestRestoreBackup:
|
||||
assert config.STATE_FILE.read_text(encoding="utf-8") == '{"current_app_id": 1}'
|
||||
|
||||
def test_malformed_id_raises(self) -> None:
|
||||
"""A malformed backup id raises BackupError."""
|
||||
with pytest.raises(backups.BackupError):
|
||||
backups.restore_backup("../../etc")
|
||||
|
||||
@ -34,6 +34,7 @@ class TestTodayGames:
|
||||
"""Today's list is uncapped: it is text, not six colours."""
|
||||
|
||||
def test_orders_largest_first_with_shares(self) -> None:
|
||||
"""Games are listed largest first with their rounded share of the day."""
|
||||
state = PlaytimeState(
|
||||
day_key="2026-08-29",
|
||||
seconds=300.0,
|
||||
@ -45,6 +46,7 @@ class TestTodayGames:
|
||||
]
|
||||
|
||||
def test_surfaces_the_unattributed_remainder_last(self) -> None:
|
||||
"""Billed time not attributed to a game is listed last as Unattributed."""
|
||||
state = PlaytimeState(
|
||||
day_key="2026-08-29", seconds=300.0, per_game={"app:1": 200.0}
|
||||
)
|
||||
@ -56,12 +58,14 @@ class TestTodayGames:
|
||||
}
|
||||
|
||||
def test_no_remainder_when_fully_attributed(self) -> None:
|
||||
"""A fully attributed day has no Unattributed row."""
|
||||
state = PlaytimeState(
|
||||
day_key="2026-08-29", seconds=100.0, per_game={"app:1": 100.0}
|
||||
)
|
||||
assert [g["key"] for g in today_games(state)] == ["app:1"]
|
||||
|
||||
def test_a_day_that_billed_nothing_lists_nothing(self) -> None:
|
||||
"""A day with no billed time lists no games."""
|
||||
assert today_games(PlaytimeState(day_key="2026-08-29")) == []
|
||||
|
||||
def test_drift_below_zero_cannot_produce_a_negative_slice(self) -> None:
|
||||
@ -83,6 +87,7 @@ class TestHistoryView:
|
||||
"""The window is capped at the six hues the shared ramp actually has."""
|
||||
|
||||
def test_orders_segments_by_the_shared_legend(self) -> None:
|
||||
"""Every bar orders its segments by the window-wide legend ranking."""
|
||||
days = [
|
||||
HistoryDay("2026-08-27", 300.0, {"app:1": 100.0, "app:2": 200.0}),
|
||||
HistoryDay("2026-08-28", 50.0, {"app:1": 50.0}),
|
||||
@ -98,6 +103,7 @@ class TestHistoryView:
|
||||
assert plotted[1]["segments"] == [{"key": "app:1", "seconds": 50.0}]
|
||||
|
||||
def test_folds_the_seventh_game_into_other(self) -> None:
|
||||
"""Games past the sixth fold into "other" without losing time."""
|
||||
games = {f"app:{i}": float(100 - i) for i in range(8)}
|
||||
plotted, legend = history_view(
|
||||
[HistoryDay("2026-08-28", sum(games.values()), games)]
|
||||
@ -114,9 +120,11 @@ class TestHistoryView:
|
||||
assert legend == [{"key": "unattributed", "label": "Unattributed"}]
|
||||
|
||||
def test_a_day_with_no_time_has_no_segments(self) -> None:
|
||||
"""A day with zero time has no segments and adds nothing to the legend."""
|
||||
plotted, legend = history_view([HistoryDay("2026-08-24", 0.0, {})])
|
||||
assert plotted[0]["segments"] == []
|
||||
assert legend == []
|
||||
|
||||
def test_empty_window(self) -> None:
|
||||
"""An empty window yields no bars and no legend."""
|
||||
assert history_view([]) == ([], [])
|
||||
|
||||
@ -32,11 +32,13 @@ class TestLastVerdict:
|
||||
"""Tests for reading the tail of the audit log."""
|
||||
|
||||
def test_missing_log_is_unavailable(self) -> None:
|
||||
"""A missing log reports no verdict."""
|
||||
view = last_verdict(demo=False)
|
||||
assert view.available is False
|
||||
assert view.state == ""
|
||||
|
||||
def test_reads_the_last_record(self) -> None:
|
||||
"""The newest record in the log is the verdict shown."""
|
||||
_write([_record(state="not_applicable"), _record(state="engaged")])
|
||||
view = last_verdict(demo=False)
|
||||
assert view.available is True
|
||||
@ -44,23 +46,28 @@ class TestLastVerdict:
|
||||
assert view.observed_at.startswith("2026-08-28")
|
||||
|
||||
def test_skips_a_malformed_last_line(self) -> None:
|
||||
"""A corrupt final line falls back to the previous record."""
|
||||
_write([_record(state="engaged"), "{ this is not json"])
|
||||
assert last_verdict(demo=False).state == "engaged"
|
||||
|
||||
def test_empty_log_is_unavailable(self) -> None:
|
||||
"""An empty log reports no verdict."""
|
||||
log_mod.BUDGET_LOG_FILE.write_text("", encoding="utf-8")
|
||||
assert last_verdict(demo=False).available is False
|
||||
|
||||
def test_a_json_scalar_is_not_a_record(self) -> None:
|
||||
"""A JSON scalar line is not treated as a record."""
|
||||
_write(["42"])
|
||||
assert last_verdict(demo=False).available is False
|
||||
|
||||
def test_unreadable_log_is_unavailable(self) -> None:
|
||||
"""A permission error reading the log reports no verdict."""
|
||||
_write([_record()])
|
||||
with patch("pathlib.Path.open", side_effect=PermissionError):
|
||||
assert last_verdict(demo=False).available is False
|
||||
|
||||
def test_reads_only_the_tail(self) -> None:
|
||||
"""Only the tail is read, and a leading fragment is skipped."""
|
||||
# Seeking back a fixed number of bytes lands mid-record; that leading
|
||||
# fragment must be skipped rather than crashing the read.
|
||||
newest = _record(state="newest")
|
||||
@ -69,11 +76,13 @@ class TestLastVerdict:
|
||||
assert last_verdict(demo=False).state == "newest"
|
||||
|
||||
def test_a_tail_shorter_than_one_record_finds_nothing(self) -> None:
|
||||
"""A tail too short to hold one record finds nothing."""
|
||||
_write([_record(state="newest")])
|
||||
with patch(f"{_PKG}._TAIL_BYTES", 10):
|
||||
assert last_verdict(demo=False).available is False
|
||||
|
||||
def test_demo_log_is_read_separately(self) -> None:
|
||||
"""Demo and production verdicts come from separate logs."""
|
||||
_write([_record(state="production")])
|
||||
log_mod.BUDGET_DEMO_LOG_FILE.write_text(
|
||||
_record(state="demo") + "\n", encoding="utf-8"
|
||||
@ -82,6 +91,7 @@ class TestLastVerdict:
|
||||
assert last_verdict(demo=False).state == "production"
|
||||
|
||||
def test_missing_and_odd_fields_default(self) -> None:
|
||||
"""A record lacking fields still reads, with empty defaults."""
|
||||
_write([json.dumps({"event": "heartbeat"})])
|
||||
view = last_verdict(demo=False)
|
||||
assert view.available is True
|
||||
@ -93,12 +103,14 @@ class TestLiveGames:
|
||||
"""Tests for resolving qualifying PIDs to live processes."""
|
||||
|
||||
def test_resolves_running_pids(self) -> None:
|
||||
"""Qualifying PIDs resolve to their live process names."""
|
||||
_write([_record(qualifying=[1, 2])])
|
||||
with patch(f"{_PKG}.process_name", side_effect=["alpha", "beta"]):
|
||||
games = last_verdict(demo=False).games
|
||||
assert [(g.pid, g.name) for g in games] == [(1, "alpha"), (2, "beta")]
|
||||
|
||||
def test_drops_pids_that_have_exited(self) -> None:
|
||||
"""PIDs whose process has exited are dropped."""
|
||||
# A recycled PID would otherwise be reported under the wrong process.
|
||||
_write([_record(qualifying=[1, 2])])
|
||||
with patch(f"{_PKG}.process_name", side_effect=["alpha", None]):
|
||||
@ -106,9 +118,11 @@ class TestLiveGames:
|
||||
assert [g.name for g in games] == ["alpha"]
|
||||
|
||||
def test_ignores_non_integer_pids(self) -> None:
|
||||
"""Non-integer PIDs are ignored."""
|
||||
_write([_record(qualifying=["nope"])])
|
||||
assert last_verdict(demo=False).games == []
|
||||
|
||||
def test_non_list_qualifying_is_dropped(self) -> None:
|
||||
"""A non-list qualifying field yields no games."""
|
||||
_write([_record(qualifying=7)])
|
||||
assert last_verdict(demo=False).games == []
|
||||
|
||||
@ -15,22 +15,23 @@ from unittest.mock import patch
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _budget_resolve, _leetcode_bonus, _workout_budget
|
||||
from steam_backlog_enforcer._budget_resolve import resolve_budget
|
||||
from steam_backlog_enforcer import _leetcode_bonus, _workout_budget
|
||||
from steam_backlog_enforcer._budget_resolve import _last_logged, resolve_budget
|
||||
from steam_backlog_enforcer.config import Config
|
||||
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today
|
||||
from steam_backlog_enforcer.tests._budget_earners import (
|
||||
HOUR,
|
||||
LEETCODE,
|
||||
WORKOUT,
|
||||
resolve_with,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
_WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
|
||||
_LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
|
||||
_LEETCODE_HTTP = "steam_backlog_enforcer._leetcode_bonus._fetch_leetcode_today"
|
||||
_READING = "steam_backlog_enforcer._budget_resolve.read_today"
|
||||
_LEDGER = "steam_backlog_enforcer._budget_resolve.ledger_answer"
|
||||
|
||||
_HOUR = 3600.0
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_caches() -> Iterator[None]:
|
||||
@ -41,11 +42,11 @@ def _clear_caches() -> Iterator[None]:
|
||||
"""
|
||||
_workout_budget.reset_cache()
|
||||
_leetcode_bonus.reset_cache()
|
||||
_budget_resolve._last_logged.clear()
|
||||
_last_logged.clear()
|
||||
yield
|
||||
_workout_budget.reset_cache()
|
||||
_leetcode_bonus.reset_cache()
|
||||
_budget_resolve._last_logged.clear()
|
||||
_last_logged.clear()
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -87,22 +88,12 @@ class TestTheEightDays:
|
||||
expected_hours: The budget those three should produce.
|
||||
"""
|
||||
workout, leetcode, reading = earners
|
||||
with (
|
||||
patch(_WORKOUT, return_value=workout),
|
||||
patch(_LEETCODE, return_value=leetcode),
|
||||
patch(_READING, return_value=reading),
|
||||
):
|
||||
resolved = resolve_budget(Config())
|
||||
assert resolved.seconds == expected_hours * _HOUR
|
||||
resolved = resolve_with(workout=workout, leetcode=leetcode, reading=reading)
|
||||
assert resolved.seconds == expected_hours * HOUR
|
||||
|
||||
def test_the_breakdown_matches_the_total(self) -> None:
|
||||
"""The parts a caller displays must add up to the number enforced."""
|
||||
with (
|
||||
patch(_WORKOUT, return_value=True),
|
||||
patch(_LEETCODE, return_value=True),
|
||||
patch(_READING, return_value=True),
|
||||
):
|
||||
resolved = resolve_budget(Config())
|
||||
resolved = resolve_with(workout=True, leetcode=True, reading=True)
|
||||
assert (
|
||||
resolved.base_seconds
|
||||
+ resolved.workout_seconds
|
||||
@ -113,19 +104,14 @@ class TestTheEightDays:
|
||||
|
||||
def test_every_earner_is_in_the_generic_breakdown(self) -> None:
|
||||
"""``earned_seconds`` carries the same terms as the named fields."""
|
||||
with (
|
||||
patch(_WORKOUT, return_value=True),
|
||||
patch(_LEETCODE, return_value=False),
|
||||
patch(_READING, return_value=True),
|
||||
):
|
||||
resolved = resolve_budget(Config())
|
||||
resolved = resolve_with(workout=True, leetcode=False, reading=True)
|
||||
# Every other registered earner has no ledger here, so earns nothing.
|
||||
others = {e.name: 0.0 for e in earned_time.EARNERS}
|
||||
assert resolved.earned_seconds == {
|
||||
**others,
|
||||
"workout": 2 * _HOUR,
|
||||
"workout": 2 * HOUR,
|
||||
"leetcode": 0.0,
|
||||
"reading": _HOUR,
|
||||
"reading": HOUR,
|
||||
}
|
||||
|
||||
|
||||
@ -135,39 +121,39 @@ class TestFailingClosed:
|
||||
def test_an_unreachable_locker_costs_only_the_workout_bonus(self) -> None:
|
||||
"""A dead screen locker must not also cost the LeetCode hour."""
|
||||
with (
|
||||
patch(_WORKOUT, side_effect=OSError("refused")),
|
||||
patch(_LEETCODE, return_value=True),
|
||||
patch(WORKOUT, side_effect=OSError("refused")),
|
||||
patch(LEETCODE, return_value=True),
|
||||
):
|
||||
resolved = resolve_budget(Config())
|
||||
assert resolved.seconds == 5.0 * _HOUR
|
||||
assert resolved.leetcode_seconds == _HOUR
|
||||
assert resolved.seconds == 5.0 * HOUR
|
||||
assert resolved.leetcode_seconds == HOUR
|
||||
|
||||
def test_an_unreadable_ledger_costs_only_the_leetcode_bonus(self) -> None:
|
||||
"""The whole point of the split: LeetCode failing leaves 6h, not 4h."""
|
||||
with (
|
||||
patch(_WORKOUT, return_value=True),
|
||||
patch(_LEETCODE, return_value=None),
|
||||
patch(WORKOUT, return_value=True),
|
||||
patch(LEETCODE, return_value=None),
|
||||
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
|
||||
):
|
||||
resolved = resolve_budget(Config())
|
||||
assert resolved.seconds == 6.0 * _HOUR
|
||||
assert resolved.workout_seconds == 2 * _HOUR
|
||||
assert resolved.seconds == 6.0 * HOUR
|
||||
assert resolved.workout_seconds == 2 * HOUR
|
||||
|
||||
def test_both_unavailable_leaves_the_bare_floor(self) -> None:
|
||||
"""Neither earner answering can never grant more than the floor."""
|
||||
with (
|
||||
patch(_WORKOUT, side_effect=OSError("refused")),
|
||||
patch(_LEETCODE, return_value=None),
|
||||
patch(WORKOUT, side_effect=OSError("refused")),
|
||||
patch(LEETCODE, return_value=None),
|
||||
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
|
||||
):
|
||||
resolved = resolve_budget(Config())
|
||||
assert resolved.seconds == 4.0 * _HOUR
|
||||
assert resolved.seconds == 4.0 * HOUR
|
||||
|
||||
def test_unknown_is_reported_differently_from_no(self) -> None:
|
||||
""" "Could not check" and "not earned" must not read the same."""
|
||||
with (
|
||||
patch(_WORKOUT, return_value=False),
|
||||
patch(_LEETCODE, return_value=None),
|
||||
patch(WORKOUT, return_value=False),
|
||||
patch(LEETCODE, return_value=None),
|
||||
patch(_LEETCODE_HTTP, side_effect=OSError("refused")),
|
||||
patch(_LEDGER, return_value=False),
|
||||
):
|
||||
@ -175,8 +161,8 @@ class TestFailingClosed:
|
||||
_leetcode_bonus.reset_cache()
|
||||
_workout_budget.reset_cache()
|
||||
with (
|
||||
patch(_WORKOUT, return_value=False),
|
||||
patch(_LEETCODE, return_value=False),
|
||||
patch(WORKOUT, return_value=False),
|
||||
patch(LEETCODE, return_value=False),
|
||||
patch(_LEDGER, return_value=False),
|
||||
):
|
||||
answered = resolve_budget(Config()).reason
|
||||
@ -196,8 +182,8 @@ class TestTheExplanation:
|
||||
"""
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
patch(_WORKOUT, return_value=False),
|
||||
patch(_LEETCODE, return_value=False),
|
||||
patch(WORKOUT, return_value=False),
|
||||
patch(LEETCODE, return_value=False),
|
||||
):
|
||||
resolve_budget(Config())
|
||||
assert "4.0h" in caplog.text
|
||||
@ -215,8 +201,8 @@ class TestTheExplanation:
|
||||
"""
|
||||
with (
|
||||
caplog.at_level(logging.INFO),
|
||||
patch(_WORKOUT, return_value=False),
|
||||
patch(_LEETCODE, return_value=False),
|
||||
patch(WORKOUT, return_value=False),
|
||||
patch(LEETCODE, return_value=False),
|
||||
):
|
||||
resolve_budget(Config())
|
||||
resolve_budget(Config())
|
||||
|
||||
@ -44,8 +44,8 @@ def _resolve(hour: int) -> tuple[float, list[object]]:
|
||||
return base, [c.kwargs["leisure"] for c in spy.call_args_list]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dormant(tmp_path: Path) -> float:
|
||||
@pytest.fixture(name="dormant")
|
||||
def fixture_dormant(tmp_path: Path) -> float:
|
||||
"""The dormant base at noon, then the switch dated the go-live day."""
|
||||
base = _resolve(12)[0]
|
||||
switch_file(tmp_path).write_text(AFTER_CUT.isoformat() + "\n", encoding="ascii")
|
||||
@ -53,11 +53,13 @@ def dormant(tmp_path: Path) -> float:
|
||||
|
||||
|
||||
def test_dormant_prices_pre_leisure_with_a_leisure_shadow() -> None:
|
||||
"""With no switch the live price is pre-leisure and the shadow is leisure."""
|
||||
assert _resolve(12)[1] == [False, True]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("hour", [6, 12, 23])
|
||||
def test_the_go_live_gaming_day_is_priced_as_leisure(dormant: float, hour: int) -> None:
|
||||
"""From 06:00 on the go-live day the base is priced as leisure."""
|
||||
base, leisure = _resolve(hour)
|
||||
assert leisure == [True, True]
|
||||
assert base - dormant == 2 * _HOUR
|
||||
@ -67,12 +69,14 @@ def test_the_go_live_gaming_day_is_priced_as_leisure(dormant: float, hour: int)
|
||||
def test_before_06_on_the_go_live_date_stays_pre_leisure(
|
||||
dormant: float, hour: int
|
||||
) -> None:
|
||||
"""Before 06:00 on the go-live date the old gaming day stays pre-leisure."""
|
||||
base, leisure = _resolve(hour)
|
||||
assert leisure == [False, True]
|
||||
assert base == dormant
|
||||
|
||||
|
||||
def test_a_switch_dated_tomorrow_is_dormant_today(tmp_path: Path) -> None:
|
||||
"""A switch dated tomorrow does not apply today."""
|
||||
tomorrow = (AFTER_CUT + timedelta(days=1)).isoformat()
|
||||
switch_file(tmp_path).write_text(tomorrow + "\n", encoding="ascii")
|
||||
assert _resolve(12)[1] == [False, True]
|
||||
|
||||
@ -18,7 +18,6 @@ from steam_backlog_enforcer import _budget_view as view
|
||||
from steam_backlog_enforcer import _leetcode_bonus, _workout_budget
|
||||
from steam_backlog_enforcer._budget_resolve import (
|
||||
BudgetResolution,
|
||||
resolve_budget,
|
||||
)
|
||||
from steam_backlog_enforcer._ledger_earners import registry_for
|
||||
from steam_backlog_enforcer._playtime_state import rules_for
|
||||
@ -28,17 +27,12 @@ from steam_backlog_enforcer.tests._budget_dates import (
|
||||
BEFORE_CUT,
|
||||
pin_today,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._budget_earners import HOUR, resolve_with
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
|
||||
_WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
|
||||
_LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
|
||||
_READING = "steam_backlog_enforcer._budget_resolve.read_today"
|
||||
|
||||
_HOUR = 3600.0
|
||||
|
||||
|
||||
def _later_penalised(day: date) -> list[earned_time.Earner]:
|
||||
"""Earners in force on ``day`` whose penalty starts after reading's cut.
|
||||
@ -81,12 +75,7 @@ def _resolve(*, earners: tuple[bool, bool, bool | None]) -> BudgetResolution:
|
||||
The resolved budget.
|
||||
"""
|
||||
workout, leetcode, reading = earners
|
||||
with (
|
||||
patch(_WORKOUT, return_value=workout),
|
||||
patch(_LEETCODE, return_value=leetcode),
|
||||
patch(_READING, return_value=reading),
|
||||
):
|
||||
return resolve_budget(Config())
|
||||
return resolve_with(workout=workout, leetcode=leetcode, reading=reading)
|
||||
|
||||
|
||||
class TestTheBase:
|
||||
@ -96,13 +85,13 @@ class TestTheBase:
|
||||
"""The day before still gets the hour reading cannot yet earn back."""
|
||||
with pin_today(BEFORE_CUT):
|
||||
resolved = _resolve(earners=(False, False, False))
|
||||
assert resolved.base_seconds == 5 * _HOUR
|
||||
assert resolved.base_seconds == 5 * HOUR
|
||||
|
||||
def test_the_cut_day_itself_uses_the_lower_floor(self) -> None:
|
||||
"""The boundary is inclusive: the penalty start is the first 4h day."""
|
||||
with pin_today(AFTER_CUT):
|
||||
resolved = _resolve(earners=(False, False, False))
|
||||
assert resolved.base_seconds == 4 * _HOUR
|
||||
assert resolved.base_seconds == 4 * HOUR
|
||||
|
||||
def test_every_later_day_uses_the_lower_floor(self) -> None:
|
||||
"""No drift back to 5h once the cut has happened.
|
||||
@ -116,25 +105,27 @@ class TestTheBase:
|
||||
resolved = _resolve(earners=(False, False, False))
|
||||
later_cuts = 60 * sum(e.max_gaming_minutes for e in _later_penalised(day))
|
||||
# From earned_time 0.14 the floor itself is dated (LEISURE_FROM lifts it).
|
||||
lift = 60 * earned_time.gaming_base_minutes_for(day) - 5 * _HOUR
|
||||
assert resolved.base_seconds == 4 * _HOUR + lift - later_cuts
|
||||
lift = 60 * earned_time.gaming_base_minutes_for(day) - 5 * HOUR
|
||||
assert resolved.base_seconds == 4 * HOUR + lift - later_cuts
|
||||
|
||||
|
||||
class TestTheReadingTerm:
|
||||
"""Reading adds one hour, and an unknown answer adds nothing."""
|
||||
|
||||
def test_credited_reading_adds_the_hour(self) -> None:
|
||||
"""Credited reading adds exactly one hour and says so."""
|
||||
with pin_today(AFTER_CUT):
|
||||
resolved = _resolve(earners=(False, False, True))
|
||||
assert resolved.reading_seconds == _HOUR
|
||||
assert resolved.seconds == 5 * _HOUR
|
||||
assert resolved.reading_seconds == HOUR
|
||||
assert resolved.seconds == 5 * HOUR
|
||||
assert "reading credited" in resolved.reason
|
||||
|
||||
def test_no_reading_adds_nothing(self) -> None:
|
||||
"""No reading leaves the budget at the base."""
|
||||
with pin_today(AFTER_CUT):
|
||||
resolved = _resolve(earners=(False, False, False))
|
||||
assert resolved.reading_seconds == 0.0
|
||||
assert resolved.seconds == 4 * _HOUR
|
||||
assert resolved.seconds == 4 * HOUR
|
||||
assert "no reading credited" in resolved.reason
|
||||
|
||||
def test_an_unreadable_ledger_costs_only_the_reading_hour(self) -> None:
|
||||
@ -142,9 +133,9 @@ class TestTheReadingTerm:
|
||||
with pin_today(AFTER_CUT):
|
||||
resolved = _resolve(earners=(True, True, None))
|
||||
assert resolved.reading_seconds == 0.0
|
||||
assert resolved.workout_seconds == 2 * _HOUR
|
||||
assert resolved.leetcode_seconds == _HOUR
|
||||
assert resolved.seconds == 7 * _HOUR
|
||||
assert resolved.workout_seconds == 2 * HOUR
|
||||
assert resolved.leetcode_seconds == HOUR
|
||||
assert resolved.seconds == 7 * HOUR
|
||||
assert "reading unknown (book-guard ledger unreadable)" in resolved.reason
|
||||
|
||||
|
||||
@ -155,14 +146,15 @@ class TestTheCeiling:
|
||||
"""5h + 2h + 1h + 1h is 9h; 8h is what gets enforced."""
|
||||
with pin_today(BEFORE_CUT):
|
||||
resolved = _resolve(earners=(True, True, True))
|
||||
assert resolved.base_seconds == 5 * _HOUR
|
||||
assert resolved.seconds == 8 * _HOUR
|
||||
assert resolved.base_seconds == 5 * HOUR
|
||||
assert resolved.seconds == 8 * HOUR
|
||||
assert resolved.reason.startswith("8.0h: ")
|
||||
|
||||
def test_before_the_cut_an_idle_day_keeps_five_hours(self) -> None:
|
||||
"""An idle day before the cut keeps the 5h floor."""
|
||||
with pin_today(BEFORE_CUT):
|
||||
resolved = _resolve(earners=(False, False, False))
|
||||
assert resolved.seconds == 5 * _HOUR
|
||||
assert resolved.seconds == 5 * HOUR
|
||||
|
||||
def test_a_lower_registry_ceiling_binds(self) -> None:
|
||||
"""The ceiling is the registry's, read when the budget resolves."""
|
||||
@ -172,41 +164,45 @@ class TestTheCeiling:
|
||||
patch("earned_time._leisure.GAMING_CEILING_MINUTES", 6 * 60),
|
||||
):
|
||||
resolved = _resolve(earners=(True, True, True))
|
||||
assert resolved.seconds == 6 * _HOUR
|
||||
assert resolved.seconds == 6 * HOUR
|
||||
|
||||
def test_after_the_cut_a_full_day_is_exactly_the_ceiling(self) -> None:
|
||||
"""After the cut every earner together lands on the 8h ceiling."""
|
||||
with pin_today(AFTER_CUT):
|
||||
resolved = _resolve(earners=(True, True, True))
|
||||
ceiling = earned_time.GAMING_CEILING_MINUTES * 60
|
||||
assert resolved.seconds == ceiling == 8 * _HOUR
|
||||
assert resolved.seconds == ceiling == 8 * HOUR
|
||||
|
||||
|
||||
class TestReadingReachesTheRulesAndTheView:
|
||||
"""The resolved reading hour is carried, never re-resolved."""
|
||||
|
||||
def test_rules_and_bonuses_carry_the_reading_seconds(self) -> None:
|
||||
"""The reading hour flows into the rules and the view's bonuses."""
|
||||
resolution = BudgetResolution(
|
||||
seconds=5 * _HOUR,
|
||||
base_seconds=4 * _HOUR,
|
||||
seconds=5 * HOUR,
|
||||
base_seconds=4 * HOUR,
|
||||
workout_seconds=0.0,
|
||||
leetcode_seconds=0.0,
|
||||
reason="stub",
|
||||
reading_seconds=_HOUR,
|
||||
reading_seconds=HOUR,
|
||||
)
|
||||
with patch(
|
||||
"steam_backlog_enforcer._playtime_state.resolve_budget",
|
||||
return_value=resolution,
|
||||
):
|
||||
rules = rules_for(Config(), demo=False)
|
||||
assert rules.reading_seconds == _HOUR
|
||||
assert rules.reading_seconds == HOUR
|
||||
with patch.object(view, "mounted_targets", return_value=set()):
|
||||
bonuses = view.build_rules(rules)["bonuses"]
|
||||
assert bonuses["reading"] == _HOUR
|
||||
assert bonuses["reading"] == HOUR
|
||||
|
||||
def test_a_demo_run_earns_no_reading(self) -> None:
|
||||
"""A demo run never earns the reading hour."""
|
||||
assert rules_for(Config(), demo=True).reading_seconds == 0.0
|
||||
|
||||
def test_a_resolution_defaults_to_no_reading(self) -> None:
|
||||
"""A resolution built without reading defaults to zero."""
|
||||
resolution = BudgetResolution(
|
||||
seconds=0.0,
|
||||
base_seconds=0.0,
|
||||
|
||||
@ -48,13 +48,16 @@ class TestStateAccess:
|
||||
"""Tests for telling apart the reasons state fails to load."""
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""An absent state file reads as missing."""
|
||||
assert state_access(state_mod.PLAYTIME_STATE_FILE) == MISSING
|
||||
|
||||
def test_readable_file(self) -> None:
|
||||
"""A present, readable state file reads as readable."""
|
||||
state_mod.PLAYTIME_STATE_FILE.write_text("{}", encoding="utf-8")
|
||||
assert state_access(state_mod.PLAYTIME_STATE_FILE) == READABLE
|
||||
|
||||
def test_unreadable_file(self) -> None:
|
||||
"""A permission error reads as denied."""
|
||||
state_mod.PLAYTIME_STATE_FILE.write_text("{}", encoding="utf-8")
|
||||
with patch("pathlib.Path.read_bytes", side_effect=PermissionError):
|
||||
assert state_access(state_mod.PLAYTIME_STATE_FILE) == DENIED
|
||||
@ -64,18 +67,22 @@ class TestNextWarning:
|
||||
"""Tests for the forward-looking warning threshold."""
|
||||
|
||||
def test_largest_threshold_below_remaining(self) -> None:
|
||||
"""The next warning is the largest threshold still ahead."""
|
||||
state = PlaytimeState(day_key="d", seconds=0.0)
|
||||
assert next_warning(state, RULES) == 3600
|
||||
|
||||
def test_skips_thresholds_already_fired(self) -> None:
|
||||
"""Thresholds that already fired are skipped."""
|
||||
state = PlaytimeState(day_key="d", seconds=0.0, warned_seconds=[3600])
|
||||
assert next_warning(state, RULES) == 1800
|
||||
|
||||
def test_none_once_every_threshold_is_passed(self) -> None:
|
||||
"""A spent budget has no next warning."""
|
||||
state = PlaytimeState(day_key="d", seconds=RULES.budget_seconds)
|
||||
assert next_warning(state, RULES) is None
|
||||
|
||||
def test_is_not_the_already_crossed_threshold(self) -> None:
|
||||
"""The next warning is the one ahead, not the one just crossed."""
|
||||
# pending_warning() would return 3600 here; the display wants the next
|
||||
# one the countdown is heading towards.
|
||||
state = PlaytimeState(day_key="d", seconds=RULES.budget_seconds - 3000)
|
||||
@ -86,6 +93,7 @@ class TestBuildToday:
|
||||
"""Tests for the today block."""
|
||||
|
||||
def test_reports_usage_and_remaining(self) -> None:
|
||||
"""Today's block reports time used and time left."""
|
||||
today = build_today(PlaytimeState(day_key="2026-08-28", seconds=100.0), RULES)
|
||||
assert today["seconds_used"] == 100.0
|
||||
assert today["seconds_remaining"] == RULES.budget_seconds - 100.0
|
||||
@ -93,11 +101,13 @@ class TestBuildToday:
|
||||
assert today["fraction_used"] < 0.01
|
||||
|
||||
def test_never_reports_negative_remaining(self) -> None:
|
||||
"""Overspending clamps remaining time at zero."""
|
||||
today = build_today(PlaytimeState(day_key="d", seconds=10**9), RULES)
|
||||
assert today["seconds_remaining"] == 0.0
|
||||
assert today["fraction_used"] == 1.0
|
||||
|
||||
def test_reports_the_cutoff(self) -> None:
|
||||
"""A blocked day reports when it was cut off."""
|
||||
today = build_today(
|
||||
PlaytimeState(day_key="d", seconds=1.0, blocked_at=2.0), RULES
|
||||
)
|
||||
@ -105,6 +115,7 @@ class TestBuildToday:
|
||||
assert today["blocked_at"] == 2.0
|
||||
|
||||
def test_a_zero_budget_reads_as_fully_spent(self) -> None:
|
||||
"""A zero budget reads as fully spent."""
|
||||
with fixed_budget(0.0):
|
||||
rules = rules_for(Config(), demo=False)
|
||||
today = build_today(PlaytimeState(day_key="d", seconds=0.0), rules)
|
||||
@ -115,6 +126,7 @@ class TestBuildRules:
|
||||
"""Tests for the rules block."""
|
||||
|
||||
def test_lists_the_policy(self) -> None:
|
||||
"""The rules block lists budget, warnings and masked launchers."""
|
||||
with patch.object(view, "mounted_targets", return_value=set()):
|
||||
rules = build_rules(RULES)
|
||||
assert rules["budget_seconds"] == 8 * 3600
|
||||
@ -122,6 +134,7 @@ class TestBuildRules:
|
||||
assert rules["masked_launchers"] == []
|
||||
|
||||
def test_lists_masked_launchers(self) -> None:
|
||||
"""Mounted launcher masks are listed."""
|
||||
with patch.object(view, "mounted_targets", return_value={"/usr/bin/steam"}):
|
||||
assert build_rules(RULES)["masked_launchers"] == ["/usr/bin/steam"]
|
||||
|
||||
@ -145,6 +158,7 @@ class TestBuildBudgetSnapshot:
|
||||
return build_budget_snapshot(demo=bool(kwargs.get("demo", False)))
|
||||
|
||||
def test_reports_a_readable_day(self) -> None:
|
||||
"""A readable state yields a full today block and no error."""
|
||||
snapshot = self._snapshot()
|
||||
assert snapshot["readable"] is True
|
||||
assert snapshot["state_status"] == READABLE
|
||||
@ -152,6 +166,7 @@ class TestBuildBudgetSnapshot:
|
||||
assert snapshot["today"]["gaming_day"] == "2026-08-28"
|
||||
|
||||
def test_reports_denied_state(self) -> None:
|
||||
"""Denied state reports an error but keeps the rules."""
|
||||
snapshot = self._snapshot(stored=None, access=DENIED)
|
||||
assert snapshot["readable"] is False
|
||||
assert snapshot["state_status"] == DENIED
|
||||
@ -161,16 +176,19 @@ class TestBuildBudgetSnapshot:
|
||||
assert snapshot["rules"]["budget_seconds"] == 8 * 3600
|
||||
|
||||
def test_reports_missing_state(self) -> None:
|
||||
"""Missing state reports that no time is recorded."""
|
||||
snapshot = self._snapshot(stored=None, access=MISSING)
|
||||
assert snapshot["state_status"] == MISSING
|
||||
assert "No gaming time" in snapshot["error"]
|
||||
|
||||
def test_a_file_that_opens_but_will_not_parse_is_corrupt(self) -> None:
|
||||
"""A readable file that fails to parse is reported as corrupt."""
|
||||
snapshot = self._snapshot(stored=None, access=READABLE)
|
||||
assert snapshot["state_status"] == CORRUPT
|
||||
assert "tampering" in snapshot["error"]
|
||||
|
||||
def test_includes_the_live_session(self) -> None:
|
||||
"""The live session block names the game and its processes."""
|
||||
session = SessionView(
|
||||
observed_at="2026-08-28T20:42:19+02:00",
|
||||
state="engaged",
|
||||
@ -187,6 +205,7 @@ class TestBuildBudgetSnapshot:
|
||||
assert block["processes"] == [{"pid": 7, "name": "hollow_knight"}]
|
||||
|
||||
def test_includes_recorded_history(self) -> None:
|
||||
"""Recorded history is served with an unattributed residual."""
|
||||
with patch.object(
|
||||
view,
|
||||
"load_history",
|
||||
|
||||
@ -16,6 +16,7 @@ if TYPE_CHECKING:
|
||||
|
||||
|
||||
def test_a_live_host_switch_does_not_leak_into_rules(tmp_path: Path) -> None:
|
||||
"""A host switch file read at import never changes the rules."""
|
||||
with (
|
||||
patch("steam_backlog_enforcer._playtime_state.held_today", return_value=0.0),
|
||||
patch(
|
||||
|
||||
20
steam_backlog_enforcer/tests/test_cmd_done_hltb.py
Normal file
20
steam_backlog_enforcer/tests/test_cmd_done_hltb.py
Normal file
@ -0,0 +1,20 @@
|
||||
"""Tests for ``cmd_done``: fetch, the completion verdict and the HLTB estimate.
|
||||
|
||||
Split out of ``test_main_part2b.py`` to keep both files inside the 250-line cap.
|
||||
"""
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._cmd_done_finalize import cmd_done
|
||||
from steam_backlog_enforcer.tests._cmd_done_cases import CmdDoneCases
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
|
||||
class TestCmdDoneVerdict(CmdDoneCases):
|
||||
"""Tests for cmd_done imported from its defining module."""
|
||||
|
||||
def run_done(self, config: Config, state: State) -> None:
|
||||
"""Call cmd_done from ``_cmd_done_finalize``."""
|
||||
cmd_done(config, state)
|
||||
@ -2,26 +2,23 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._cmd_done import _prompt_keep_or_skip
|
||||
from steam_backlog_enforcer._prompter import use_prompter
|
||||
from steam_backlog_enforcer.config import State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
from steam_backlog_enforcer.tests._fake_prompter import FakePrompter
|
||||
from steam_backlog_enforcer.tests._half_done_game import half_done_game
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
|
||||
|
||||
def _game(hours: float = 5.0, app_id: int = 42, name: str = "Test") -> GameInfo:
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
completionist_hours=hours,
|
||||
)
|
||||
return half_done_game(app_id, name, hours)
|
||||
|
||||
|
||||
def _ask(prompter: FakePrompter, game: GameInfo | None = None) -> bool | GameInfo:
|
||||
@ -34,33 +31,40 @@ class TestPromptKeepOrSkip:
|
||||
"""Tests for _prompt_keep_or_skip."""
|
||||
|
||||
def test_non_interactive_accepts_silently(self) -> None:
|
||||
"""Without a terminal the pick is accepted and nothing is asked."""
|
||||
prompter = FakePrompter(interactive=False)
|
||||
assert _ask(prompter) is True
|
||||
assert prompter.asked == []
|
||||
|
||||
def test_keep_accepts(self) -> None:
|
||||
"""Answering keep accepts the pick."""
|
||||
assert _ask(FakePrompter(choices=["keep"])) is True
|
||||
|
||||
def test_skip_rejects(self) -> None:
|
||||
"""Answering skip rejects the pick."""
|
||||
assert _ask(FakePrompter(choices=["skip"])) is False
|
||||
|
||||
def test_offers_keep_skip_and_own_pick(self) -> None:
|
||||
"""The prompt offers keep, skip and own-pick."""
|
||||
prompter = FakePrompter(choices=["keep"])
|
||||
_ask(prompter)
|
||||
assert prompter.asked == [(" Keep this game?", ["keep", "skip", "own"])]
|
||||
|
||||
def test_own_pick_returns_the_chosen_game(self) -> None:
|
||||
"""Own-pick returns the game the user chose."""
|
||||
mine = _game(app_id=7, name="Mine")
|
||||
with patch(f"{CMD_DONE_PKG}.prompt_own_pick", return_value=mine):
|
||||
assert _ask(FakePrompter(choices=["own"])) is mine
|
||||
|
||||
def test_going_back_from_own_pick_asks_again(self) -> None:
|
||||
"""Backing out of own-pick asks the question again."""
|
||||
prompter = FakePrompter(choices=["own", "skip"])
|
||||
with patch(f"{CMD_DONE_PKG}.prompt_own_pick", return_value=None):
|
||||
assert _ask(prompter) is False
|
||||
assert len(prompter.asked) == 2
|
||||
|
||||
def test_hours_are_shown_when_known(self) -> None:
|
||||
"""Known completion hours appear in the prompt."""
|
||||
with (
|
||||
use_prompter(FakePrompter(choices=["keep"])),
|
||||
patch(f"{CMD_DONE_PKG}._echo") as echo,
|
||||
@ -69,6 +73,7 @@ class TestPromptKeepOrSkip:
|
||||
assert "(~5.0h leisure+dlc)" in echo.call_args_list[0].args[0]
|
||||
|
||||
def test_zero_hours_omits_hours_string(self) -> None:
|
||||
"""Unknown (zero) hours are left out of the prompt."""
|
||||
with (
|
||||
use_prompter(FakePrompter(choices=["keep"])),
|
||||
patch(f"{CMD_DONE_PKG}._echo") as echo,
|
||||
|
||||
68
steam_backlog_enforcer/tests/test_cmd_gaming_unblock.py
Normal file
68
steam_backlog_enforcer/tests/test_cmd_gaming_unblock.py
Normal file
@ -0,0 +1,68 @@
|
||||
"""Tests for ``gaming-unblock``, the root-only recovery hatch for the budget.
|
||||
|
||||
Split out of ``test_cmd_playtime.py`` to keep both files inside the 250-line cap.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._cmd_playtime import cmd_gaming_unblock
|
||||
from steam_backlog_enforcer.tests._cmd_playtime_patches import cli_as, echoed
|
||||
|
||||
PKG = "steam_backlog_enforcer._cmd_playtime"
|
||||
|
||||
|
||||
class TestCmdGamingUnblock:
|
||||
"""gaming-unblock releases the mounts as root and nothing else."""
|
||||
|
||||
def test_rejects_arguments(self) -> None:
|
||||
"""Any argument is rejected before anything is released."""
|
||||
with cli_as(None) as mocks:
|
||||
assert cmd_gaming_unblock(["--force"]) == 2
|
||||
mocks.release.assert_not_called()
|
||||
assert "Unknown argument" in echoed(mocks.echo)
|
||||
|
||||
def test_requires_root(self) -> None:
|
||||
"""Without root nothing is released."""
|
||||
with cli_as(1000) as mocks:
|
||||
assert cmd_gaming_unblock([]) == 1
|
||||
mocks.release.assert_not_called()
|
||||
assert "requires root" in echoed(mocks.echo)
|
||||
|
||||
def test_reports_when_nothing_was_mounted(self) -> None:
|
||||
"""With nothing mounted it says so and succeeds."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.release_block", return_value=[]),
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 0
|
||||
assert "No playtime mounts" in echoed(mock_echo)
|
||||
|
||||
def test_lists_released_mounts(self) -> None:
|
||||
"""Every released mount is listed."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(
|
||||
f"{PKG}.release_block",
|
||||
return_value=["/usr/bin/steam", "/usr/bin/lutris"],
|
||||
),
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 0
|
||||
output = echoed(mock_echo)
|
||||
assert "/usr/bin/steam" in output
|
||||
assert "/usr/bin/lutris" in output
|
||||
|
||||
def test_consults_no_state_at_all(self) -> None:
|
||||
"""The recovery hatch must work with an unreadable state file."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(
|
||||
f"{PKG}.load_state", side_effect=AssertionError("must not be called")
|
||||
),
|
||||
patch(f"{PKG}.release_block", return_value=[]),
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 0
|
||||
@ -8,7 +8,6 @@ from steam_backlog_enforcer._cmd_playtime import (
|
||||
cmd_enforce,
|
||||
cmd_gaming_reset,
|
||||
cmd_gaming_status,
|
||||
cmd_gaming_unblock,
|
||||
)
|
||||
from steam_backlog_enforcer._playtime_state import (
|
||||
PlaytimeState,
|
||||
@ -16,6 +15,7 @@ from steam_backlog_enforcer._playtime_state import (
|
||||
save_state,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.tests._cmd_playtime_patches import cli_as, echoed
|
||||
from steam_backlog_enforcer.tests._no_workout_http import fixed_budget
|
||||
|
||||
RESET_PKG = "steam_backlog_enforcer._gaming_reset"
|
||||
@ -23,12 +23,11 @@ _RESET_PHRASE = "reset today's gaming budget"
|
||||
PKG = "steam_backlog_enforcer._cmd_playtime"
|
||||
|
||||
|
||||
def _echoed(mock_echo: object) -> str:
|
||||
return " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdEnforce:
|
||||
"""``enforce`` runs the real loop or, with --demo, the budget-only demo."""
|
||||
|
||||
def test_runs_in_production_by_default(self) -> None:
|
||||
"""Without flags the production enforce loop runs."""
|
||||
with (
|
||||
patch(f"{PKG}.do_enforce") as mock_run,
|
||||
patch(f"{PKG}.run_demo") as mock_demo,
|
||||
@ -39,6 +38,7 @@ class TestCmdEnforce:
|
||||
mock_demo.assert_not_called()
|
||||
|
||||
def test_demo_flag_runs_the_budget_only_demo(self) -> None:
|
||||
"""--demo runs the demo instead of the production loop."""
|
||||
with (
|
||||
patch(f"{PKG}.do_enforce") as mock_run,
|
||||
patch(f"{PKG}.run_demo", return_value=0) as mock_demo,
|
||||
@ -48,6 +48,7 @@ class TestCmdEnforce:
|
||||
mock_run.assert_not_called()
|
||||
|
||||
def test_demo_exit_code_is_passed_on(self) -> None:
|
||||
"""The demo's exit code is returned unchanged."""
|
||||
with patch(f"{PKG}.run_demo", return_value=1):
|
||||
assert cmd_enforce(Config(), State(), ["--demo"]) == 1
|
||||
|
||||
@ -56,24 +57,29 @@ class TestCmdEnforce:
|
||||
with patch(f"{PKG}.do_enforce") as mock_run, patch(f"{PKG}._echo") as mock_echo:
|
||||
assert cmd_enforce(Config(), State(), ["--Demo"]) == 2
|
||||
mock_run.assert_not_called()
|
||||
assert "Unknown argument" in _echoed(mock_echo)
|
||||
assert "Unknown argument" in echoed(mock_echo)
|
||||
|
||||
def test_unknown_flag_alongside_demo_still_rejected(self) -> None:
|
||||
"""An unknown flag is rejected even next to --demo."""
|
||||
with patch(f"{PKG}.do_enforce") as mock_run, patch(f"{PKG}._echo"):
|
||||
assert cmd_enforce(Config(), State(), ["--demo", "--bogus"]) == 2
|
||||
mock_run.assert_not_called()
|
||||
|
||||
|
||||
class TestCmdGamingStatus:
|
||||
"""gaming-status reports today's budget, demo budget and masks."""
|
||||
|
||||
def test_reports_no_state_yet(self) -> None:
|
||||
"""With no saved state the status says so."""
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.mounted_targets", return_value=set()),
|
||||
):
|
||||
cmd_gaming_status(Config(), State())
|
||||
assert "no state recorded yet" in _echoed(mock_echo)
|
||||
assert "no state recorded yet" in echoed(mock_echo)
|
||||
|
||||
def test_reports_usage_and_remaining(self) -> None:
|
||||
"""Usage and remaining budget are both reported."""
|
||||
save_state(PlaytimeState(day_key="2026-07-27", seconds=100.0), demo=False)
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
@ -81,39 +87,43 @@ class TestCmdGamingStatus:
|
||||
fixed_budget(500.0),
|
||||
):
|
||||
cmd_gaming_status(Config(), State())
|
||||
output = _echoed(mock_echo)
|
||||
output = echoed(mock_echo)
|
||||
assert "used:" in output
|
||||
assert "100s" in output
|
||||
assert "400s" in output
|
||||
|
||||
def test_hides_the_demo_section_when_unused(self) -> None:
|
||||
"""The demo section is hidden when the demo was never used."""
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.mounted_targets", return_value=set()),
|
||||
):
|
||||
cmd_gaming_status(Config(), State())
|
||||
assert "Demo budget" not in _echoed(mock_echo)
|
||||
assert "Demo budget" not in echoed(mock_echo)
|
||||
|
||||
def test_shows_the_demo_section_when_present(self) -> None:
|
||||
"""The demo section appears once demo state exists."""
|
||||
save_state(PlaytimeState(day_key="2026-07-27", seconds=5.0), demo=True)
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.mounted_targets", return_value=set()),
|
||||
):
|
||||
cmd_gaming_status(Config(), State())
|
||||
assert "Demo budget" in _echoed(mock_echo)
|
||||
assert "Demo budget" in echoed(mock_echo)
|
||||
|
||||
def test_lists_masked_launchers(self) -> None:
|
||||
"""Masked launchers are counted and listed."""
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.mounted_targets", return_value={"/usr/bin/steam"}),
|
||||
):
|
||||
cmd_gaming_status(Config(), State())
|
||||
output = _echoed(mock_echo)
|
||||
output = echoed(mock_echo)
|
||||
assert "1/4" in output
|
||||
assert "/usr/bin/steam" in output
|
||||
|
||||
def test_reports_blocked_flag(self) -> None:
|
||||
"""A blocked day reports the blocked flag."""
|
||||
save_state(
|
||||
PlaytimeState(day_key="2026-07-27", seconds=1.0, blocked_at=5.0),
|
||||
demo=False,
|
||||
@ -123,32 +133,28 @@ class TestCmdGamingStatus:
|
||||
patch(f"{PKG}.mounted_targets", return_value=set()),
|
||||
):
|
||||
cmd_gaming_status(Config(), State())
|
||||
assert "blocked: True" in _echoed(mock_echo)
|
||||
assert "blocked: True" in echoed(mock_echo)
|
||||
|
||||
|
||||
class TestCmdGamingReset:
|
||||
"""gaming-reset clears today's counter only for root with the phrase."""
|
||||
|
||||
def test_requires_root(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.release_block") as mock_release,
|
||||
):
|
||||
"""Without root nothing is reset."""
|
||||
with cli_as(1000) as mocks:
|
||||
assert cmd_gaming_reset(Config(), State()) == 1
|
||||
mock_release.assert_not_called()
|
||||
assert "requires root" in _echoed(mock_echo)
|
||||
mocks.release.assert_not_called()
|
||||
assert "requires root" in echoed(mocks.echo)
|
||||
|
||||
def test_aborts_without_the_typed_confirmation(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="yes"),
|
||||
patch(f"{PKG}.release_block") as mock_release,
|
||||
):
|
||||
"""Anything but the exact phrase aborts the reset."""
|
||||
with cli_as(0) as mocks, patch("builtins.input", return_value="yes"):
|
||||
assert cmd_gaming_reset(Config(), State()) == 1
|
||||
mock_release.assert_not_called()
|
||||
assert "Aborted" in _echoed(mock_echo)
|
||||
mocks.release.assert_not_called()
|
||||
assert "Aborted" in echoed(mocks.echo)
|
||||
|
||||
def test_resets_counter_and_releases_mounts(self) -> None:
|
||||
"""The reset zeroes the counter and clears the block."""
|
||||
save_state(
|
||||
PlaytimeState(day_key="2026-07-26", seconds=999.0, blocked_at=5.0),
|
||||
demo=False,
|
||||
@ -165,6 +171,7 @@ class TestCmdGamingReset:
|
||||
assert stored.is_blocked() is False
|
||||
|
||||
def test_reports_how_many_mounts_were_released(self) -> None:
|
||||
"""The reset reports how many mounts it released."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
@ -172,60 +179,4 @@ class TestCmdGamingReset:
|
||||
patch(f"{RESET_PKG}.release_block", return_value=["/a", "/b"]),
|
||||
):
|
||||
cmd_gaming_reset(Config(), State())
|
||||
assert "Released 2 mount(s)" in _echoed(mock_echo)
|
||||
|
||||
|
||||
class TestCmdGamingUnblock:
|
||||
def test_rejects_arguments(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.release_block") as mock_release,
|
||||
):
|
||||
assert cmd_gaming_unblock(["--force"]) == 2
|
||||
mock_release.assert_not_called()
|
||||
assert "Unknown argument" in _echoed(mock_echo)
|
||||
|
||||
def test_requires_root(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.release_block") as mock_release,
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 1
|
||||
mock_release.assert_not_called()
|
||||
assert "requires root" in _echoed(mock_echo)
|
||||
|
||||
def test_reports_when_nothing_was_mounted(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.release_block", return_value=[]),
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 0
|
||||
assert "No playtime mounts" in _echoed(mock_echo)
|
||||
|
||||
def test_lists_released_mounts(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(
|
||||
f"{PKG}.release_block",
|
||||
return_value=["/usr/bin/steam", "/usr/bin/lutris"],
|
||||
),
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 0
|
||||
output = _echoed(mock_echo)
|
||||
assert "/usr/bin/steam" in output
|
||||
assert "/usr/bin/lutris" in output
|
||||
|
||||
def test_consults_no_state_at_all(self) -> None:
|
||||
"""The recovery hatch must work with an unreadable state file."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(
|
||||
f"{PKG}.load_state", side_effect=AssertionError("must not be called")
|
||||
),
|
||||
patch(f"{PKG}.release_block", return_value=[]),
|
||||
):
|
||||
assert cmd_gaming_unblock([]) == 0
|
||||
assert "Released 2 mount(s)" in echoed(mock_echo)
|
||||
|
||||
@ -38,6 +38,7 @@ class TestCmdServe:
|
||||
"""
|
||||
|
||||
def test_invokes_serve_with_parsed_args(self) -> None:
|
||||
"""The parsed port is checked for availability and then served."""
|
||||
with (
|
||||
patch.object(misc_mod, "frontend_is_stale", return_value=False),
|
||||
patch.object(misc_mod, "ensure_port_available") as guard,
|
||||
@ -48,6 +49,7 @@ class TestCmdServe:
|
||||
mock_serve.assert_called_once_with(DEFAULT_HOST, 8123)
|
||||
|
||||
def test_stale_frontend_is_rebuilt_before_binding(self) -> None:
|
||||
"""A stale frontend is rebuilt before the port check runs."""
|
||||
# Order matters: a failed build must not cost the caller the server
|
||||
# that was already running, so the build happens before the port check.
|
||||
_CALLS.clear()
|
||||
@ -61,6 +63,7 @@ class TestCmdServe:
|
||||
assert _CALLS == ["build", "guard"]
|
||||
|
||||
def test_failed_build_exits_1(self) -> None:
|
||||
"""A failed rebuild exits 1 without checking the port or serving."""
|
||||
with (
|
||||
patch.object(misc_mod, "frontend_is_stale", return_value=True),
|
||||
patch.object(misc_mod, "build_frontend", return_value=False),
|
||||
@ -74,6 +77,7 @@ class TestCmdServe:
|
||||
mock_serve.assert_not_called()
|
||||
|
||||
def test_port_stolen_after_the_check_exits_1(self) -> None:
|
||||
"""A port taken between the check and the bind exits 1 with a message."""
|
||||
# Covers the race between the /proc scan and the actual bind.
|
||||
taken = OSError(errno.EADDRINUSE, "Address already in use")
|
||||
with (
|
||||
@ -88,6 +92,7 @@ class TestCmdServe:
|
||||
assert "was taken while starting up" in echo.call_args[0][0]
|
||||
|
||||
def test_unrelated_oserror_propagates(self) -> None:
|
||||
"""An OSError other than address-in-use propagates."""
|
||||
# Swallowing this would turn a real fault into a bare "try again".
|
||||
denied = OSError(errno.EACCES, "Permission denied")
|
||||
with (
|
||||
|
||||
@ -5,29 +5,46 @@ from __future__ import annotations
|
||||
from string import Formatter
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer import _command_catalog, _command_locks
|
||||
from steam_backlog_enforcer import _command_catalog
|
||||
from steam_backlog_enforcer._command_catalog import (
|
||||
_Entry,
|
||||
_friction_json,
|
||||
_job,
|
||||
_spec,
|
||||
_view,
|
||||
)
|
||||
from steam_backlog_enforcer._command_locks import (
|
||||
_MANUAL_LOCK_EXEMPT_COMMANDS,
|
||||
_TOTAL_BLOCK_EXEMPT_COMMANDS,
|
||||
)
|
||||
from steam_backlog_enforcer._command_params import PARAMS
|
||||
from steam_backlog_enforcer._ctl_context import require_phrase
|
||||
from steam_backlog_enforcer._friction import FRICTION, Friction, expected_phrase
|
||||
from steam_backlog_enforcer._web_privileged import BUY_DLC_MINUTES
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.jobs._specs import JobFlags
|
||||
from steam_backlog_enforcer.main import _shared
|
||||
from steam_backlog_enforcer.main._shared import (
|
||||
_MANUAL_LOCK_EXEMPT_COMMANDS as SHARED_MANUAL_LOCK_EXEMPT,
|
||||
)
|
||||
from steam_backlog_enforcer.main._shared import (
|
||||
_TOTAL_BLOCK_EXEMPT_COMMANDS as SHARED_TOTAL_BLOCK_EXEMPT,
|
||||
)
|
||||
|
||||
|
||||
class TestFrictionJson:
|
||||
"""The contract's FrictionSpec."""
|
||||
|
||||
def test_none(self) -> None:
|
||||
assert _command_catalog._friction_json(None) is None
|
||||
"""No friction serialises to None."""
|
||||
assert _friction_json(None) is None
|
||||
|
||||
def test_without_countdown(self) -> None:
|
||||
assert _command_catalog._friction_json(Friction("do {x}")) == {
|
||||
"phrase_template": "do {x}"
|
||||
}
|
||||
"""A friction without countdown serialises just the phrase."""
|
||||
assert _friction_json(Friction("do {x}")) == {"phrase_template": "do {x}"}
|
||||
|
||||
def test_with_countdown(self) -> None:
|
||||
assert _command_catalog._friction_json(Friction("do it", 300)) == {
|
||||
"""A countdown is included when set."""
|
||||
assert _friction_json(Friction("do it", 300)) == {
|
||||
"phrase_template": "do it",
|
||||
"countdown_seconds": 300,
|
||||
}
|
||||
@ -37,8 +54,9 @@ class TestSpec:
|
||||
"""One CommandSpec object."""
|
||||
|
||||
def test_view_uses_entry_flags_and_endpoint(self) -> None:
|
||||
entry = _command_catalog._view("backlog", "/api/x")
|
||||
spec = _command_catalog._spec("status", entry, "desc", None)
|
||||
"""A view spec carries its endpoint and is not mutating."""
|
||||
entry = _view("backlog", "/api/x")
|
||||
spec = _spec("status", entry, "desc", None)
|
||||
assert spec["kind"] == "view"
|
||||
assert spec["view_endpoint"] == "/api/x"
|
||||
assert spec["mutating"] is False
|
||||
@ -47,10 +65,11 @@ class TestSpec:
|
||||
assert spec["locked_reason"] is None
|
||||
|
||||
def test_enforce_screen_is_cancellable(self) -> None:
|
||||
"""The enforce screen is cancellable and privileged."""
|
||||
# Its demo is a job that loops until stopped; without the flag the UI
|
||||
# offered no Cancel for it.
|
||||
entry = _command_catalog.CATALOG["enforce"]
|
||||
spec = _command_catalog._spec("enforce", entry, "d", None)
|
||||
spec = _spec("enforce", entry, "d", None)
|
||||
assert (spec["kind"], spec["cancellable"], spec["privileged"]) == (
|
||||
"screen",
|
||||
True,
|
||||
@ -58,11 +77,10 @@ class TestSpec:
|
||||
)
|
||||
|
||||
def test_job_takes_flags_from_registry(self) -> None:
|
||||
"""A job spec takes its flags from the job registry."""
|
||||
flags = JobFlags(mutating=False, cancellable=True, privileged=True)
|
||||
with patch.dict(_command_catalog.JOB_FLAGS, {"scan": flags}):
|
||||
spec = _command_catalog._spec(
|
||||
"scan", _command_catalog._job("backlog"), "d", "why"
|
||||
)
|
||||
spec = _spec("scan", _job("backlog"), "d", "why")
|
||||
assert (spec["mutating"], spec["cancellable"], spec["privileged"]) == (
|
||||
False,
|
||||
True,
|
||||
@ -72,15 +90,15 @@ class TestSpec:
|
||||
assert "view_endpoint" not in spec
|
||||
|
||||
def test_job_without_registry_entry_falls_back_to_entry(self) -> None:
|
||||
entry = _command_catalog._Entry("system", "job", mutating=True)
|
||||
"""A job missing from the registry uses the entry's own flags."""
|
||||
entry = _Entry("system", "job", mutating=True)
|
||||
with patch.dict(_command_catalog.JOB_FLAGS, clear=True):
|
||||
spec = _command_catalog._spec("zzz", entry, "d", None)
|
||||
spec = _spec("zzz", entry, "d", None)
|
||||
assert spec["mutating"] is True
|
||||
|
||||
def test_params_and_friction_are_attached(self) -> None:
|
||||
spec = _command_catalog._spec(
|
||||
"block-gaming", _command_catalog._job("gaming"), "d", None
|
||||
)
|
||||
"""A spec carries its params and friction phrase."""
|
||||
spec = _spec("block-gaming", _job("gaming"), "d", None)
|
||||
assert spec["params"] == [_command_catalog.PARAMS["block-gaming"][0].to_json()]
|
||||
assert spec["friction"] == {
|
||||
"phrase_template": "block all gaming for {days} days"
|
||||
@ -91,6 +109,7 @@ class TestCommandSpecs:
|
||||
"""The full payload."""
|
||||
|
||||
def test_one_spec_per_catalog_entry(self) -> None:
|
||||
"""Every catalog entry yields one described spec, in order."""
|
||||
specs = _command_catalog.command_specs(Config(), State())
|
||||
assert [s["name"] for s in specs] == list(_command_catalog.CATALOG)
|
||||
assert all(
|
||||
@ -98,6 +117,7 @@ class TestCommandSpecs:
|
||||
)
|
||||
|
||||
def test_unconfigured_commands_report_a_lock(self) -> None:
|
||||
"""Without setup, commands other than setup report a lock."""
|
||||
specs = {
|
||||
s["name"]: s for s in _command_catalog.command_specs(Config(), State())
|
||||
}
|
||||
@ -105,6 +125,7 @@ class TestCommandSpecs:
|
||||
assert specs["scan"]["locked_reason"] == "Not configured. Run 'setup' first."
|
||||
|
||||
def test_loads_config_and_state_when_omitted(self) -> None:
|
||||
"""Config and state are loaded when not passed in."""
|
||||
with (
|
||||
patch.object(_command_catalog.Config, "load", return_value=Config()) as cfg,
|
||||
patch.object(_command_catalog.State, "load", return_value=State()) as st,
|
||||
@ -118,24 +139,21 @@ class TestCommandLocks:
|
||||
"""The daemon and the CLI consult one table."""
|
||||
|
||||
def test_cli_uses_the_same_tables(self) -> None:
|
||||
assert _shared._MANUAL_LOCK_EXEMPT_COMMANDS is (
|
||||
_command_locks._MANUAL_LOCK_EXEMPT_COMMANDS
|
||||
)
|
||||
assert _shared._TOTAL_BLOCK_EXEMPT_COMMANDS is (
|
||||
_command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
)
|
||||
"""The CLI and the daemon share the very same lock tables."""
|
||||
assert SHARED_MANUAL_LOCK_EXEMPT is _MANUAL_LOCK_EXEMPT_COMMANDS
|
||||
assert SHARED_TOTAL_BLOCK_EXEMPT is _TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
|
||||
def test_total_block_is_stricter(self) -> None:
|
||||
assert _command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS <= (
|
||||
_command_locks._MANUAL_LOCK_EXEMPT_COMMANDS
|
||||
)
|
||||
assert "gaming-reset" not in _command_locks._TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
"""The total-block exemptions are a subset of the manual-lock ones."""
|
||||
assert _TOTAL_BLOCK_EXEMPT_COMMANDS <= _MANUAL_LOCK_EXEMPT_COMMANDS
|
||||
assert "gaming-reset" not in _TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
|
||||
|
||||
class TestPhrasesAreFillable:
|
||||
"""Every phrase the UI shows can be completed and is the one checked."""
|
||||
|
||||
def test_every_template_field_has_a_source(self) -> None:
|
||||
"""Every phrase field can be filled from a param or derived value."""
|
||||
# Mirrors fillPhrase in web/src/commands/catalog.ts: a field is a
|
||||
# param, ``game_name`` from an ``app_id`` param, or ``count`` from
|
||||
# the installed list. Anything else leaves Run disabled forever.
|
||||
@ -151,6 +169,7 @@ class TestPhrasesAreFillable:
|
||||
assert fields <= params | derived, name
|
||||
|
||||
def test_buy_dlc_phrase_is_the_unblock_the_daemon_checks(self) -> None:
|
||||
"""The buy-dlc phrase is exactly the unblock phrase the daemon accepts."""
|
||||
shown = expected_phrase("buy-dlc")
|
||||
assert shown == expected_phrase("unblock", minutes=BUY_DLC_MINUTES)
|
||||
assert shown is not None
|
||||
|
||||
@ -31,17 +31,22 @@ def _block(*, active: bool, days: float = 3.5) -> Iterator[None]:
|
||||
|
||||
|
||||
class TestLockReason:
|
||||
"""Which commands the gate refuses, and why."""
|
||||
|
||||
def test_unconfigured_is_refused(self) -> None:
|
||||
"""An unconfigured install refuses normal commands."""
|
||||
assert _gate.lock_reason("status", Config(), State()) == (
|
||||
_gate.NOT_CONFIGURED_MSG
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("command", ["setup", "add-exception", "serve"])
|
||||
def test_setup_commands_need_no_key(self, command: str) -> None:
|
||||
"""Setup commands run without an API key."""
|
||||
with _block(active=False):
|
||||
assert _gate.lock_reason(command, Config(), State()) is None
|
||||
|
||||
def test_total_block_names_days_and_allowed_commands(self) -> None:
|
||||
"""A total block names the time left and the allowed commands."""
|
||||
with _block(active=True):
|
||||
reason = _gate.lock_reason("scan", _CONFIGURED, State())
|
||||
assert reason is not None
|
||||
@ -49,19 +54,23 @@ class TestLockReason:
|
||||
assert "Allowed:" in reason
|
||||
|
||||
def test_exempt_commands_pass_the_total_block(self) -> None:
|
||||
"""Exempt commands pass a total block."""
|
||||
with _block(active=True):
|
||||
assert _gate.lock_reason("status", _CONFIGURED, State()) is None
|
||||
|
||||
def test_manual_pick_lock_names_the_picks(self) -> None:
|
||||
"""The manual pick lock names the locked picks."""
|
||||
with _block(active=False):
|
||||
reason = _gate.lock_reason("scan", _CONFIGURED, locked_state())
|
||||
assert reason is not None
|
||||
assert "Manual pick lock active (TestGame)" in reason
|
||||
|
||||
def test_exempt_commands_pass_the_pick_lock(self) -> None:
|
||||
"""Exempt commands pass the pick lock."""
|
||||
with _block(active=False):
|
||||
assert _gate.lock_reason("status", _CONFIGURED, locked_state()) is None
|
||||
|
||||
def test_no_state_skips_the_pick_check(self) -> None:
|
||||
"""Without state the pick lock is not checked."""
|
||||
with _block(active=False):
|
||||
assert _gate.lock_reason("scan", _CONFIGURED, None) is None
|
||||
|
||||
@ -16,6 +16,7 @@ class TestToJson:
|
||||
"""Contract serialisation omits unset optional keys."""
|
||||
|
||||
def test_minimal(self) -> None:
|
||||
"""A required spec serialises only name, label, type and required."""
|
||||
spec = ParamSpec("n", "N", "int")
|
||||
assert spec.to_json() == {
|
||||
"name": "n",
|
||||
@ -25,6 +26,7 @@ class TestToJson:
|
||||
}
|
||||
|
||||
def test_full(self) -> None:
|
||||
"""An optional spec serialises its bounds, default and help."""
|
||||
spec = ParamSpec(
|
||||
"n", "N", "int", required=False, min=0, max=9, default=0, help="h"
|
||||
)
|
||||
@ -44,55 +46,68 @@ class TestValidateParams:
|
||||
"""Coercion, bounds, defaults and unknown/missing params."""
|
||||
|
||||
def test_command_without_params(self) -> None:
|
||||
"""A command with no params validates to an empty dict."""
|
||||
assert validate_params("status", {}) == {}
|
||||
|
||||
def test_unknown_param(self) -> None:
|
||||
"""Unknown params are refused, listed in sorted order."""
|
||||
with pytest.raises(InvalidParamsError, match=r"Unknown param.*x, y"):
|
||||
validate_params("status", {"y": 1, "x": 2})
|
||||
|
||||
def test_missing_required(self) -> None:
|
||||
"""A missing required param is refused by name."""
|
||||
with pytest.raises(InvalidParamsError, match="Missing required param: days"):
|
||||
validate_params("block-gaming", {})
|
||||
|
||||
def test_default_filled(self) -> None:
|
||||
"""An omitted param with a default gets the default."""
|
||||
assert validate_params("unblock", {}) == {"minutes": 15}
|
||||
|
||||
def test_optional_without_default_is_omitted(self) -> None:
|
||||
"""An omitted optional param with no default stays absent."""
|
||||
optional = ParamSpec("o", "O", "string", required=False)
|
||||
with pytest.MonkeyPatch.context() as patch:
|
||||
patch.setitem(PARAMS, "probe", (optional,))
|
||||
assert validate_params("probe", {}) == {}
|
||||
|
||||
def test_int_from_string(self) -> None:
|
||||
"""Padded integer text is coerced to an int."""
|
||||
assert validate_params("block-gaming", {"days": " 7 "}) == {"days": 7}
|
||||
|
||||
def test_string_is_stripped(self) -> None:
|
||||
"""String params are stripped of surrounding whitespace."""
|
||||
assert validate_params("restore-backup", {"backup_id": " b1 "}) == {
|
||||
"backup_id": "b1"
|
||||
}
|
||||
|
||||
@pytest.mark.parametrize("raw", ["", " ", 5, None])
|
||||
def test_string_must_be_text(self, raw: object) -> None:
|
||||
"""Blank or non-string values for a string param are refused."""
|
||||
with pytest.raises(InvalidParamsError, match="non-empty string"):
|
||||
validate_params("restore-backup", {"backup_id": raw})
|
||||
|
||||
@pytest.mark.parametrize("raw", [True, 1.5, None, [1]])
|
||||
def test_int_must_be_integer_like(self, raw: object) -> None:
|
||||
"""Bools, floats, None and lists for an int param are refused."""
|
||||
with pytest.raises(InvalidParamsError, match="whole number"):
|
||||
validate_params("block-gaming", {"days": raw})
|
||||
|
||||
def test_int_text_must_parse(self) -> None:
|
||||
"""Non-numeric text for an int param is refused, quoting the input."""
|
||||
with pytest.raises(InvalidParamsError, match="got 'abc'"):
|
||||
validate_params("block-gaming", {"days": "abc"})
|
||||
|
||||
def test_below_min(self) -> None:
|
||||
"""A value under the minimum is refused."""
|
||||
with pytest.raises(InvalidParamsError, match="at least 1"):
|
||||
validate_params("block-gaming", {"days": 0})
|
||||
|
||||
def test_above_max(self) -> None:
|
||||
"""A value over the maximum is refused."""
|
||||
with pytest.raises(InvalidParamsError, match="at most 30"):
|
||||
validate_params("unblock", {"minutes": 31})
|
||||
|
||||
def test_boundaries_accepted(self) -> None:
|
||||
"""Values exactly at the bounds are accepted."""
|
||||
assert validate_params("unblock", {"minutes": 30}) == {"minutes": 30}
|
||||
assert validate_params("enforce", {"demo": 0}) == {"demo": 0}
|
||||
|
||||
@ -2,13 +2,14 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC
|
||||
from datetime import UTC, datetime, timedelta
|
||||
import json
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._snapshot import load_snapshot, save_snapshot
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.tests._config_paths import config_file_under
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -30,12 +31,7 @@ class TestConfig:
|
||||
def test_save(self, tmp_path: Path) -> None:
|
||||
"""Test save."""
|
||||
cfg = Config(steam_api_key="abc", steam_id="123")
|
||||
config_dir = tmp_path / "cfg"
|
||||
config_file = config_dir / "config.json"
|
||||
with (
|
||||
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
||||
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
||||
):
|
||||
with config_file_under(tmp_path) as config_file:
|
||||
cfg.save()
|
||||
data = json.loads(config_file.read_text(encoding="utf-8"))
|
||||
assert data["steam_api_key"] == "abc"
|
||||
@ -159,16 +155,12 @@ class TestState:
|
||||
state.skip_for_days(42, 7)
|
||||
assert "42" in state.skipped_until
|
||||
# Round-trip parse and check ~7 days in the future.
|
||||
from datetime import datetime
|
||||
|
||||
expiry = datetime.fromisoformat(state.skipped_until["42"])
|
||||
delta = (expiry - datetime.now(UTC)).total_seconds()
|
||||
assert 6 * 86400 < delta <= 7 * 86400 + 1
|
||||
|
||||
def test_active_skipped_ids_returns_active(self) -> None:
|
||||
"""Test active skipped ids returns active."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
state = State()
|
||||
future = datetime.now(UTC) + timedelta(days=3)
|
||||
state.skipped_until["100"] = future.isoformat()
|
||||
@ -178,8 +170,6 @@ class TestState:
|
||||
|
||||
def test_active_skipped_ids_prunes_expired(self) -> None:
|
||||
"""Test active skipped ids prunes expired."""
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
state = State()
|
||||
past = datetime.now(UTC) - timedelta(days=1)
|
||||
state.skipped_until["50"] = past.isoformat()
|
||||
|
||||
@ -8,6 +8,7 @@ from unittest.mock import patch
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._config_setup import interactive_setup
|
||||
from steam_backlog_enforcer.tests._config_paths import config_file_under
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -18,11 +19,8 @@ class TestInteractiveSetup:
|
||||
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
"""Test success."""
|
||||
config_dir = tmp_path / "cfg"
|
||||
config_file = config_dir / "config.json"
|
||||
with (
|
||||
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
||||
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
||||
config_file_under(tmp_path) as config_file,
|
||||
patch("builtins.input", side_effect=["mykey", "myid"]),
|
||||
):
|
||||
cfg = interactive_setup()
|
||||
@ -40,11 +38,8 @@ class TestInteractiveSetup:
|
||||
|
||||
def test_empty_steam_id_exits(self, tmp_path: Path) -> None:
|
||||
"""Test empty steam id exits."""
|
||||
config_dir = tmp_path / "cfg"
|
||||
config_file = config_dir / "config.json"
|
||||
with (
|
||||
patch("steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
|
||||
patch("steam_backlog_enforcer.config.CONFIG_FILE", config_file),
|
||||
config_file_under(tmp_path),
|
||||
patch("builtins.input", side_effect=["key", ""]),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
|
||||
@ -35,8 +35,9 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sock_path() -> Iterator[Path]:
|
||||
@pytest.fixture(name="sock_path")
|
||||
def fixture_sock_path() -> Iterator[Path]:
|
||||
"""Yield a socket path in a short temp dir (Unix socket path limit)."""
|
||||
with short_socket_dir() as directory:
|
||||
yield directory / "c.sock"
|
||||
|
||||
@ -49,15 +50,18 @@ class TestCall:
|
||||
"""The wire round trip."""
|
||||
|
||||
def test_returns_data(self, sock_path: Path) -> None:
|
||||
"""An ok reply returns its data object."""
|
||||
thread = serve_once(sock_path, _line({"ok": True, "data": {"a": 1}}))
|
||||
assert _ctl_client.call("ping", {"x": 1}, path=sock_path) == {"a": 1}
|
||||
thread.join(5)
|
||||
|
||||
def test_ok_without_object_data_is_empty(self, sock_path: Path) -> None:
|
||||
"""An ok reply whose data is not an object yields an empty dict."""
|
||||
serve_once(sock_path, _line({"ok": True, "data": 5}))
|
||||
assert _ctl_client.call("ping", path=sock_path) == {}
|
||||
|
||||
def test_error_reply_raises_ctl_error(self, sock_path: Path) -> None:
|
||||
"""An error reply raises CtlError carrying code, message and data."""
|
||||
reply = {"ok": False, "error": "locked", "message": "no", "data": {"k": 1}}
|
||||
serve_once(sock_path, _line(reply))
|
||||
with pytest.raises(CtlError) as caught:
|
||||
@ -66,6 +70,7 @@ class TestCall:
|
||||
assert caught.value.data == {"k": 1}
|
||||
|
||||
def test_error_reply_defaults(self, sock_path: Path) -> None:
|
||||
"""An error reply without fields falls back to op_failed and empty data."""
|
||||
serve_once(sock_path, _line({"ok": False}))
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_client.call("ping", path=sock_path)
|
||||
@ -73,21 +78,25 @@ class TestCall:
|
||||
assert caught.value.data == {}
|
||||
|
||||
def test_missing_socket(self, sock_path: Path) -> None:
|
||||
"""No socket file means the daemon is unreachable."""
|
||||
with pytest.raises(DaemonUnreachableError, match="control socket"):
|
||||
_ctl_client.call("ping", path=sock_path)
|
||||
|
||||
def test_closed_without_answer(self, sock_path: Path) -> None:
|
||||
"""A daemon that hangs up silently counts as unreachable."""
|
||||
serve_once(sock_path, b"")
|
||||
with pytest.raises(DaemonUnreachableError, match="without answering"):
|
||||
_ctl_client.call("ping", path=sock_path)
|
||||
|
||||
@pytest.mark.parametrize("reply", [b"{nope\n", b"[1]\n", b"\xff\n"])
|
||||
def test_malformed_reply(self, sock_path: Path, reply: bytes) -> None:
|
||||
"""Non-JSON, non-object or undecodable replies are rejected as malformed."""
|
||||
serve_once(sock_path, reply)
|
||||
with pytest.raises(DaemonUnreachableError, match="malformed"):
|
||||
_ctl_client.call("ping", path=sock_path)
|
||||
|
||||
def test_oversize_reply(self, sock_path: Path) -> None:
|
||||
"""A reply over the size cap is rejected as too large."""
|
||||
serve_once(sock_path, b"x" * (MAX_RESPONSE_BYTES + 70000))
|
||||
with pytest.raises(DaemonUnreachableError, match="too large"):
|
||||
_ctl_client.call("ping", path=sock_path)
|
||||
@ -109,11 +118,13 @@ class TestTypedWrappers:
|
||||
return patch.object(_ctl_client, "call", return_value=data)
|
||||
|
||||
def test_ping(self) -> None:
|
||||
"""ping sends the bare ping op."""
|
||||
with self._call({}) as call:
|
||||
_ctl_client.ping()
|
||||
call.assert_called_once_with("ping")
|
||||
|
||||
def test_status(self) -> None:
|
||||
"""status maps the reply onto DaemonInfo."""
|
||||
data = {
|
||||
"state": "running",
|
||||
"started_at": "t",
|
||||
@ -124,6 +135,7 @@ class TestTypedWrappers:
|
||||
assert _ctl_client.status() == DaemonInfo("running", "t", 4, "x")
|
||||
|
||||
def test_status_no_restart_time(self) -> None:
|
||||
"""status tolerates a missing restart time and fast-install list."""
|
||||
data = {"state": "running", "started_at": "t", "pid": 4}
|
||||
with self._call(data):
|
||||
info = _ctl_client.status()
|
||||
@ -131,6 +143,7 @@ class TestTypedWrappers:
|
||||
assert info.fast_installs == () # an older daemon sends none
|
||||
|
||||
def test_status_fast_installs(self) -> None:
|
||||
"""status parses running fast installs into FastInstallInfo."""
|
||||
run = {
|
||||
"app_id": 7,
|
||||
"name": "G",
|
||||
@ -146,6 +159,7 @@ class TestTypedWrappers:
|
||||
)
|
||||
|
||||
def test_status_failed_fast_install(self) -> None:
|
||||
"""status keeps the error of a failed fast install."""
|
||||
run = {"app_id": 7, "name": "G", "phase": "failed", "percent": None}
|
||||
run |= {"elapsed_seconds": 0, "error": "exit 1"}
|
||||
data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]}
|
||||
@ -154,34 +168,41 @@ class TestTypedWrappers:
|
||||
assert info.fast_installs[0].error == "exit 1"
|
||||
|
||||
def test_journal_tail(self) -> None:
|
||||
"""journal_tail sends the line count and stringifies every line."""
|
||||
with self._call({"lines": ["a", 2]}) as call:
|
||||
assert _ctl_client.journal_tail(9) == ["a", "2"]
|
||||
call.assert_called_once_with("journal_tail", {"lines": 9})
|
||||
|
||||
def test_store_unblock(self) -> None:
|
||||
"""store_unblock sends minutes and phrase and returns a StoreWindow."""
|
||||
with self._call({"minutes": 5, "until": "u"}) as call:
|
||||
assert _ctl_client.store_unblock(5, "ph") == StoreWindow(5, "u")
|
||||
assert call.call_args.args == ("store_unblock", {"minutes": 5, "phrase": "ph"})
|
||||
|
||||
def test_block_gaming(self) -> None:
|
||||
"""block_gaming returns a BlockResult with the until time."""
|
||||
with self._call({"days": 3, "until": "u"}):
|
||||
assert _ctl_client.block_gaming(3, "ph") == BlockResult(3, "u")
|
||||
|
||||
def test_block_gaming_no_until(self) -> None:
|
||||
"""block_gaming keeps a null until as None."""
|
||||
with self._call({"days": 3, "until": None}):
|
||||
assert _ctl_client.block_gaming(3, "ph") == BlockResult(3, None)
|
||||
|
||||
def test_gaming_unblock(self) -> None:
|
||||
"""gaming_unblock returns the released paths."""
|
||||
with self._call({"released": ["/a"]}):
|
||||
assert _ctl_client.gaming_unblock("ph") == ["/a"]
|
||||
|
||||
def test_reset_arm_and_heartbeat(self) -> None:
|
||||
"""Arm and heartbeat both return the PendingReset."""
|
||||
expected = PendingReset("abc", "p", "a", "r", 10)
|
||||
with self._call(dict(_PENDING)):
|
||||
assert _ctl_client.gaming_reset_arm("ph") == expected
|
||||
assert _ctl_client.gaming_reset_heartbeat("abc") == expected
|
||||
|
||||
def test_reset_commit(self) -> None:
|
||||
"""gaming_reset_commit coerces the reply into a ResetResult."""
|
||||
data = {
|
||||
"day_key": "d",
|
||||
"seconds_before": 1.5,
|
||||
@ -193,14 +214,17 @@ class TestTypedWrappers:
|
||||
assert result == ResetResult("d", 1.5, was_blocked=True, released=["/m"])
|
||||
|
||||
def test_reset_cancel(self) -> None:
|
||||
"""gaming_reset_cancel sends the pending id."""
|
||||
with self._call({}) as call:
|
||||
_ctl_client.gaming_reset_cancel("abc")
|
||||
call.assert_called_once_with("gaming_reset_cancel", {"pending_id": "abc"})
|
||||
|
||||
def test_restart(self) -> None:
|
||||
"""restart returns the next restart time."""
|
||||
with self._call({"restart_available_at": "x"}):
|
||||
assert _ctl_client.restart() == "x"
|
||||
|
||||
def test_restart_no_time(self) -> None:
|
||||
"""restart returns None when the daemon sends no time."""
|
||||
with self._call({}):
|
||||
assert _ctl_client.restart() is None
|
||||
|
||||
@ -20,12 +20,14 @@ class TestHoldTick:
|
||||
"""The lock is held for the body and always released."""
|
||||
|
||||
def test_holds_and_releases(self) -> None:
|
||||
"""The tick lock is held inside the block and released after."""
|
||||
ctx = make_ctx()
|
||||
with _ctl_context.hold_tick(ctx):
|
||||
assert ctx.tick_lock.locked()
|
||||
assert not ctx.tick_lock.locked()
|
||||
|
||||
def test_releases_after_error(self) -> None:
|
||||
"""The tick lock is released when the body raises."""
|
||||
ctx = make_ctx()
|
||||
with pytest.raises(RuntimeError), _ctl_context.hold_tick(ctx):
|
||||
msg = "boom"
|
||||
@ -33,6 +35,7 @@ class TestHoldTick:
|
||||
assert not ctx.tick_lock.locked()
|
||||
|
||||
def test_busy_when_loop_keeps_the_lock(self) -> None:
|
||||
"""A lock the loop never frees times out as busy."""
|
||||
ctx = make_ctx()
|
||||
ctx.tick_lock.acquire()
|
||||
with (
|
||||
@ -48,21 +51,25 @@ class TestRequirePhrase:
|
||||
"""The typed confirmation phrase."""
|
||||
|
||||
def test_accepts_exact_phrase_with_padding(self) -> None:
|
||||
"""The exact phrase is accepted despite surrounding spaces."""
|
||||
_ctl_context.require_phrase(
|
||||
"block-gaming", " block all gaming for 3 days ", days=3
|
||||
)
|
||||
|
||||
def test_non_string(self) -> None:
|
||||
"""A non-string phrase is invalid_params."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_context.require_phrase("gaming-unblock", 5)
|
||||
assert _code(caught) == "invalid_params"
|
||||
|
||||
def test_wrong_phrase(self) -> None:
|
||||
"""A mistyped phrase is wrong_phrase."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_context.require_phrase("gaming-unblock", "nope")
|
||||
assert _code(caught) == "wrong_phrase"
|
||||
|
||||
def test_command_without_phrase_is_never_accepted(self) -> None:
|
||||
"""A command with no phrase can never be confirmed."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_context.require_phrase("status", "")
|
||||
assert _code(caught) == "wrong_phrase"
|
||||
@ -72,19 +79,23 @@ class TestIntArg:
|
||||
"""Bounded integer arguments."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
"""An in-range integer is returned."""
|
||||
assert _ctl_context.int_arg({"n": 5}, "n", 1, 10) == 5
|
||||
|
||||
@pytest.mark.parametrize("value", [True, "5", 5.0, None])
|
||||
def test_wrong_type(self, value: object) -> None:
|
||||
"""Bools, strings, floats and None are not integers."""
|
||||
with pytest.raises(CtlError, match="must be an integer"):
|
||||
_ctl_context.int_arg({"n": value}, "n", 1, 10)
|
||||
|
||||
def test_missing(self) -> None:
|
||||
"""A missing key is not an integer."""
|
||||
with pytest.raises(CtlError, match="must be an integer"):
|
||||
_ctl_context.int_arg({}, "n", 1, 10)
|
||||
|
||||
@pytest.mark.parametrize("value", [0, 11])
|
||||
def test_out_of_range(self, value: int) -> None:
|
||||
"""Values outside the bounds are refused."""
|
||||
with pytest.raises(CtlError, match="between 1 and 10"):
|
||||
_ctl_context.int_arg({"n": value}, "n", 1, 10)
|
||||
|
||||
@ -93,10 +104,12 @@ class TestStrArg:
|
||||
"""Non-empty string arguments."""
|
||||
|
||||
def test_valid(self) -> None:
|
||||
"""A non-empty string is returned."""
|
||||
assert _ctl_context.str_arg({"s": "abc"}, "s") == "abc"
|
||||
|
||||
@pytest.mark.parametrize("value", ["", 5, None])
|
||||
def test_invalid(self, value: object) -> None:
|
||||
"""Empty strings and non-strings are refused."""
|
||||
with pytest.raises(CtlError, match="non-empty string"):
|
||||
_ctl_context.str_arg({"s": value}, "s")
|
||||
|
||||
@ -117,23 +130,28 @@ class TestCheckLocks:
|
||||
"""The socket honours the same locks as the CLI."""
|
||||
|
||||
def test_nothing_locked(self) -> None:
|
||||
"""With no lock active every command passes."""
|
||||
with _locks(block=False, manual=False):
|
||||
_ctl_context.check_locks("block-gaming")
|
||||
|
||||
def test_total_block_refuses(self) -> None:
|
||||
"""A total block refuses a non-exempt command."""
|
||||
with _locks(block=True, manual=False), pytest.raises(CtlError) as caught:
|
||||
_ctl_context.check_locks("gaming-reset")
|
||||
assert _code(caught) == "locked"
|
||||
|
||||
def test_total_block_exempt_command(self) -> None:
|
||||
"""A total block lets gaming-unblock through."""
|
||||
with _locks(block=True, manual=False):
|
||||
_ctl_context.check_locks("gaming-unblock")
|
||||
|
||||
def test_manual_pick_lock_refuses(self) -> None:
|
||||
"""The manual-pick lock refuses unblock."""
|
||||
with _locks(block=False, manual=True), pytest.raises(CtlError) as caught:
|
||||
_ctl_context.check_locks("unblock")
|
||||
assert _code(caught) == "locked"
|
||||
|
||||
def test_manual_pick_exempt_command(self) -> None:
|
||||
"""The manual-pick lock lets gaming-reset through."""
|
||||
with _locks(block=False, manual=True):
|
||||
_ctl_context.check_locks("gaming-reset")
|
||||
|
||||
@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _ctl_control
|
||||
from steam_backlog_enforcer._ctl_control import _is_supervised
|
||||
from steam_backlog_enforcer.config import Config
|
||||
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
||||
|
||||
@ -19,6 +20,7 @@ class TestDaemonControl:
|
||||
"""The handle the loop holds."""
|
||||
|
||||
def test_properties(self) -> None:
|
||||
"""The handle exposes the shared tick lock and the restart flag."""
|
||||
ctx = make_ctx()
|
||||
control = _ctl_control.DaemonControl(ctx, None)
|
||||
assert control.tick_lock is ctx.tick_lock
|
||||
@ -27,6 +29,7 @@ class TestDaemonControl:
|
||||
assert control.restart_requested is True
|
||||
|
||||
def test_flush_without_request_does_nothing(self) -> None:
|
||||
"""Without a restart request nothing is billed."""
|
||||
control = _ctl_control.DaemonControl(make_ctx(), None)
|
||||
with patch.object(_ctl_control, "playtime_tick") as tick:
|
||||
assert (
|
||||
@ -38,6 +41,7 @@ class TestDaemonControl:
|
||||
tick.assert_not_called()
|
||||
|
||||
def test_flush_bills_marks_and_exits(self) -> None:
|
||||
"""A restart request bills the tick and records the exit."""
|
||||
ctx = make_ctx()
|
||||
ctx.restart_event.set()
|
||||
control = _ctl_control.DaemonControl(ctx, None)
|
||||
@ -51,11 +55,13 @@ class TestDaemonControl:
|
||||
record.assert_called_once()
|
||||
|
||||
def test_close_stops_server(self) -> None:
|
||||
"""close() stops a running server."""
|
||||
server = MagicMock()
|
||||
_ctl_control.DaemonControl(make_ctx(), server).close()
|
||||
server.stop.assert_called_once()
|
||||
|
||||
def test_close_inert(self) -> None:
|
||||
"""close() without a server is a no-op."""
|
||||
_ctl_control.DaemonControl(make_ctx(), None).close()
|
||||
|
||||
|
||||
@ -63,34 +69,39 @@ class TestIsSupervised:
|
||||
"""systemd sets INVOCATION_ID for its services."""
|
||||
|
||||
def test_set(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""INVOCATION_ID set means supervised."""
|
||||
monkeypatch.setenv("INVOCATION_ID", "abc")
|
||||
assert _ctl_control._is_supervised() is True
|
||||
assert _is_supervised() is True
|
||||
|
||||
def test_unset(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""No INVOCATION_ID means unsupervised."""
|
||||
monkeypatch.delenv("INVOCATION_ID", raising=False)
|
||||
assert _ctl_control._is_supervised() is False
|
||||
assert _is_supervised() is False
|
||||
|
||||
|
||||
class TestStartControl:
|
||||
"""Only the real root daemon serves."""
|
||||
|
||||
def test_demo_is_inert(self) -> None:
|
||||
"""Demo mode never builds a server."""
|
||||
with patch.object(_ctl_control, "CtlServer") as server:
|
||||
control = _ctl_control.start_control(Config(), demo=True)
|
||||
server.assert_not_called()
|
||||
assert control._server is None
|
||||
assert control.server is None
|
||||
|
||||
def test_non_root_is_inert(self) -> None:
|
||||
"""A non-root process never builds a server."""
|
||||
with (
|
||||
patch.object(_ctl_control.os, "geteuid", return_value=1000),
|
||||
patch.object(_ctl_control, "CtlServer") as server,
|
||||
):
|
||||
control = _ctl_control.start_control(Config(), demo=False)
|
||||
server.assert_not_called()
|
||||
assert control._server is None
|
||||
assert control.server is None
|
||||
|
||||
@pytest.mark.parametrize("user", [None, "ghost"])
|
||||
def test_unknown_desktop_user_fails_closed(self, user: str | None) -> None:
|
||||
"""An unresolvable desktop user gets no server."""
|
||||
with (
|
||||
patch.object(_ctl_control.os, "geteuid", return_value=0),
|
||||
patch.object(_ctl_control, "resolve_desktop_user", return_value=user),
|
||||
@ -99,9 +110,10 @@ class TestStartControl:
|
||||
):
|
||||
control = _ctl_control.start_control(Config(), demo=False)
|
||||
server.assert_not_called()
|
||||
assert control._server is None
|
||||
assert control.server is None
|
||||
|
||||
def test_root_desktop_user_fails_closed(self) -> None:
|
||||
"""A desktop user that resolves to root gets no server."""
|
||||
entry = MagicMock(pw_uid=0, pw_gid=0)
|
||||
with (
|
||||
patch.object(_ctl_control.os, "geteuid", return_value=0),
|
||||
@ -111,10 +123,11 @@ class TestStartControl:
|
||||
):
|
||||
control = _ctl_control.start_control(Config(), demo=False)
|
||||
server.assert_not_called()
|
||||
assert control._server is None
|
||||
assert control.server is None
|
||||
|
||||
@pytest.mark.parametrize("started", [True, False], ids=["bound", "bind_failed"])
|
||||
def test_serves_for_desktop_user(self, *, started: bool) -> None:
|
||||
"""Root serves the desktop user's uid/gid, kept only if bound."""
|
||||
entry = MagicMock(pw_uid=1000, pw_gid=1001)
|
||||
with (
|
||||
patch.object(_ctl_control.os, "geteuid", return_value=0),
|
||||
@ -128,4 +141,6 @@ class TestStartControl:
|
||||
"desktop_uid": 1000,
|
||||
"desktop_gid": 1001,
|
||||
}
|
||||
assert (control._server is not None) is started
|
||||
# close() stops the server only if start_control kept it.
|
||||
control.close()
|
||||
assert server_cls.return_value.stop.called is started
|
||||
|
||||
@ -14,6 +14,7 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _ctl_ops, _fast_install_progress
|
||||
from steam_backlog_enforcer._ctl_ops import _hand_back_state_file
|
||||
from steam_backlog_enforcer._ctl_protocol import CtlError
|
||||
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
||||
|
||||
@ -25,9 +26,11 @@ class TestSimpleOps:
|
||||
"""ping and status."""
|
||||
|
||||
def test_ping(self) -> None:
|
||||
"""ping answers pong."""
|
||||
assert _ctl_ops.op_ping(make_ctx(), {}) == {"pong": True}
|
||||
|
||||
def test_status_running(self) -> None:
|
||||
"""status reports running state, start time, restart time and pid."""
|
||||
with patch.object(_ctl_ops, "restart_available_at", return_value=None):
|
||||
data = _ctl_ops.op_status(make_ctx(), {})
|
||||
assert data["state"] == "running"
|
||||
@ -36,6 +39,7 @@ class TestSimpleOps:
|
||||
assert isinstance(data["pid"], int)
|
||||
|
||||
def test_status_reports_fast_installs(self, tmp_path: Path) -> None:
|
||||
"""status lists a tracked fast install with its phase."""
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(b"Shutting down the Steam client...\r\n")
|
||||
_fast_install_progress.track(7, "G", log, offset=0, now=0.0)
|
||||
@ -50,10 +54,12 @@ class TestSimpleOps:
|
||||
assert run["percent"] is None
|
||||
|
||||
def test_status_without_fast_installs(self) -> None:
|
||||
"""status sends an empty fast-install list when none run."""
|
||||
with patch.object(_ctl_ops, "restart_available_at", return_value=None):
|
||||
assert _ctl_ops.op_status(make_ctx(), {})["fast_installs"] == []
|
||||
|
||||
def test_status_restarting(self) -> None:
|
||||
"""A set restart event makes status report restarting."""
|
||||
ctx = make_ctx()
|
||||
ctx.restart_event.set()
|
||||
assert _ctl_ops.op_status(ctx, {})["state"] == "restarting"
|
||||
@ -66,6 +72,7 @@ class TestJournalTail:
|
||||
return MagicMock(**kwargs)
|
||||
|
||||
def test_default_lines_and_truncation(self) -> None:
|
||||
"""journal_tail defaults to 50 lines and truncates long ones to 500 chars."""
|
||||
out = "a\n" + "x" * 900 + "\n"
|
||||
with patch.object(
|
||||
_ctl_ops.subprocess, "run", return_value=self._run(stdout=out)
|
||||
@ -76,6 +83,7 @@ class TestJournalTail:
|
||||
assert argv[argv.index("-n") + 1] == "50"
|
||||
|
||||
def test_explicit_lines(self) -> None:
|
||||
"""journal_tail passes an explicit line count to journalctl."""
|
||||
with patch.object(
|
||||
_ctl_ops.subprocess, "run", return_value=self._run(stdout="")
|
||||
) as run:
|
||||
@ -83,6 +91,7 @@ class TestJournalTail:
|
||||
assert "7" in run.call_args.args[0]
|
||||
|
||||
def test_lines_out_of_range(self) -> None:
|
||||
"""A line count above the cap is rejected."""
|
||||
with pytest.raises(CtlError):
|
||||
_ctl_ops.op_journal_tail(make_ctx(), {"lines": 201})
|
||||
|
||||
@ -90,6 +99,7 @@ class TestJournalTail:
|
||||
"exc", [OSError("gone"), subprocess.TimeoutExpired("journalctl", 5)]
|
||||
)
|
||||
def test_failure_is_op_failed(self, exc: Exception) -> None:
|
||||
"""journalctl failing or timing out becomes op_failed."""
|
||||
with (
|
||||
patch.object(_ctl_ops.subprocess, "run", side_effect=exc),
|
||||
pytest.raises(CtlError) as caught,
|
||||
@ -102,6 +112,7 @@ class TestHandBackStateFile:
|
||||
"""A state.json root just created is chowned to the config dir's owner."""
|
||||
|
||||
def test_chowns_when_owner_differs(self, tmp_path: Path) -> None:
|
||||
"""state.json is chowned to the config dir owner when they differ."""
|
||||
state = tmp_path / "state.json"
|
||||
state.write_text("{}")
|
||||
owner = MagicMock(st_uid=4242, st_gid=4343)
|
||||
@ -110,10 +121,11 @@ class TestHandBackStateFile:
|
||||
patch.object(_ctl_ops, "STATE_FILE", state),
|
||||
patch.object(_ctl_ops.os, "chown") as chown,
|
||||
):
|
||||
_ctl_ops._hand_back_state_file()
|
||||
_hand_back_state_file()
|
||||
chown.assert_called_once_with(state, 4242, 4343)
|
||||
|
||||
def test_leaves_matching_owner_alone(self, tmp_path: Path) -> None:
|
||||
"""No chown happens when the owner already matches."""
|
||||
state = tmp_path / "state.json"
|
||||
state.write_text("{}")
|
||||
with (
|
||||
@ -121,12 +133,13 @@ class TestHandBackStateFile:
|
||||
patch.object(_ctl_ops, "STATE_FILE", state),
|
||||
patch.object(_ctl_ops.os, "chown") as chown,
|
||||
):
|
||||
_ctl_ops._hand_back_state_file()
|
||||
_hand_back_state_file()
|
||||
chown.assert_not_called()
|
||||
|
||||
def test_missing_file_is_tolerated(self, tmp_path: Path) -> None:
|
||||
"""A missing state.json does not raise."""
|
||||
with (
|
||||
patch.object(_ctl_ops, "CONFIG_DIR", tmp_path),
|
||||
patch.object(_ctl_ops, "STATE_FILE", tmp_path / "absent.json"),
|
||||
):
|
||||
_ctl_ops._hand_back_state_file()
|
||||
_hand_back_state_file()
|
||||
|
||||
@ -25,17 +25,18 @@ _UNTIL = datetime(2026, 2, 1, 12, 0, tzinfo=UTC)
|
||||
_UNBLOCK_PHRASE = "unblock the store for 5 minutes"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def hand_back() -> Iterator[MagicMock]:
|
||||
@pytest.fixture(name="hand_back", autouse=True)
|
||||
def fixture_hand_back() -> Iterator[MagicMock]:
|
||||
"""The chown helper is covered elsewhere; keep it away from real paths."""
|
||||
with patch.object(_ctl_ops, "_hand_back_state_file") as hand_back:
|
||||
yield hand_back
|
||||
with patch.object(_ctl_ops, "_hand_back_state_file") as helper:
|
||||
yield helper
|
||||
|
||||
|
||||
class TestStoreUnblock:
|
||||
"""store_unblock opens the timed store window."""
|
||||
|
||||
def test_opens_window(self, hand_back: MagicMock) -> None:
|
||||
"""The right phrase opens the window and hands the state file back."""
|
||||
ctx = make_ctx()
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks") as locks,
|
||||
@ -51,11 +52,13 @@ class TestStoreUnblock:
|
||||
assert not ctx.tick_lock.locked()
|
||||
|
||||
def test_wrong_phrase(self) -> None:
|
||||
"""A wrong phrase refuses the store window."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_ops.op_store_unblock(make_ctx(), {"minutes": 5, "phrase": "no"})
|
||||
assert caught.value.code == "wrong_phrase"
|
||||
|
||||
def test_minutes_out_of_range(self) -> None:
|
||||
"""Minutes outside the allowed range are refused as invalid_params."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_ops.op_store_unblock(make_ctx(), {"minutes": 99, "phrase": "x"})
|
||||
assert caught.value.code == "invalid_params"
|
||||
@ -63,6 +66,7 @@ class TestStoreUnblock:
|
||||
def test_runtime_error_is_op_failed_and_still_hands_back(
|
||||
self, hand_back: MagicMock
|
||||
) -> None:
|
||||
"""A RuntimeError is op_failed and the state is still handed back."""
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks"),
|
||||
patch.object(_ctl_ops, "open_store_window", side_effect=RuntimeError("x")),
|
||||
@ -75,6 +79,7 @@ class TestStoreUnblock:
|
||||
hand_back.assert_called_once()
|
||||
|
||||
def test_locked(self) -> None:
|
||||
"""An active CLI lock refuses the store window."""
|
||||
lock = CtlError("locked", "a total gaming block is active")
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks", side_effect=lock),
|
||||
@ -95,6 +100,7 @@ class TestBlockGaming:
|
||||
}
|
||||
|
||||
def test_starts_block(self) -> None:
|
||||
"""The right phrase starts the block and reports its end time."""
|
||||
status = MagicMock(until=_UNTIL)
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks") as locks,
|
||||
@ -107,6 +113,7 @@ class TestBlockGaming:
|
||||
locks.assert_called_once_with("block-gaming")
|
||||
|
||||
def test_until_unknown(self) -> None:
|
||||
"""A block without a known end reports until as None."""
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks"),
|
||||
patch.object(_ctl_ops, "start_total_block", return_value=True),
|
||||
@ -118,6 +125,7 @@ class TestBlockGaming:
|
||||
assert data["until"] is None
|
||||
|
||||
def test_failure_is_op_failed(self) -> None:
|
||||
"""A block that fails to start is op_failed."""
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks"),
|
||||
patch.object(_ctl_ops, "start_total_block", return_value=False),
|
||||
@ -127,6 +135,7 @@ class TestBlockGaming:
|
||||
assert caught.value.code == "op_failed"
|
||||
|
||||
def test_days_capped(self) -> None:
|
||||
"""Days above MAX_BLOCK_DAYS are refused as invalid_params."""
|
||||
args = {"days": _ctl_ops.MAX_BLOCK_DAYS + 1, "phrase": "x"}
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_ops.op_block_gaming(make_ctx(), args)
|
||||
@ -137,6 +146,7 @@ class TestGamingUnblock:
|
||||
"""gaming_unblock releases every playtime mount."""
|
||||
|
||||
def test_releases(self) -> None:
|
||||
"""The right phrase releases the mounts and lists their paths."""
|
||||
with (
|
||||
patch.object(_ctl_ops, "check_locks") as locks,
|
||||
patch.object(
|
||||
@ -150,6 +160,7 @@ class TestGamingUnblock:
|
||||
locks.assert_called_once_with("gaming-unblock")
|
||||
|
||||
def test_wrong_phrase(self) -> None:
|
||||
"""A wrong phrase refuses the mount release."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_ops.op_gaming_unblock(make_ctx(), {"phrase": ""})
|
||||
assert caught.value.code == "wrong_phrase"
|
||||
@ -159,6 +170,7 @@ class TestOpsTable:
|
||||
"""The allowlist is exactly the documented ops."""
|
||||
|
||||
def test_names(self) -> None:
|
||||
"""The op table contains exactly the documented op names."""
|
||||
assert set(_ctl_ops.OPS) == {
|
||||
"ping",
|
||||
"status",
|
||||
|
||||
@ -29,13 +29,15 @@ class _Clock:
|
||||
return self.now
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clock() -> _Clock:
|
||||
@pytest.fixture(name="clock")
|
||||
def fixture_clock() -> _Clock:
|
||||
"""A fake monotonic clock starting at 1000 s."""
|
||||
return _Clock()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pending(clock: _Clock) -> _ctl_pending.PendingResets:
|
||||
@pytest.fixture(name="pending")
|
||||
def fixture_pending(clock: _Clock) -> _ctl_pending.PendingResets:
|
||||
"""PendingResets driven by the fake clock and a fixed wall time."""
|
||||
return _ctl_pending.PendingResets(clock=clock, wall=lambda: _WALL)
|
||||
|
||||
|
||||
@ -43,10 +45,12 @@ class TestCountdownSeconds:
|
||||
"""The VM-only override can shorten the countdown, never lengthen it."""
|
||||
|
||||
def test_default(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Without the override the countdown is 300 s."""
|
||||
monkeypatch.delenv(_ctl_pending.TEST_COUNTDOWN_ENV, raising=False)
|
||||
assert _ctl_pending.countdown_seconds() == 300
|
||||
|
||||
def test_shortened(self, monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""A shorter override shortens the countdown."""
|
||||
monkeypatch.setenv(_ctl_pending.TEST_COUNTDOWN_ENV, "7")
|
||||
assert _ctl_pending.countdown_seconds() == 7
|
||||
|
||||
@ -54,6 +58,7 @@ class TestCountdownSeconds:
|
||||
def test_invalid_or_longer_is_ignored(
|
||||
self, monkeypatch: pytest.MonkeyPatch, raw: str
|
||||
) -> None:
|
||||
"""Zero, longer or unparsable overrides fall back to 300 s."""
|
||||
monkeypatch.setenv(_ctl_pending.TEST_COUNTDOWN_ENV, raw)
|
||||
assert _ctl_pending.countdown_seconds() == 300
|
||||
|
||||
@ -64,6 +69,7 @@ class TestArm:
|
||||
def test_arm_sets_deadlines(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""arm records wall and monotonic deadlines 300 s out and a 32-char id."""
|
||||
armed = pending.arm()
|
||||
assert armed.armed_at == _WALL
|
||||
assert armed.ready_mono == clock.now + 300
|
||||
@ -73,6 +79,7 @@ class TestArm:
|
||||
def test_rearm_returns_the_live_one(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""Arming again while live returns the same reset."""
|
||||
first = pending.arm()
|
||||
clock.now += 5
|
||||
assert pending.arm() is first
|
||||
@ -80,6 +87,7 @@ class TestArm:
|
||||
def test_rearm_after_lapse_is_new(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""Arming after a lapse starts a new reset with a new id."""
|
||||
first = pending.arm()
|
||||
clock.now += _ctl_pending.LAPSE_AFTER_SECONDS + 1
|
||||
second = pending.arm()
|
||||
@ -92,6 +100,7 @@ class TestBeat:
|
||||
def test_beat_extends_life(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""A heartbeat updates the last beat but not the ready deadline."""
|
||||
armed = pending.arm()
|
||||
clock.now += 10
|
||||
beat = pending.beat(armed.pending_id)
|
||||
@ -103,6 +112,7 @@ class TestBeat:
|
||||
def test_beat_after_lapse_fails(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""A heartbeat after the lapse window fails as lapsed."""
|
||||
armed = pending.arm()
|
||||
clock.now += _ctl_pending.LAPSE_AFTER_SECONDS + 1
|
||||
with pytest.raises(CtlError) as caught:
|
||||
@ -110,6 +120,7 @@ class TestBeat:
|
||||
assert caught.value.code == PENDING_LAPSED
|
||||
|
||||
def test_beat_unknown_id_fails(self, pending: _ctl_pending.PendingResets) -> None:
|
||||
"""A heartbeat with the wrong id fails as lapsed."""
|
||||
pending.arm()
|
||||
with pytest.raises(CtlError) as caught:
|
||||
pending.beat("not-the-id")
|
||||
@ -118,11 +129,13 @@ class TestBeat:
|
||||
def test_beat_non_ascii_id_does_not_raise_typeerror(
|
||||
self, pending: _ctl_pending.PendingResets
|
||||
) -> None:
|
||||
"""A non-ASCII id is a CtlError, not a TypeError from the compare."""
|
||||
pending.arm()
|
||||
with pytest.raises(CtlError):
|
||||
pending.beat("é")
|
||||
|
||||
def test_beat_with_nothing_armed(self, pending: _ctl_pending.PendingResets) -> None:
|
||||
"""A heartbeat with nothing armed fails as lapsed."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
pending.beat("x")
|
||||
assert caught.value.code == PENDING_LAPSED
|
||||
@ -134,6 +147,7 @@ class TestCheckReady:
|
||||
def test_early_reports_remaining(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""check_ready before the countdown ends reports the seconds left."""
|
||||
armed = pending.arm()
|
||||
for _ in range(10):
|
||||
clock.now += 10
|
||||
@ -146,6 +160,7 @@ class TestCheckReady:
|
||||
def test_ready_after_countdown(
|
||||
self, pending: _ctl_pending.PendingResets, clock: _Clock
|
||||
) -> None:
|
||||
"""check_ready passes once the countdown has elapsed."""
|
||||
armed = pending.arm()
|
||||
for _ in range(30):
|
||||
clock.now += 10
|
||||
@ -153,6 +168,7 @@ class TestCheckReady:
|
||||
assert pending.check_ready(armed.pending_id).pending_id == armed.pending_id
|
||||
|
||||
def test_unknown_id(self, pending: _ctl_pending.PendingResets) -> None:
|
||||
"""check_ready with an unknown id fails as lapsed."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
pending.check_ready("x")
|
||||
assert caught.value.code == PENDING_LAPSED
|
||||
@ -162,6 +178,7 @@ class TestFinish:
|
||||
"""Finishing drops the reset and is idempotent."""
|
||||
|
||||
def test_finish_clears(self, pending: _ctl_pending.PendingResets) -> None:
|
||||
"""finish drops the reset so later beats fail."""
|
||||
armed = pending.arm()
|
||||
pending.finish(armed.pending_id)
|
||||
with pytest.raises(CtlError):
|
||||
@ -170,6 +187,7 @@ class TestFinish:
|
||||
def test_finish_other_id_keeps_current(
|
||||
self, pending: _ctl_pending.PendingResets
|
||||
) -> None:
|
||||
"""finish with another id keeps the current reset."""
|
||||
armed = pending.arm()
|
||||
pending.finish("other")
|
||||
assert pending.beat(armed.pending_id).pending_id == armed.pending_id
|
||||
@ -177,8 +195,10 @@ class TestFinish:
|
||||
def test_finish_with_nothing_armed(
|
||||
self, pending: _ctl_pending.PendingResets
|
||||
) -> None:
|
||||
"""finish with nothing armed is a no-op."""
|
||||
pending.finish("x")
|
||||
|
||||
def test_default_sources_are_usable(self) -> None:
|
||||
"""The default clock and wall sources produce sane deadlines."""
|
||||
resets = _ctl_pending.PendingResets()
|
||||
assert resets.arm().ready_at > resets.arm().armed_at
|
||||
|
||||
@ -25,8 +25,8 @@ if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def short_dir() -> Iterator[Path]:
|
||||
@pytest.fixture(name="short_dir")
|
||||
def fixture_short_dir() -> Iterator[Path]:
|
||||
"""A short-path temp dir: AF_UNIX paths are capped near 108 bytes."""
|
||||
with tempfile.TemporaryDirectory(prefix="ctl") as name:
|
||||
yield Path(name)
|
||||
@ -36,29 +36,35 @@ class TestCodec:
|
||||
"""Line encoding and the two response shapes."""
|
||||
|
||||
def test_roundtrip(self) -> None:
|
||||
"""Encode then decode returns the object; the line ends in a newline."""
|
||||
raw = _ctl_protocol.encode_line({"op": "ping", "args": {}})
|
||||
assert raw.endswith(b"\n")
|
||||
assert _ctl_protocol.decode_line(raw) == {"op": "ping", "args": {}}
|
||||
|
||||
def test_decode_rejects_garbage(self) -> None:
|
||||
"""Decoding invalid JSON raises ValueError."""
|
||||
with pytest.raises(ValueError, match="Expecting"):
|
||||
_ctl_protocol.decode_line(b"{nope")
|
||||
|
||||
def test_ok_response(self) -> None:
|
||||
"""An ok response wraps the data under ok=true."""
|
||||
body = json.loads(_ctl_protocol.ok_response({"a": 1}))
|
||||
assert body == {"ok": True, "data": {"a": 1}}
|
||||
|
||||
def test_error_response_without_data(self) -> None:
|
||||
"""An error response without data carries only code and message."""
|
||||
err = _ctl_protocol.CtlError(_ctl_protocol.LOCKED, "nope")
|
||||
body = json.loads(_ctl_protocol.error_response(err))
|
||||
assert body == {"ok": False, "error": "locked", "message": "nope"}
|
||||
|
||||
def test_error_response_with_data(self) -> None:
|
||||
"""An error response includes the error data when present."""
|
||||
err = _ctl_protocol.CtlError(_ctl_protocol.BUSY, "wait", {"retry": 2})
|
||||
body = json.loads(_ctl_protocol.error_response(err))
|
||||
assert body["data"] == {"retry": 2}
|
||||
|
||||
def test_ctl_error_attributes(self) -> None:
|
||||
"""CtlError exposes code, message, empty data and a code: msg string."""
|
||||
err = _ctl_protocol.CtlError("code", "msg")
|
||||
assert (err.code, err.message, err.data) == ("code", "msg", {})
|
||||
assert str(err) == "code: msg"
|
||||
@ -68,9 +74,11 @@ class TestTypes:
|
||||
"""The result types are frozen value objects."""
|
||||
|
||||
def test_hashable_because_frozen(self) -> None:
|
||||
"""DaemonInfo is hashable because it is frozen."""
|
||||
assert hash(DaemonInfo("running", "t", 1, None)) is not None
|
||||
|
||||
def test_equality(self) -> None:
|
||||
"""BlockResult values compare equal by field."""
|
||||
assert BlockResult(3, None) == BlockResult(3, None)
|
||||
|
||||
|
||||
@ -78,6 +86,7 @@ class TestPeerUid:
|
||||
"""SO_PEERCRED reports the connecting process's real uid."""
|
||||
|
||||
def test_reports_own_uid(self) -> None:
|
||||
"""The peer uid of a socketpair is the current uid."""
|
||||
left, right = socket.socketpair()
|
||||
try:
|
||||
assert _ctl_wire.peer_uid(left) == os.getuid()
|
||||
@ -90,9 +99,11 @@ class TestSocketIsLive:
|
||||
"""Probing a path for an accepting listener."""
|
||||
|
||||
def test_missing_path(self, short_dir: Path) -> None:
|
||||
"""A path with no socket is reported as not live."""
|
||||
assert _ctl_wire.socket_is_live(short_dir / "none.sock") is False
|
||||
|
||||
def test_listening_path(self, short_dir: Path) -> None:
|
||||
"""A path with a listening socket is reported as live."""
|
||||
path = short_dir / "s.sock"
|
||||
server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
||||
try:
|
||||
@ -107,12 +118,14 @@ class TestReadLine:
|
||||
"""Bounded, deadline-limited request reading."""
|
||||
|
||||
def test_reads_first_line(self) -> None:
|
||||
"""Only the first line is returned, without its newline."""
|
||||
left, right = socket.socketpair()
|
||||
with left, right:
|
||||
right.sendall(b'{"op":"ping"}\nextra')
|
||||
assert _ctl_wire.read_line(left) == b'{"op":"ping"}'
|
||||
|
||||
def test_eof_without_newline_returns_data(self) -> None:
|
||||
"""Data ended by EOF without a newline is still returned."""
|
||||
left, right = socket.socketpair()
|
||||
with left, right:
|
||||
right.sendall(b"abc")
|
||||
@ -120,12 +133,14 @@ class TestReadLine:
|
||||
assert _ctl_wire.read_line(left) == b"abc"
|
||||
|
||||
def test_immediate_eof_returns_none(self) -> None:
|
||||
"""EOF before any data returns None."""
|
||||
left, right = socket.socketpair()
|
||||
with left, right:
|
||||
right.shutdown(socket.SHUT_WR)
|
||||
assert _ctl_wire.read_line(left) is None
|
||||
|
||||
def test_oversize_is_truncated_past_the_cap(self) -> None:
|
||||
"""An oversize request is cut to one byte past the cap."""
|
||||
left, right = socket.socketpair()
|
||||
with left, right:
|
||||
right.sendall(b"x" * (_ctl_protocol.MAX_REQUEST_BYTES + 100))
|
||||
@ -134,6 +149,7 @@ class TestReadLine:
|
||||
assert len(line) == _ctl_protocol.MAX_REQUEST_BYTES + 1
|
||||
|
||||
def test_stalled_client_times_out(self) -> None:
|
||||
"""A client that never sends returns None after the read timeout."""
|
||||
left, right = socket.socketpair()
|
||||
with (
|
||||
left,
|
||||
@ -143,6 +159,7 @@ class TestReadLine:
|
||||
assert _ctl_wire.read_line(left) is None
|
||||
|
||||
def test_deadline_already_passed(self) -> None:
|
||||
"""A deadline already in the past returns None without reading."""
|
||||
left, right = socket.socketpair()
|
||||
with left, right, patch.object(_ctl_wire, "READ_TIMEOUT_SECONDS", -1.0):
|
||||
assert _ctl_wire.read_line(left) is None
|
||||
@ -152,12 +169,14 @@ class TestParseRequest:
|
||||
"""Validation of a decoded request."""
|
||||
|
||||
def test_valid_with_args(self) -> None:
|
||||
"""A valid request yields its op and args."""
|
||||
assert _ctl_wire.parse_request(b'{"op":"x","args":{"a":1}}') == (
|
||||
"x",
|
||||
{"a": 1},
|
||||
)
|
||||
|
||||
def test_args_default_to_empty(self) -> None:
|
||||
"""A request without args yields an empty args dict."""
|
||||
assert _ctl_wire.parse_request(b'{"op":"x"}') == ("x", {})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
@ -165,10 +184,12 @@ class TestParseRequest:
|
||||
[b"{bad", b"\xff\xfe", b"[1]", b'{"op":5}', b'{"args":{}}'],
|
||||
)
|
||||
def test_rejects_malformed(self, line: bytes) -> None:
|
||||
"""Malformed lines are refused as invalid_params."""
|
||||
with pytest.raises(_ctl_protocol.CtlError) as caught:
|
||||
_ctl_wire.parse_request(line)
|
||||
assert caught.value.code == _ctl_protocol.INVALID_PARAMS
|
||||
|
||||
def test_rejects_non_object_args(self) -> None:
|
||||
"""Non-object args are refused."""
|
||||
with pytest.raises(_ctl_protocol.CtlError, match="args must be"):
|
||||
_ctl_wire.parse_request(b'{"op":"x","args":[1]}')
|
||||
|
||||
@ -49,6 +49,7 @@ class TestArmHeartbeatCancel:
|
||||
"""The cheap phases of the two-phase reset."""
|
||||
|
||||
def test_arm_returns_view(self) -> None:
|
||||
"""Arming checks the reset lock and returns the pending countdown view."""
|
||||
ctx = make_ctx()
|
||||
with patch.object(_ctl_reset, "check_locks") as locks:
|
||||
view = _ctl_reset.op_arm(ctx, {"phrase": _PHRASE})
|
||||
@ -59,22 +60,26 @@ class TestArmHeartbeatCancel:
|
||||
assert set(view) >= {"pending_id", "armed_at", "ready_at"}
|
||||
|
||||
def test_arm_wrong_phrase(self) -> None:
|
||||
"""Arming with the wrong phrase is refused as wrong_phrase."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_reset.op_arm(make_ctx(), {"phrase": "no"})
|
||||
assert caught.value.code == "wrong_phrase"
|
||||
|
||||
def test_heartbeat(self) -> None:
|
||||
"""A heartbeat on an armed reset returns its view."""
|
||||
ctx = make_ctx()
|
||||
pending_id = ctx.pending.arm().pending_id
|
||||
view = _ctl_reset.op_heartbeat(ctx, {"pending_id": pending_id})
|
||||
assert view["pending_id"] == pending_id
|
||||
|
||||
def test_heartbeat_needs_id(self) -> None:
|
||||
"""A heartbeat without a pending id is invalid_params."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_reset.op_heartbeat(make_ctx(), {})
|
||||
assert caught.value.code == "invalid_params"
|
||||
|
||||
def test_cancel_is_idempotent(self) -> None:
|
||||
"""Cancelling twice succeeds both times and the reset is gone."""
|
||||
ctx = make_ctx()
|
||||
pending_id = ctx.pending.arm().pending_id
|
||||
for _ in range(2):
|
||||
@ -97,6 +102,7 @@ class TestCommit:
|
||||
return ctx, pending_id
|
||||
|
||||
def test_commits_and_clears(self) -> None:
|
||||
"""A ready commit resets today, reports it and clears the pending reset."""
|
||||
ctx, pending_id = self._ready()
|
||||
with (
|
||||
patch.object(_ctl_reset, "check_locks"),
|
||||
@ -117,6 +123,7 @@ class TestCommit:
|
||||
ctx.pending.beat(pending_id)
|
||||
|
||||
def test_too_early(self) -> None:
|
||||
"""Committing before the countdown ends is countdown_running."""
|
||||
ctx = make_ctx()
|
||||
pending_id = ctx.pending.arm().pending_id
|
||||
with pytest.raises(CtlError) as caught:
|
||||
@ -124,12 +131,14 @@ class TestCommit:
|
||||
assert caught.value.code == "countdown_running"
|
||||
|
||||
def test_wrong_phrase(self) -> None:
|
||||
"""Committing with the wrong phrase is wrong_phrase."""
|
||||
ctx, pending_id = self._ready()
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_reset.op_commit(ctx, {"pending_id": pending_id, "phrase": "no"})
|
||||
assert caught.value.code == "wrong_phrase"
|
||||
|
||||
def test_os_error_is_op_failed_and_keeps_pending(self) -> None:
|
||||
"""A failed reset write is op_failed and leaves the reset pending."""
|
||||
ctx, pending_id = self._ready()
|
||||
with (
|
||||
patch.object(_ctl_reset, "check_locks"),
|
||||
@ -141,6 +150,7 @@ class TestCommit:
|
||||
assert ctx.pending.check_ready(pending_id).pending_id == pending_id
|
||||
|
||||
def test_locked(self) -> None:
|
||||
"""An active lock refuses the commit as locked."""
|
||||
ctx, pending_id = self._ready()
|
||||
lock = CtlError("locked", "total block")
|
||||
with (
|
||||
|
||||
@ -15,20 +15,23 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _ctl_gap, _ctl_restart
|
||||
from steam_backlog_enforcer._ctl_gap import _take_exit_time
|
||||
from steam_backlog_enforcer._ctl_protocol import CtlError
|
||||
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
||||
|
||||
|
||||
def _write_last(value: object) -> None:
|
||||
"""Persist ``value`` as the last restart time in the state file."""
|
||||
path = _ctl_restart.RESTART_STATE_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps({"last_restart_at": value}))
|
||||
path.write_text(json.dumps({"last_restart_at": value}), encoding="utf-8")
|
||||
|
||||
|
||||
class TestRateLimitState:
|
||||
"""Reading the persisted last-restart time."""
|
||||
|
||||
def test_absent_means_allowed(self) -> None:
|
||||
"""No state file means a restart is available now."""
|
||||
assert _ctl_restart.seconds_until_available() == 0.0
|
||||
assert _ctl_restart.restart_available_at() is None
|
||||
|
||||
@ -36,23 +39,27 @@ class TestRateLimitState:
|
||||
"text", ["not json", "{}", "[1]", '{"last_restart_at":"x"}']
|
||||
)
|
||||
def test_unusable_file_means_allowed(self, text: str) -> None:
|
||||
"""A corrupt or malformed state file does not block a restart."""
|
||||
path = _ctl_restart.RESTART_STATE_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
assert _ctl_restart.seconds_until_available() == 0.0
|
||||
|
||||
def test_recent_restart_waits(self) -> None:
|
||||
"""A restart 100 s ago leaves the rest of the interval to wait."""
|
||||
_write_last(time.time() - 100)
|
||||
wait = _ctl_restart.seconds_until_available()
|
||||
assert 495 < wait <= 500
|
||||
assert _ctl_restart.restart_available_at() is not None
|
||||
|
||||
def test_future_timestamp_is_capped_at_interval(self) -> None:
|
||||
"""A clock-skewed future timestamp never waits longer than one interval."""
|
||||
_write_last(time.time() + 99999)
|
||||
wait = _ctl_restart.seconds_until_available()
|
||||
assert wait == pytest.approx(_ctl_restart.RESTART_INTERVAL_SECONDS, abs=1)
|
||||
|
||||
def test_old_restart_allowed(self) -> None:
|
||||
"""A restart long past the interval allows another one."""
|
||||
_write_last(time.time() - 10_000)
|
||||
assert _ctl_restart.seconds_until_available() == 0.0
|
||||
|
||||
@ -61,6 +68,7 @@ class TestOpRestart:
|
||||
"""The restart op."""
|
||||
|
||||
def test_unsupervised_is_refused(self) -> None:
|
||||
"""Without a supervisor the op refuses and sets no restart event."""
|
||||
ctx = make_ctx(supervised=False)
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_restart.op_restart(ctx, {})
|
||||
@ -68,16 +76,18 @@ class TestOpRestart:
|
||||
assert not ctx.restart_event.is_set()
|
||||
|
||||
def test_accepts_and_persists(self) -> None:
|
||||
"""An accepted restart sets the event and saves a 0600 state file."""
|
||||
ctx = make_ctx()
|
||||
data = _ctl_restart.op_restart(ctx, {})
|
||||
assert ctx.restart_event.is_set()
|
||||
assert data["restarting"] is True
|
||||
assert data["restart_available_at"] is not None
|
||||
saved = json.loads(_ctl_restart.RESTART_STATE_FILE.read_text())
|
||||
saved = json.loads(_ctl_restart.RESTART_STATE_FILE.read_text(encoding="utf-8"))
|
||||
assert saved["last_restart_at"] == pytest.approx(time.time(), abs=5)
|
||||
assert _ctl_restart.RESTART_STATE_FILE.stat().st_mode & 0o777 == 0o600
|
||||
|
||||
def test_second_restart_is_rate_limited(self) -> None:
|
||||
"""A second restart inside the interval is refused with a retry hint."""
|
||||
_ctl_restart.op_restart(make_ctx(), {})
|
||||
ctx = make_ctx()
|
||||
with pytest.raises(CtlError) as caught:
|
||||
@ -87,6 +97,7 @@ class TestOpRestart:
|
||||
assert not ctx.restart_event.is_set()
|
||||
|
||||
def test_unwritable_limit_fails_closed(self) -> None:
|
||||
"""If the rate limit cannot be saved, the restart does not happen."""
|
||||
ctx = make_ctx()
|
||||
with (
|
||||
patch.object(_ctl_restart, "_atomic_write", side_effect=OSError("ro")),
|
||||
@ -101,30 +112,36 @@ class TestRestartGap:
|
||||
"""The exit marker and its settlement by the next daemon."""
|
||||
|
||||
def test_record_exit_writes_marker(self) -> None:
|
||||
"""Recording an exit saves the current time in the marker."""
|
||||
_ctl_gap.record_exit()
|
||||
saved = json.loads(_ctl_gap.RESTART_EXIT_FILE.read_text())
|
||||
saved = json.loads(_ctl_gap.RESTART_EXIT_FILE.read_text(encoding="utf-8"))
|
||||
assert saved["exited_at"] == pytest.approx(time.time(), abs=5)
|
||||
|
||||
def test_record_exit_never_raises(self) -> None:
|
||||
"""A failed marker write is swallowed."""
|
||||
with patch.object(_ctl_gap, "_atomic_write", side_effect=OSError("ro")):
|
||||
_ctl_gap.record_exit()
|
||||
|
||||
def _marker(self, text: str) -> Path:
|
||||
"""Write ``text`` to the exit marker and return its path."""
|
||||
path = _ctl_gap.RESTART_EXIT_FILE
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(text)
|
||||
path.write_text(text, encoding="utf-8")
|
||||
return path
|
||||
|
||||
@pytest.mark.parametrize("text", ["junk", "{}", '{"exited_at":"x"}'])
|
||||
def test_unusable_marker_is_dropped(self, text: str) -> None:
|
||||
"""A corrupt marker reads as no exit time and is deleted."""
|
||||
path = self._marker(text)
|
||||
assert _ctl_gap._take_exit_time() is None
|
||||
assert _take_exit_time() is None
|
||||
assert not path.exists()
|
||||
|
||||
def test_missing_marker(self) -> None:
|
||||
assert _ctl_gap._take_exit_time() is None
|
||||
"""No marker reads as no exit time."""
|
||||
assert _take_exit_time() is None
|
||||
|
||||
def _settle(self, *, demo: bool = False) -> MagicMock:
|
||||
"""Run the gap settlement and return the patched playtime tick."""
|
||||
with patch.object(_ctl_gap, "playtime_tick") as tick:
|
||||
_ctl_gap.settle_restart_gap(
|
||||
MagicMock(), MagicMock(), base_interval=3.0, demo=demo
|
||||
@ -132,6 +149,7 @@ class TestRestartGap:
|
||||
return tick
|
||||
|
||||
def test_bills_the_gap_once(self) -> None:
|
||||
"""A 20 s gap is billed as one tick and the marker is spent."""
|
||||
path = self._marker(json.dumps({"exited_at": time.time() - 20}))
|
||||
tick = self._settle()
|
||||
interval = tick.call_args.kwargs["interval"]
|
||||
@ -139,25 +157,30 @@ class TestRestartGap:
|
||||
assert not path.exists()
|
||||
|
||||
def test_short_gap_keeps_base_interval(self) -> None:
|
||||
"""A gap shorter than the base interval bills the base interval."""
|
||||
self._marker(json.dumps({"exited_at": time.time() - 0.5}))
|
||||
assert self._settle().call_args.kwargs["interval"] == 3.0
|
||||
|
||||
@pytest.mark.parametrize("age", [-50, 301])
|
||||
def test_out_of_window_is_not_billed(self, age: float) -> None:
|
||||
"""Future or too-old exit times bill nothing."""
|
||||
self._marker(json.dumps({"exited_at": time.time() - age}))
|
||||
self._settle().assert_not_called()
|
||||
|
||||
def test_no_marker_bills_nothing(self) -> None:
|
||||
"""Without a marker no playtime is billed."""
|
||||
self._settle().assert_not_called()
|
||||
|
||||
def test_demo_never_touches_the_marker(self) -> None:
|
||||
# The marker is the daemon's (root-only); a demo -- often the desktop
|
||||
# user's web job -- must neither crash on it nor spend it.
|
||||
"""A demo run neither bills nor deletes the daemon's marker."""
|
||||
path = self._marker(json.dumps({"exited_at": time.time() - 20}))
|
||||
self._settle(demo=True).assert_not_called()
|
||||
assert path.exists()
|
||||
|
||||
def test_undeletable_marker_is_still_read(self) -> None:
|
||||
"""A marker that cannot be deleted still yields its exit time."""
|
||||
self._marker(json.dumps({"exited_at": 123.0}))
|
||||
with patch.object(Path, "unlink", side_effect=PermissionError("root")):
|
||||
assert _ctl_gap._take_exit_time() == 123.0
|
||||
assert _take_exit_time() == 123.0
|
||||
|
||||
@ -15,7 +15,11 @@ from unittest.mock import MagicMock, patch
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _ctl_client, _ctl_server
|
||||
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError
|
||||
from steam_backlog_enforcer._ctl_protocol import (
|
||||
MAX_REQUEST_BYTES,
|
||||
CtlError,
|
||||
DaemonUnreachableError,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx, short_socket_dir
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -23,14 +27,16 @@ if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sock_path() -> Iterator[Path]:
|
||||
@pytest.fixture(name="sock_path")
|
||||
def fixture_sock_path() -> Iterator[Path]:
|
||||
"""Yield a socket path inside a short temporary directory."""
|
||||
with short_socket_dir() as directory:
|
||||
yield directory / "ctl.sock"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def server(sock_path: Path) -> Iterator[_ctl_server.CtlServer]:
|
||||
@pytest.fixture(name="server")
|
||||
def fixture_server(sock_path: Path) -> Iterator[_ctl_server.CtlServer]:
|
||||
"""Yield a started server bound to ``sock_path``, stopped afterwards."""
|
||||
srv = _ctl_server.CtlServer(
|
||||
make_ctx(), desktop_uid=os.getuid(), desktop_gid=os.getgid(), path=sock_path
|
||||
)
|
||||
@ -46,14 +52,15 @@ class TestLifecycle:
|
||||
def test_serves_ping_and_stop_removes_socket(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
"""A started server answers ping with the socket mode set; stop unlinks it."""
|
||||
assert _ctl_client.call("ping", path=sock_path) == {"pong": True}
|
||||
assert sock_path.stat().st_mode & 0o777 == _ctl_server.SOCKET_MODE
|
||||
server.stop()
|
||||
assert not sock_path.exists()
|
||||
|
||||
def test_does_not_take_over_a_live_socket(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_does_not_take_over_a_live_socket(self, sock_path: Path) -> None:
|
||||
"""A second server refuses a live socket and the first keeps serving."""
|
||||
second = _ctl_server.CtlServer(
|
||||
make_ctx(), desktop_uid=os.getuid(), desktop_gid=os.getgid(), path=sock_path
|
||||
)
|
||||
@ -61,6 +68,7 @@ class TestLifecycle:
|
||||
assert _ctl_client.call("ping", path=sock_path) == {"pong": True}
|
||||
|
||||
def test_replaces_a_stale_socket_file(self, sock_path: Path) -> None:
|
||||
"""A leftover non-listening file at the path is replaced on start."""
|
||||
sock_path.write_text("stale")
|
||||
srv = _ctl_server.CtlServer(
|
||||
make_ctx(), desktop_uid=os.getuid(), desktop_gid=os.getgid(), path=sock_path
|
||||
@ -70,6 +78,7 @@ class TestLifecycle:
|
||||
srv.stop()
|
||||
|
||||
def test_bind_failure_returns_false(self, sock_path: Path) -> None:
|
||||
"""An unbindable path makes start report failure instead of raising."""
|
||||
blocker = sock_path.parent / "file"
|
||||
blocker.write_text("x")
|
||||
srv = _ctl_server.CtlServer(
|
||||
@ -81,6 +90,7 @@ class TestLifecycle:
|
||||
assert srv.start() is False
|
||||
|
||||
def test_stop_before_start(self, sock_path: Path) -> None:
|
||||
"""Stopping a never-started server is a harmless no-op."""
|
||||
srv = _ctl_server.CtlServer(
|
||||
make_ctx(), desktop_uid=1, desktop_gid=1, path=sock_path
|
||||
)
|
||||
@ -90,14 +100,16 @@ class TestLifecycle:
|
||||
class TestRequests:
|
||||
"""End-to-end dispatch through the real socket."""
|
||||
|
||||
def test_unknown_op(self, server: _ctl_server.CtlServer, sock_path: Path) -> None:
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_unknown_op(self, sock_path: Path) -> None:
|
||||
"""An op the server does not know is refused as unknown_command."""
|
||||
with pytest.raises(CtlError) as caught:
|
||||
_ctl_client.call("rm_rf", path=sock_path)
|
||||
assert caught.value.code == "unknown_command"
|
||||
|
||||
def test_crashing_op_is_internal_error(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_crashing_op_is_internal_error(self, sock_path: Path) -> None:
|
||||
"""An op that raises a bare exception answers op_failed."""
|
||||
boom = MagicMock(side_effect=ValueError("bad"))
|
||||
with (
|
||||
patch.dict(_ctl_server.OPS, {"boom": boom}),
|
||||
@ -106,9 +118,9 @@ class TestRequests:
|
||||
_ctl_client.call("boom", path=sock_path)
|
||||
assert caught.value.code == "op_failed"
|
||||
|
||||
def test_refused_op_carries_error_data(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_refused_op_carries_error_data(self, sock_path: Path) -> None:
|
||||
"""A CtlError raised by an op reaches the client with its code and data."""
|
||||
refuse = MagicMock(side_effect=CtlError("busy", "wait", {"n": 1}))
|
||||
with (
|
||||
patch.dict(_ctl_server.OPS, {"r": refuse}),
|
||||
@ -117,14 +129,19 @@ class TestRequests:
|
||||
_ctl_client.call("r", path=sock_path)
|
||||
assert (caught.value.code, caught.value.data) == ("busy", {"n": 1})
|
||||
|
||||
def test_oversize_line(self, server: _ctl_server.CtlServer) -> None:
|
||||
with pytest.raises(CtlError) as caught:
|
||||
server._run(b"x" * (_ctl_server.MAX_REQUEST_BYTES + 1), 0)
|
||||
assert caught.value.code == "invalid_params"
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_oversize_line(self, sock_path: Path) -> None:
|
||||
"""A request line over the size cap is refused as invalid_params."""
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as raw:
|
||||
raw.settimeout(5)
|
||||
raw.connect(str(sock_path))
|
||||
raw.sendall(b"x" * (MAX_REQUEST_BYTES + 1))
|
||||
reply = json.loads(raw.makefile().readline())
|
||||
assert reply["error"] == "invalid_params"
|
||||
|
||||
def test_malformed_request_over_socket(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_malformed_request_over_socket(self, sock_path: Path) -> None:
|
||||
"""A non-JSON line gets an ok=false invalid_params reply."""
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as raw:
|
||||
raw.settimeout(5)
|
||||
raw.connect(str(sock_path))
|
||||
@ -133,9 +150,9 @@ class TestRequests:
|
||||
assert reply["ok"] is False
|
||||
assert reply["error"] == "invalid_params"
|
||||
|
||||
def test_silent_client_gets_no_reply(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
@pytest.mark.usefixtures("server")
|
||||
def test_silent_client_gets_no_reply(self, sock_path: Path) -> None:
|
||||
"""A client that sends nothing is closed without a reply."""
|
||||
with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as raw:
|
||||
raw.settimeout(5)
|
||||
raw.connect(str(sock_path))
|
||||
@ -145,6 +162,7 @@ class TestRequests:
|
||||
def test_unreachable_after_stop(
|
||||
self, server: _ctl_server.CtlServer, sock_path: Path
|
||||
) -> None:
|
||||
"""After stop, a client call raises DaemonUnreachableError."""
|
||||
server.stop()
|
||||
with pytest.raises(DaemonUnreachableError):
|
||||
_ctl_client.call("ping", path=sock_path)
|
||||
|
||||
@ -19,6 +19,7 @@ from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
||||
|
||||
|
||||
def _server() -> _ctl_server.CtlServer:
|
||||
"""Build a server whose desktop uid is not the test's own uid."""
|
||||
return _ctl_server.CtlServer(
|
||||
make_ctx(), desktop_uid=os.getuid() + 1, desktop_gid=1, path=MagicMock()
|
||||
)
|
||||
@ -28,61 +29,66 @@ class TestServeLoop:
|
||||
"""The accept loop survives everything but being stopped."""
|
||||
|
||||
def test_no_socket_returns(self) -> None:
|
||||
_server()._serve()
|
||||
"""Without a bound socket the accept loop returns at once."""
|
||||
_server().serve()
|
||||
|
||||
def test_timeout_then_stop(self) -> None:
|
||||
"""An accept timeout loops again until the server is stopped."""
|
||||
srv = _server()
|
||||
sock = MagicMock()
|
||||
|
||||
def accept() -> None:
|
||||
srv._stop.set()
|
||||
srv.stop()
|
||||
raise TimeoutError
|
||||
|
||||
sock.accept.side_effect = accept
|
||||
srv._sock = sock
|
||||
srv._serve()
|
||||
srv.sock = sock
|
||||
srv.serve()
|
||||
sock.accept.assert_called_once()
|
||||
|
||||
def test_oserror_while_stopping_returns_quietly(self) -> None:
|
||||
"""An accept error after stop() exits without backing off."""
|
||||
srv = _server()
|
||||
sock = MagicMock()
|
||||
|
||||
def accept() -> None:
|
||||
srv._stop.set()
|
||||
srv.stop()
|
||||
msg = "closed"
|
||||
raise OSError(msg)
|
||||
|
||||
sock.accept.side_effect = accept
|
||||
srv._sock = sock
|
||||
srv.sock = sock
|
||||
with patch.object(_ctl_server.time, "sleep") as sleep:
|
||||
srv._serve()
|
||||
srv.serve()
|
||||
sleep.assert_not_called()
|
||||
|
||||
def test_oserror_while_running_backs_off(self) -> None:
|
||||
"""An accept error while running sleeps before retrying."""
|
||||
srv = _server()
|
||||
sock = MagicMock()
|
||||
sock.accept.side_effect = [OSError("emfile"), TimeoutError]
|
||||
srv._sock = sock
|
||||
srv.sock = sock
|
||||
|
||||
def stop(_seconds: float) -> None:
|
||||
srv._stop.set()
|
||||
srv.stop()
|
||||
|
||||
with patch.object(_ctl_server.time, "sleep", side_effect=stop) as sleep:
|
||||
srv._serve()
|
||||
srv.serve()
|
||||
sleep.assert_called_once()
|
||||
|
||||
def test_accepted_connection_is_admitted(self) -> None:
|
||||
"""An accepted connection is passed to admission."""
|
||||
srv = _server()
|
||||
conn = MagicMock()
|
||||
sock = MagicMock()
|
||||
sock.accept.return_value = (conn, None)
|
||||
srv._sock = sock
|
||||
srv.sock = sock
|
||||
|
||||
def admit(_conn: object) -> None:
|
||||
srv._stop.set()
|
||||
srv.stop()
|
||||
|
||||
with patch.object(srv, "_admit", side_effect=admit) as admitted:
|
||||
srv._serve()
|
||||
with patch.object(srv, "admit", side_effect=admit) as admitted:
|
||||
srv.serve()
|
||||
admitted.assert_called_once_with(conn)
|
||||
|
||||
|
||||
@ -90,35 +96,39 @@ class TestAdmit:
|
||||
"""Authentication and the connection cap."""
|
||||
|
||||
def test_peercred_failure_closes(self) -> None:
|
||||
"""A connection whose peer uid cannot be read is closed."""
|
||||
conn = MagicMock()
|
||||
with patch.object(_ctl_server, "peer_uid", side_effect=OSError("no")):
|
||||
_server()._admit(conn)
|
||||
_server().admit(conn)
|
||||
conn.close.assert_called_once()
|
||||
|
||||
def test_foreign_uid_is_closed_unanswered(self) -> None:
|
||||
"""A foreign uid is closed without any reply."""
|
||||
conn = MagicMock()
|
||||
with patch.object(_ctl_server, "peer_uid", return_value=31337):
|
||||
_server()._admit(conn)
|
||||
_server().admit(conn)
|
||||
conn.close.assert_called_once()
|
||||
conn.sendall.assert_not_called()
|
||||
|
||||
def test_no_free_slot_drops(self) -> None:
|
||||
"""A connection is dropped when every slot is taken."""
|
||||
srv = _server()
|
||||
srv._slots = threading.BoundedSemaphore(1)
|
||||
srv._slots.acquire()
|
||||
srv.slots = threading.BoundedSemaphore(1)
|
||||
assert srv.take_slot()
|
||||
conn = MagicMock()
|
||||
with patch.object(_ctl_server, "peer_uid", return_value=0):
|
||||
srv._admit(conn)
|
||||
srv.admit(conn)
|
||||
conn.close.assert_called_once()
|
||||
|
||||
def test_allowed_uid_gets_a_worker(self) -> None:
|
||||
"""An allowed uid is served on a worker thread."""
|
||||
srv = _server()
|
||||
conn = MagicMock()
|
||||
with (
|
||||
patch.object(_ctl_server, "peer_uid", return_value=0),
|
||||
patch.object(_ctl_server.threading, "Thread") as thread,
|
||||
):
|
||||
srv._admit(conn)
|
||||
srv.admit(conn)
|
||||
thread.return_value.start.assert_called_once()
|
||||
assert thread.call_args.kwargs["args"] == (conn, 0)
|
||||
|
||||
@ -127,60 +137,67 @@ class TestHandle:
|
||||
"""A connection handler always releases its slot and socket."""
|
||||
|
||||
def test_writes_dispatch_result(self) -> None:
|
||||
"""The handler writes the dispatched reply and closes."""
|
||||
srv = _server()
|
||||
conn = MagicMock()
|
||||
with (
|
||||
patch.object(_ctl_server, "read_line", return_value=b"line"),
|
||||
patch.object(srv, "_dispatch", return_value=b"out") as dispatch,
|
||||
patch.object(srv, "dispatch", return_value=b"out") as dispatch,
|
||||
):
|
||||
srv._slots.acquire()
|
||||
srv._handle(conn, 7)
|
||||
assert srv.take_slot()
|
||||
srv.handle(conn, 7)
|
||||
dispatch.assert_called_once_with(b"line", 7)
|
||||
conn.sendall.assert_called_once_with(b"out")
|
||||
conn.close.assert_called_once()
|
||||
|
||||
def test_silent_client_writes_nothing(self) -> None:
|
||||
"""A client that sends no line gets no reply."""
|
||||
srv = _server()
|
||||
conn = MagicMock()
|
||||
srv._slots.acquire()
|
||||
assert srv.take_slot()
|
||||
with patch.object(_ctl_server, "read_line", return_value=None):
|
||||
srv._handle(conn, 7)
|
||||
srv.handle(conn, 7)
|
||||
conn.sendall.assert_not_called()
|
||||
conn.close.assert_called_once()
|
||||
|
||||
def test_write_failure_is_swallowed(self) -> None:
|
||||
"""A failed write is swallowed and the slot is returned."""
|
||||
srv = _server()
|
||||
conn = MagicMock()
|
||||
conn.sendall.side_effect = BrokenPipeError
|
||||
srv._slots.acquire()
|
||||
assert srv.take_slot()
|
||||
with (
|
||||
patch.object(_ctl_server, "read_line", return_value=b"x"),
|
||||
patch.object(srv, "_dispatch", return_value=b"out"),
|
||||
patch.object(srv, "dispatch", return_value=b"out"),
|
||||
):
|
||||
srv._handle(conn, 7)
|
||||
srv.handle(conn, 7)
|
||||
conn.close.assert_called_once()
|
||||
# The slot came back: the semaphore can be filled to its cap again.
|
||||
for _ in range(_ctl_server.MAX_CONNECTIONS):
|
||||
assert srv._slots.acquire(blocking=False)
|
||||
assert srv.take_slot()
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
"""_dispatch never raises."""
|
||||
|
||||
def test_ok(self) -> None:
|
||||
"""A valid ping answers ok."""
|
||||
srv = _server()
|
||||
assert b'"ok":true' in srv._dispatch(b'{"op":"ping"}', 0)
|
||||
assert b'"ok":true' in srv.dispatch(b'{"op":"ping"}', 0)
|
||||
|
||||
def test_ctl_error(self) -> None:
|
||||
"""A CtlError becomes its error code in the reply."""
|
||||
srv = _server()
|
||||
with patch.object(srv, "_run", side_effect=CtlError("locked", "x")):
|
||||
assert b'"error":"locked"' in srv._dispatch(b"", 0)
|
||||
with patch.object(srv, "run", side_effect=CtlError("locked", "x")):
|
||||
assert b'"error":"locked"' in srv.dispatch(b"", 0)
|
||||
|
||||
def test_unexpected_error(self) -> None:
|
||||
"""An unexpected exception becomes op_failed."""
|
||||
srv = _server()
|
||||
with patch.object(srv, "_run", side_effect=RuntimeError("x")):
|
||||
assert b'"error":"op_failed"' in srv._dispatch(b"", 0)
|
||||
with patch.object(srv, "run", side_effect=RuntimeError("x")):
|
||||
assert b'"error":"op_failed"' in srv.dispatch(b"", 0)
|
||||
|
||||
def test_run_rejects_unknown(self) -> None:
|
||||
"""An unknown op raises CtlError."""
|
||||
with pytest.raises(CtlError, match="unknown op"):
|
||||
_server()._run(b'{"op":"zzz"}', 0)
|
||||
_server().run(b'{"op":"zzz"}', 0)
|
||||
|
||||
@ -10,21 +10,16 @@ from steam_backlog_enforcer._desktop_env import (
|
||||
)
|
||||
from steam_backlog_enforcer._desktop_env import desktop_uid as _desktop_uid
|
||||
from steam_backlog_enforcer._steam_process import _run_as_user
|
||||
from steam_backlog_enforcer.tests._desktop_launch_patches import (
|
||||
DESKTOP_ENV_PKG,
|
||||
STEAM_PROCESS_PKG,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._desktop_launch_patches import (
|
||||
env_value as _env_value,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.library_hider"
|
||||
DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env"
|
||||
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
|
||||
STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process"
|
||||
ENV_PKG = "steam_backlog_enforcer._desktop_env"
|
||||
|
||||
|
||||
def _env_value(args: list[str], name: str) -> str | None:
|
||||
"""Return the value assigned to *name* in a list of ``VAR=value`` args."""
|
||||
prefix = f"{name}="
|
||||
for arg in args:
|
||||
if arg.startswith(prefix):
|
||||
return arg[len(prefix) :]
|
||||
return None
|
||||
ENV_PKG = DESKTOP_ENV_PKG
|
||||
|
||||
|
||||
class TestDesktopEnvArgs:
|
||||
@ -161,14 +156,17 @@ class TestDesktopUid:
|
||||
"""Tests for _desktop_uid."""
|
||||
|
||||
def test_resolves_known_user(self) -> None:
|
||||
"""A known desktop user resolves to their uid."""
|
||||
mock_pw = MagicMock()
|
||||
mock_pw.pw_uid = 1234
|
||||
with patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", return_value=mock_pw):
|
||||
assert _desktop_uid("bob") == 1234
|
||||
|
||||
def test_unknown_user_falls_back(self) -> None:
|
||||
"""An unknown user falls back to uid 1000."""
|
||||
with patch(f"{DESKTOP_ENV_PKG}.pwd.getpwnam", side_effect=KeyError):
|
||||
assert _desktop_uid("ghost") == 1000
|
||||
|
||||
def test_no_user_falls_back(self) -> None:
|
||||
"""No user name falls back to uid 1000."""
|
||||
assert _desktop_uid(None) == 1000
|
||||
|
||||
@ -15,35 +15,31 @@ from steam_backlog_enforcer._desktop_env import (
|
||||
)
|
||||
from steam_backlog_enforcer._steam_errors import DesktopSessionNotReadyError
|
||||
from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port
|
||||
from steam_backlog_enforcer.tests._desktop_launch_patches import (
|
||||
DESKTOP_ENV_PKG,
|
||||
STEAM_PROCESS_PKG,
|
||||
fresh_launch_patches,
|
||||
launch_patches,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.library_hider"
|
||||
DESKTOP_ENV_PKG = "steam_backlog_enforcer._desktop_env"
|
||||
STEAM_LAUNCH_PKG = "steam_backlog_enforcer._steam_launch"
|
||||
STEAM_PROCESS_PKG = "steam_backlog_enforcer._steam_process"
|
||||
ENV_PKG = "steam_backlog_enforcer._desktop_env"
|
||||
|
||||
|
||||
def _env_value(args: list[str], name: str) -> str | None:
|
||||
"""Return the value assigned to *name* in a list of ``VAR=value`` args."""
|
||||
prefix = f"{name}="
|
||||
for arg in args:
|
||||
if arg.startswith(prefix):
|
||||
return arg[len(prefix) :]
|
||||
return None
|
||||
ENV_PKG = DESKTOP_ENV_PKG
|
||||
|
||||
|
||||
class TestDesktopSessionReady:
|
||||
"""Tests for desktop_session_ready."""
|
||||
|
||||
def test_ready_when_runtime_dir_exists(self) -> None:
|
||||
"""An existing /run/user/<uid> means the session is ready."""
|
||||
with patch(f"{ENV_PKG}.Path.is_dir", return_value=True):
|
||||
assert desktop_session_ready(1000) is True
|
||||
|
||||
def test_not_ready_when_runtime_dir_missing(self) -> None:
|
||||
"""A missing runtime dir means the session is not ready."""
|
||||
with patch(f"{ENV_PKG}.Path.is_dir", return_value=False):
|
||||
assert desktop_session_ready(1000) is False
|
||||
|
||||
def test_runtime_dir_path(self) -> None:
|
||||
"""The runtime dir is /run/user/<uid>."""
|
||||
assert desktop_runtime_dir(1000) == "/run/user/1000"
|
||||
|
||||
|
||||
@ -58,104 +54,51 @@ class TestLaunchDefersUntilSessionReady:
|
||||
"""
|
||||
|
||||
def test_defers_when_runtime_dir_missing(self) -> None:
|
||||
"""Steam is not launched while the runtime dir is missing."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch.steam_is_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._resolve_desktop_user",
|
||||
return_value="bob",
|
||||
),
|
||||
patch(f"{DESKTOP_ENV_PKG}.desktop_uid", return_value=1000),
|
||||
patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=False),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._shutdown_steam"
|
||||
) as mock_shutdown,
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug"
|
||||
) as mock_launch,
|
||||
launch_patches(
|
||||
"_shutdown_steam",
|
||||
"_launch_steam_with_debug",
|
||||
extra={
|
||||
f"{STEAM_PROCESS_PKG}.os.geteuid": 0,
|
||||
f"{DESKTOP_ENV_PKG}.desktop_uid": 1000,
|
||||
},
|
||||
_steam_has_debug_port=False,
|
||||
steam_is_installed=True,
|
||||
_resolve_desktop_user="bob",
|
||||
desktop_session_ready=False,
|
||||
) as mocks,
|
||||
pytest.raises(DesktopSessionNotReadyError),
|
||||
):
|
||||
ensure_steam_debug_port()
|
||||
|
||||
# Neither bounce a working Steam nor start a broken one.
|
||||
mock_launch.assert_not_called()
|
||||
mock_shutdown.assert_not_called()
|
||||
mocks["_launch_steam_with_debug"].assert_not_called()
|
||||
mocks["_shutdown_steam"].assert_not_called()
|
||||
|
||||
def test_launches_when_runtime_dir_present(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch.steam_is_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._resolve_desktop_user",
|
||||
return_value="bob",
|
||||
),
|
||||
patch(f"{DESKTOP_ENV_PKG}.desktop_uid", return_value=1000),
|
||||
patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=True),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._is_steam_running",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug"
|
||||
) as mock_launch,
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
"""Steam is launched once the runtime dir exists."""
|
||||
with fresh_launch_patches(
|
||||
extra={
|
||||
f"{STEAM_PROCESS_PKG}.os.geteuid": 0,
|
||||
f"{DESKTOP_ENV_PKG}.desktop_uid": 1000,
|
||||
},
|
||||
steam_is_installed=True,
|
||||
_resolve_desktop_user="bob",
|
||||
desktop_session_ready=True,
|
||||
_is_steam_running=False,
|
||||
) as mocks:
|
||||
ensure_steam_debug_port()
|
||||
mock_launch.assert_called_once()
|
||||
mocks["_launch_steam_with_debug"].assert_called_once()
|
||||
|
||||
def test_non_root_does_not_gate(self) -> None:
|
||||
"""Run interactively, the ambient session is already the user's."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch.steam_is_installed",
|
||||
return_value=True,
|
||||
),
|
||||
patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._resolve_desktop_user",
|
||||
return_value="bob",
|
||||
),
|
||||
patch(f"{STEAM_LAUNCH_PKG}.desktop_session_ready", return_value=False),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._is_steam_running",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug"
|
||||
) as mock_launch,
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
with fresh_launch_patches(
|
||||
extra={f"{STEAM_PROCESS_PKG}.os.geteuid": 1000},
|
||||
steam_is_installed=True,
|
||||
_resolve_desktop_user="bob",
|
||||
desktop_session_ready=False,
|
||||
_is_steam_running=False,
|
||||
) as mocks:
|
||||
ensure_steam_debug_port()
|
||||
mock_launch.assert_called_once()
|
||||
mocks["_launch_steam_with_debug"].assert_called_once()
|
||||
|
||||
@ -13,17 +13,12 @@ from steam_backlog_enforcer._desktop_env import (
|
||||
desktop_user_cmd,
|
||||
resolve_desktop_user,
|
||||
)
|
||||
|
||||
ENV_PKG = "steam_backlog_enforcer._desktop_env"
|
||||
|
||||
|
||||
def _env_value(args: list[str], name: str) -> str | None:
|
||||
"""Return the value assigned to *name* in a list of ``VAR=value`` args."""
|
||||
prefix = f"{name}="
|
||||
for arg in args:
|
||||
if arg.startswith(prefix):
|
||||
return arg[len(prefix) :]
|
||||
return None
|
||||
from steam_backlog_enforcer.tests._desktop_launch_patches import (
|
||||
DESKTOP_ENV_PKG as ENV_PKG,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._desktop_launch_patches import (
|
||||
env_value as _env_value,
|
||||
)
|
||||
|
||||
|
||||
class TestResolveDesktopUser:
|
||||
@ -42,6 +37,7 @@ class TestResolveDesktopUser:
|
||||
assert resolve_desktop_user() == "kuhy"
|
||||
|
||||
def test_falls_back_to_sudo_user(self) -> None:
|
||||
"""Without the enforcer variable, SUDO_USER wins over USER."""
|
||||
env = {
|
||||
k: v for k, v in os.environ.items() if k != "STEAM_ENFORCER_DESKTOP_USER"
|
||||
}
|
||||
@ -50,6 +46,7 @@ class TestResolveDesktopUser:
|
||||
assert resolve_desktop_user() == "alice"
|
||||
|
||||
def test_falls_back_to_user(self) -> None:
|
||||
"""Without the enforcer variable or SUDO_USER, USER is used."""
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
@ -60,6 +57,7 @@ class TestResolveDesktopUser:
|
||||
assert resolve_desktop_user() == "bob"
|
||||
|
||||
def test_returns_none_when_unset(self) -> None:
|
||||
"""With none of the variables set, there is no desktop user."""
|
||||
env = {
|
||||
k: v
|
||||
for k, v in os.environ.items()
|
||||
@ -73,10 +71,12 @@ class TestDesktopUserCmd:
|
||||
"""Tests for desktop_user_cmd — all four branches."""
|
||||
|
||||
def test_non_root_returns_cmd_unchanged(self) -> None:
|
||||
"""A non-root caller runs the command as is."""
|
||||
with patch(f"{ENV_PKG}.os.geteuid", return_value=1000):
|
||||
assert desktop_user_cmd(["steam"], "kuhy") == ["steam"]
|
||||
|
||||
def test_root_with_user_wraps_in_sudo(self) -> None:
|
||||
"""Root with a desktop user wraps the command in sudo with the user env."""
|
||||
with (
|
||||
patch(f"{ENV_PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{ENV_PKG}.desktop_uid", return_value=1000),
|
||||
@ -92,6 +92,7 @@ class TestDesktopUserCmd:
|
||||
assert desktop_user_cmd(["steam"], None) == ["steam"]
|
||||
|
||||
def test_root_with_root_user_returns_cmd_unchanged(self) -> None:
|
||||
"""A desktop user of root leaves the command unwrapped."""
|
||||
with patch(f"{ENV_PKG}.os.geteuid", return_value=0):
|
||||
assert desktop_user_cmd(["steam"], "root") == ["steam"]
|
||||
|
||||
@ -100,6 +101,7 @@ class TestDesktopUid:
|
||||
"""Tests for desktop_uid's lookup and fallbacks."""
|
||||
|
||||
def test_known_user(self) -> None:
|
||||
"""A known user resolves to its passwd uid."""
|
||||
with patch(f"{ENV_PKG}.pwd.getpwnam") as mock_getpwnam:
|
||||
mock_getpwnam.return_value.pw_uid = 1234
|
||||
assert desktop_uid("kuhy") == 1234
|
||||
@ -110,4 +112,5 @@ class TestDesktopUid:
|
||||
assert desktop_uid("nosuchuser") == 1000
|
||||
|
||||
def test_none_falls_back(self) -> None:
|
||||
"""No user name falls back to uid 1000."""
|
||||
assert desktop_uid(None) == 1000
|
||||
|
||||
@ -5,6 +5,7 @@ Split to keep every test file under the 250-line cap.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
@ -15,6 +16,7 @@ from steam_backlog_enforcer._enforce_loop import (
|
||||
do_enforce,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.tests._enforce_loop_once import one_iteration
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -46,18 +48,10 @@ class TestDoEnforce:
|
||||
but not yet rescanned.
|
||||
"""
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
) as mock_iter,
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
):
|
||||
with one_iteration(state) as mocks:
|
||||
do_enforce(Config(), state)
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
mock_iter.assert_called_once()
|
||||
assert any("No game" in str(c) for c in mocks.echo.call_args_list)
|
||||
mocks.iteration.assert_called_once()
|
||||
|
||||
def test_steam_absent_idles_without_setup(self) -> None:
|
||||
"""With Steam gone, say so and keep looping - never exit.
|
||||
@ -71,36 +65,23 @@ class TestDoEnforce:
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.steam_is_installed", return_value=False),
|
||||
patch(f"{PKG}._enforce_setup") as mock_setup,
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
),
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
one_iteration(state) as mocks,
|
||||
):
|
||||
do_enforce(Config(), state)
|
||||
|
||||
mock_setup.assert_not_called()
|
||||
assert any("not installed" in str(c) for c in mock_echo.call_args_list)
|
||||
assert any("not installed" in str(c) for c in mocks.echo.call_args_list)
|
||||
|
||||
def test_keyboard_interrupt(self) -> None:
|
||||
"""A KeyboardInterrupt from an iteration ends the loop cleanly."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config()
|
||||
fresh = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}._enforce_setup"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "load", return_value=fresh),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
),
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
):
|
||||
with patch(f"{PKG}._enforce_setup"), one_iteration(fresh):
|
||||
do_enforce(config, state)
|
||||
|
||||
def test_runs_iterations(self) -> None:
|
||||
"""The loop keeps iterating until interrupted."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config()
|
||||
fresh = State(current_app_id=1, current_game_name="G")
|
||||
@ -127,8 +108,6 @@ class TestDoEnforce:
|
||||
|
||||
def test_state_load_failure_continues(self) -> None:
|
||||
"""Corrupt state file should not crash the daemon."""
|
||||
import json as json_mod
|
||||
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config()
|
||||
call_count = 0
|
||||
@ -138,7 +117,7 @@ class TestDoEnforce:
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
msg = "bad"
|
||||
raise json_mod.JSONDecodeError(msg, "", 0)
|
||||
raise json.JSONDecodeError(msg, "", 0)
|
||||
if call_count == 2:
|
||||
raise KeyboardInterrupt
|
||||
return State(current_app_id=1) # pragma: no cover
|
||||
@ -158,6 +137,7 @@ class TestStateReload:
|
||||
"""The loop must pick up CLI/MCP state changes without a restart."""
|
||||
|
||||
def test_reloads_manual_picks_from_disk(self) -> None:
|
||||
"""Each iteration reloads manual picks and finished ids from disk."""
|
||||
# The MCP pick_manual tool adds a *second* pick without touching
|
||||
# current_app_id. A daemon that never reloaded manual_picks would drop
|
||||
# it out of the allowed set and uninstall it as unauthorized.
|
||||
@ -168,16 +148,7 @@ class TestStateReload:
|
||||
finished_app_ids=[7],
|
||||
manual_picks=[{"app_id": 900, "game_name": "AddedByMcp", "started_at": ""}],
|
||||
)
|
||||
with (
|
||||
patch(f"{PKG}._enforce_setup"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "load", return_value=fresh),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
),
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
):
|
||||
with patch(f"{PKG}._enforce_setup"), one_iteration(fresh):
|
||||
do_enforce(Config(), state)
|
||||
assert state.manual_picks == fresh.manual_picks
|
||||
assert state.finished_app_ids == [7]
|
||||
@ -187,6 +158,7 @@ class TestRestartRequested:
|
||||
"""A control-socket ``restart`` op ends the loop cleanly."""
|
||||
|
||||
def test_flush_then_exit_without_waiting(self) -> None:
|
||||
"""A restart request flushes state and exits without a wake wait."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
control = MagicMock()
|
||||
control.tick_lock = threading.Lock()
|
||||
|
||||
@ -20,7 +20,10 @@ def _block(days: float = 2.0) -> SimpleNamespace:
|
||||
|
||||
|
||||
class TestRunDemo:
|
||||
"""run_demo ticks only the demo budget and yields to a total block."""
|
||||
|
||||
def test_refuses_to_start_under_a_total_block(self) -> None:
|
||||
"""An active total block refuses the demo without ticking."""
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_block(2.0)),
|
||||
@ -34,6 +37,7 @@ class TestRunDemo:
|
||||
)
|
||||
|
||||
def test_ticks_only_the_demo_budget_until_interrupted(self) -> None:
|
||||
"""The demo ticks its own session until Ctrl-C."""
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.new_session", return_value="session") as session,
|
||||
@ -52,6 +56,7 @@ class TestRunDemo:
|
||||
assert "Demo stopped." in out
|
||||
|
||||
def test_stops_loudly_when_a_total_block_starts_mid_run(self) -> None:
|
||||
"""A total block starting mid-run stops the demo with a message."""
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", side_effect=[False, False, True]),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_block(1.0)),
|
||||
|
||||
@ -2,23 +2,12 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer._owned_apps_cache import (
|
||||
_load_owned_app_ids_cache,
|
||||
_save_owned_app_ids_cache,
|
||||
get_all_owned_app_ids,
|
||||
)
|
||||
from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer.config import Config
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
PKG = "steam_backlog_enforcer._enforce_loop"
|
||||
# The owned-ids cache reads its own clock; _enforce_loop no longer imports time.
|
||||
_CLOCK = "steam_backlog_enforcer._owned_apps_cache.time.time"
|
||||
ENFORCE_STEPS_PKG = "steam_backlog_enforcer._enforce_steps"
|
||||
OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache"
|
||||
|
||||
@ -27,6 +16,7 @@ class TestGetAllOwnedAppIds:
|
||||
"""Tests for get_all_owned_app_ids."""
|
||||
|
||||
def test_snapshot_used_when_api_fails(self) -> None:
|
||||
"""The library snapshot answers when the Steam API is down."""
|
||||
snap = [{"app_id": 1}, {"app_id": 2}]
|
||||
with (
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=snap),
|
||||
@ -40,6 +30,7 @@ class TestGetAllOwnedAppIds:
|
||||
assert get_all_owned_app_ids(Config()) == [1, 2]
|
||||
|
||||
def test_no_snapshot_falls_back_to_api(self) -> None:
|
||||
"""Without a snapshot the Steam API supplies the ids."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_owned_games.return_value = [
|
||||
{"appid": 10},
|
||||
@ -58,6 +49,7 @@ class TestGetAllOwnedAppIds:
|
||||
assert result == [10, 20]
|
||||
|
||||
def test_api_fails(self) -> None:
|
||||
"""No snapshot and a failing API give an empty list."""
|
||||
with (
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=None),
|
||||
patch(
|
||||
@ -71,6 +63,7 @@ class TestGetAllOwnedAppIds:
|
||||
assert get_all_owned_app_ids(Config()) == []
|
||||
|
||||
def test_empty_snapshot_falls_through_to_api(self) -> None:
|
||||
"""An empty snapshot still asks the Steam API."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_owned_games.return_value = [{"appid": 5}]
|
||||
with (
|
||||
@ -83,6 +76,7 @@ class TestGetAllOwnedAppIds:
|
||||
assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5]
|
||||
|
||||
def test_merges_snapshot_with_api_results(self) -> None:
|
||||
"""API and snapshot ids are merged without duplicates."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}]
|
||||
with (
|
||||
@ -102,6 +96,7 @@ class TestGetAllOwnedAppIds:
|
||||
]
|
||||
|
||||
def test_uses_owned_ids_cache_without_api_call(self) -> None:
|
||||
"""A fresh owned-ids cache skips the API and merges the snapshot."""
|
||||
with (
|
||||
patch(
|
||||
f"{OWNED_APPS_CACHE_PKG}.load_snapshot", return_value=[{"app_id": 30}]
|
||||
@ -118,6 +113,7 @@ class TestGetAllOwnedAppIds:
|
||||
mock_client.assert_not_called()
|
||||
|
||||
def test_cached_ids_merge_deduplicates_entries(self) -> None:
|
||||
"""Duplicate ids from the cache and snapshot collapse to one."""
|
||||
with (
|
||||
patch(
|
||||
f"{OWNED_APPS_CACHE_PKG}.load_snapshot",
|
||||
@ -135,6 +131,7 @@ class TestGetAllOwnedAppIds:
|
||||
mock_client.assert_not_called()
|
||||
|
||||
def test_api_success_saves_owned_ids_cache(self) -> None:
|
||||
"""A successful API fetch is written to the owned-ids cache."""
|
||||
mock_client = MagicMock()
|
||||
mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}]
|
||||
with (
|
||||
@ -149,91 +146,3 @@ class TestGetAllOwnedAppIds:
|
||||
|
||||
assert result == [10, 20]
|
||||
mock_save.assert_called_once_with("i", [10, 20], [{"appid": 10}, {"appid": 20}])
|
||||
|
||||
|
||||
class TestOwnedIdsCacheHelpers:
|
||||
"""Tests for owned app IDs cache helper functions."""
|
||||
|
||||
def test_load_cache_no_steam_id(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"
|
||||
):
|
||||
assert _load_owned_app_ids_cache("") is None
|
||||
|
||||
def test_load_cache_missing_file(self, tmp_path: Path) -> None:
|
||||
with patch(
|
||||
f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"
|
||||
):
|
||||
assert _load_owned_app_ids_cache("sid") is None
|
||||
|
||||
def test_load_cache_invalid_json(self, tmp_path: Path) -> None:
|
||||
cache_file = tmp_path / "owned.json"
|
||||
cache_file.write_text("{invalid", encoding="utf-8")
|
||||
with patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file):
|
||||
assert _load_owned_app_ids_cache("sid") is None
|
||||
|
||||
def test_load_cache_wrong_steam_id(self, tmp_path: Path) -> None:
|
||||
cache_file = tmp_path / "owned.json"
|
||||
cache_file.write_text(
|
||||
json.dumps({"steam_id": "other", "fetched_at": 1e12, "app_ids": [1]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file):
|
||||
assert _load_owned_app_ids_cache("sid") is None
|
||||
|
||||
def test_load_cache_stale(self, tmp_path: Path) -> None:
|
||||
cache_file = tmp_path / "owned.json"
|
||||
cache_file.write_text(
|
||||
json.dumps({"steam_id": "sid", "fetched_at": 0, "app_ids": [1]}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
|
||||
patch(_CLOCK, return_value=10_000.0),
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
|
||||
):
|
||||
assert _load_owned_app_ids_cache("sid") is None
|
||||
|
||||
def test_load_cache_non_list_ids(self, tmp_path: Path) -> None:
|
||||
cache_file = tmp_path / "owned.json"
|
||||
cache_file.write_text(
|
||||
json.dumps({"steam_id": "sid", "fetched_at": 10_000.0, "app_ids": 1}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
|
||||
patch(_CLOCK, return_value=10_010.0),
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
|
||||
):
|
||||
assert _load_owned_app_ids_cache("sid") is None
|
||||
|
||||
def test_load_cache_valid(self, tmp_path: Path) -> None:
|
||||
cache_file = tmp_path / "owned.json"
|
||||
cache_file.write_text(
|
||||
json.dumps(
|
||||
{"steam_id": "sid", "fetched_at": 10_000.0, "app_ids": ["1", 2]}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
with (
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
|
||||
patch(_CLOCK, return_value=10_010.0),
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
|
||||
):
|
||||
assert _load_owned_app_ids_cache("sid") == [1, 2]
|
||||
|
||||
def test_save_cache_writes_atomic_payload(self, tmp_path: Path) -> None:
|
||||
cache_file = tmp_path / "owned.json"
|
||||
with (
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._OWNED_IDS_CACHE_FILE", cache_file),
|
||||
patch(_CLOCK, return_value=123.0),
|
||||
patch(f"{OWNED_APPS_CACHE_PKG}._atomic_write") as mock_atomic,
|
||||
):
|
||||
_save_owned_app_ids_cache("sid", [10, 20])
|
||||
|
||||
mock_atomic.assert_called_once()
|
||||
path_arg = mock_atomic.call_args.args[0]
|
||||
payload_arg = mock_atomic.call_args.args[1]
|
||||
assert path_arg == cache_file
|
||||
assert '"steam_id": "sid"' in payload_arg
|
||||
assert '"app_ids": [\n 10,\n 20\n ]' in payload_arg
|
||||
|
||||
@ -35,6 +35,7 @@ class TestEnforceLoopIteration:
|
||||
yield
|
||||
|
||||
def test_kills_unauthorized(self) -> None:
|
||||
"""With killing on, the iteration runs the allowed-game enforcement."""
|
||||
config = Config(
|
||||
kill_unauthorized_games=True,
|
||||
uninstall_other_games=False,
|
||||
@ -52,6 +53,7 @@ class TestEnforceLoopIteration:
|
||||
_enforce_loop_iteration(config, state, session=fake_session())
|
||||
|
||||
def test_no_kill(self) -> None:
|
||||
"""With killing off, the allowed-game enforcement is never called."""
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=False,
|
||||
@ -65,6 +67,7 @@ class TestEnforceLoopIteration:
|
||||
mock_enforce.assert_not_called()
|
||||
|
||||
def test_guards_installed(self) -> None:
|
||||
"""With uninstalling on, the iteration guards installed games."""
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=True,
|
||||
@ -78,6 +81,7 @@ class TestEnforceLoopIteration:
|
||||
_enforce_loop_iteration(config, state, session=fake_session())
|
||||
|
||||
def test_guard_removes_zero(self) -> None:
|
||||
"""A guard pass that removes nothing still completes the iteration."""
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=True,
|
||||
@ -115,6 +119,7 @@ class TestEnforceLoopIteration:
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_reinstalls_missing(self) -> None:
|
||||
"""A missing assigned game is reinstalled."""
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=False,
|
||||
@ -128,6 +133,7 @@ class TestEnforceLoopIteration:
|
||||
mock_install.assert_called_once()
|
||||
|
||||
def test_no_app_id_skip_reinstall(self) -> None:
|
||||
"""With no assigned game, enforcing, guarding and reinstalling are skipped."""
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=False,
|
||||
|
||||
@ -9,6 +9,7 @@ from steam_backlog_enforcer._enforce_loop import (
|
||||
do_enforce,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.tests._enforce_loop_once import one_iteration
|
||||
from steam_backlog_enforcer.tests._fake_session import fake_session
|
||||
|
||||
PKG = "steam_backlog_enforcer._enforce_loop"
|
||||
@ -19,6 +20,7 @@ class TestEnforceLoopIterationTotalBlock:
|
||||
"""Total-block short-circuit at the top of _enforce_loop_iteration."""
|
||||
|
||||
def test_active_short_circuits_before_assigned_game_logic(self) -> None:
|
||||
"""An active total block ticks the block and skips all assigned-game guards."""
|
||||
config = Config(kill_unauthorized_games=True, uninstall_other_games=True)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -35,6 +37,7 @@ class TestEnforceLoopIterationTotalBlock:
|
||||
mock_installed.assert_not_called()
|
||||
|
||||
def test_not_active_runs_normal_logic(self) -> None:
|
||||
"""Without a total block the block tick is not called."""
|
||||
config = Config(kill_unauthorized_games=False, uninstall_other_games=False)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -47,6 +50,7 @@ class TestEnforceLoopIterationTotalBlock:
|
||||
mock_tick.assert_not_called()
|
||||
|
||||
def test_expired_lock_triggers_cleanup_once(self) -> None:
|
||||
"""An expired block that needs cleanup is cleaned up exactly once."""
|
||||
config = Config(kill_unauthorized_games=False, uninstall_other_games=False)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -59,6 +63,7 @@ class TestEnforceLoopIterationTotalBlock:
|
||||
mock_cleanup.assert_called_once()
|
||||
|
||||
def test_no_lock_no_cleanup_call(self) -> None:
|
||||
"""No block and nothing to clean means no cleanup call."""
|
||||
config = Config(kill_unauthorized_games=False, uninstall_other_games=False)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -75,18 +80,13 @@ class TestDoEnforceTotalBlock:
|
||||
"""Total-block awareness in do_enforce's one-time setup."""
|
||||
|
||||
def test_active_skips_enforce_setup_but_still_loops(self) -> None:
|
||||
"""Under a total block do_enforce skips setup but still runs the loop."""
|
||||
state = State() # no assigned game
|
||||
config = Config()
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}._enforce_setup") as mock_setup,
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
),
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
one_iteration(state),
|
||||
):
|
||||
do_enforce(config, state)
|
||||
mock_setup.assert_not_called()
|
||||
@ -98,16 +98,10 @@ class TestDoEnforceTotalBlock:
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}._enforce_setup"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
) as mock_iter,
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
one_iteration(state) as mocks,
|
||||
):
|
||||
do_enforce(config, state)
|
||||
mock_iter.assert_called_once()
|
||||
mocks.iteration.assert_called_once()
|
||||
|
||||
def test_inactive_with_no_game_still_enters_the_loop(self) -> None:
|
||||
"""No assigned game must NOT exit the process.
|
||||
@ -119,19 +113,13 @@ class TestDoEnforceTotalBlock:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
) as mock_iter,
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
one_iteration(state) as mocks,
|
||||
):
|
||||
do_enforce(Config(), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
output = " ".join(str(c) for c in mocks.echo.call_args_list)
|
||||
assert "No game" in output
|
||||
assert "budget is still enforced" in output
|
||||
mock_iter.assert_called_once()
|
||||
mocks.iteration.assert_called_once()
|
||||
|
||||
|
||||
class TestPlaytimeIsEnforcedFirst:
|
||||
@ -150,6 +138,7 @@ class TestPlaytimeIsEnforcedFirst:
|
||||
mock_playtime.assert_called_once()
|
||||
|
||||
def test_receives_the_loop_interval(self) -> None:
|
||||
"""playtime_tick gets the 3 s loop interval and demo off by default."""
|
||||
config = Config()
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -162,6 +151,7 @@ class TestPlaytimeIsEnforcedFirst:
|
||||
assert mock_playtime.call_args.kwargs["demo"] is False
|
||||
|
||||
def test_demo_flag_is_plumbed_through(self) -> None:
|
||||
"""The demo flag reaches playtime_tick."""
|
||||
config = Config()
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -173,18 +163,13 @@ class TestPlaytimeIsEnforcedFirst:
|
||||
assert mock_playtime.call_args.kwargs["demo"] is True
|
||||
|
||||
def test_do_enforce_plumbs_demo_into_the_iteration(self) -> None:
|
||||
"""do_enforce passes its demo flag into each loop iteration."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.steam_is_installed", return_value=True),
|
||||
patch(f"{PKG}._enforce_setup"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch(
|
||||
f"{PKG}._enforce_loop_iteration",
|
||||
side_effect=KeyboardInterrupt,
|
||||
) as mock_iter,
|
||||
patch(f"{PKG}.open_file_wake"),
|
||||
one_iteration(state) as mocks,
|
||||
):
|
||||
do_enforce(Config(), state, demo=True)
|
||||
assert mock_iter.call_args.kwargs["demo"] is True
|
||||
assert mocks.iteration.call_args.kwargs["demo"] is True
|
||||
|
||||
@ -25,6 +25,7 @@ class TestGuardInstalledGames:
|
||||
"""Tests for guard_installed_games."""
|
||||
|
||||
def test_removes_unauthorized(self) -> None:
|
||||
"""An installed game outside the allowed set is uninstalled."""
|
||||
with (
|
||||
patch(
|
||||
f"{GUARDS_PKG}.get_installed_games",
|
||||
@ -36,6 +37,7 @@ class TestGuardInstalledGames:
|
||||
assert guard_installed_games({440}) == 1
|
||||
|
||||
def test_skips_allowed(self) -> None:
|
||||
"""An allowed installed game is left alone."""
|
||||
with patch(
|
||||
f"{GUARDS_PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2")],
|
||||
@ -43,6 +45,7 @@ class TestGuardInstalledGames:
|
||||
assert guard_installed_games({440}) == 0
|
||||
|
||||
def test_skips_protected(self) -> None:
|
||||
"""A protected app such as a runtime is never uninstalled."""
|
||||
with (
|
||||
patch(
|
||||
f"{GUARDS_PKG}.get_installed_games",
|
||||
@ -55,6 +58,7 @@ class TestGuardInstalledGames:
|
||||
assert guard_installed_games({440}) == 0
|
||||
|
||||
def test_uninstall_fails(self) -> None:
|
||||
"""A failed uninstall is not counted as removed."""
|
||||
with (
|
||||
patch(
|
||||
f"{GUARDS_PKG}.get_installed_games",
|
||||
@ -65,6 +69,7 @@ class TestGuardInstalledGames:
|
||||
assert guard_installed_games({440}) == 0
|
||||
|
||||
def test_allowed_none_skips(self) -> None:
|
||||
"""An empty allowed set skips the guard entirely."""
|
||||
assert guard_installed_games(set()) == 0
|
||||
|
||||
|
||||
@ -72,6 +77,7 @@ class TestEnforceSetup:
|
||||
"""Tests for _enforce_setup."""
|
||||
|
||||
def test_block_store_success(self) -> None:
|
||||
"""Setup blocks the store when configured."""
|
||||
config = Config(block_store=True, uninstall_other_games=False)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
@ -83,6 +89,7 @@ class TestEnforceSetup:
|
||||
_enforce_setup(config, state)
|
||||
|
||||
def test_block_store_fail(self) -> None:
|
||||
"""A failed store block is reported."""
|
||||
config = Config(block_store=True, uninstall_other_games=False)
|
||||
state = State()
|
||||
with (
|
||||
@ -95,6 +102,7 @@ class TestEnforceSetup:
|
||||
assert any("FAILED" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_no_block_store(self) -> None:
|
||||
"""Store blocking off means block_store is never called."""
|
||||
config = Config(block_store=False, uninstall_other_games=False)
|
||||
state = State()
|
||||
with (
|
||||
@ -107,6 +115,7 @@ class TestEnforceSetup:
|
||||
mock_block.assert_not_called()
|
||||
|
||||
def test_uninstall_other_games(self) -> None:
|
||||
"""Setup uninstalls other games when configured."""
|
||||
config = Config(uninstall_other_games=True, block_store=False)
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
@ -122,9 +131,11 @@ class TestEnforceAutoInstall:
|
||||
"""Tests for _enforce_auto_install."""
|
||||
|
||||
def test_no_app_id(self) -> None:
|
||||
"""Auto-install does nothing without an assigned game."""
|
||||
_enforce_auto_install(Config(), State())
|
||||
|
||||
def test_already_installed(self) -> None:
|
||||
"""An already installed game is not reinstalled."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=True),
|
||||
@ -133,6 +144,7 @@ class TestEnforceAutoInstall:
|
||||
_enforce_auto_install(Config(), state)
|
||||
|
||||
def test_installs_successfully(self) -> None:
|
||||
"""A missing assigned game is installed."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=False),
|
||||
@ -143,6 +155,7 @@ class TestEnforceAutoInstall:
|
||||
_enforce_auto_install(Config(steam_id="i"), state)
|
||||
|
||||
def test_install_fails(self) -> None:
|
||||
"""A failed install asks the user to install manually."""
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=False),
|
||||
@ -157,6 +170,7 @@ class TestEnforceHideGames:
|
||||
"""Tests for _enforce_hide_games."""
|
||||
|
||||
def test_hides_some(self) -> None:
|
||||
"""Other owned games are hidden."""
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
@ -166,6 +180,7 @@ class TestEnforceHideGames:
|
||||
_enforce_hide_games(Config(), state)
|
||||
|
||||
def test_already_hidden(self) -> None:
|
||||
"""Nothing new to hide reports the games as already hidden."""
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
@ -176,6 +191,7 @@ class TestEnforceHideGames:
|
||||
assert any("already" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_no_owned_ids(self) -> None:
|
||||
"""No owned ids skips hiding with a message."""
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{ENFORCE_STEPS_PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
|
||||
@ -16,6 +16,7 @@ class TestEnforceAllowedGame:
|
||||
"""Tests for enforce_allowed_game."""
|
||||
|
||||
def test_no_violations(self) -> None:
|
||||
"""Only the allowed game running means no violations."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
|
||||
return_value={100: 440},
|
||||
@ -24,6 +25,7 @@ class TestEnforceAllowedGame:
|
||||
assert result == []
|
||||
|
||||
def test_kills_unauthorized(self) -> None:
|
||||
"""An unauthorized game is reported and killed."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
|
||||
@ -36,6 +38,7 @@ class TestEnforceAllowedGame:
|
||||
mock_kill.assert_called_once_with(100, 570)
|
||||
|
||||
def test_skips_app_id_zero(self) -> None:
|
||||
"""Processes with app id 0 are not games and are ignored."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
|
||||
return_value={100: 0},
|
||||
@ -44,6 +47,7 @@ class TestEnforceAllowedGame:
|
||||
assert result == []
|
||||
|
||||
def test_detects_without_killing(self) -> None:
|
||||
"""With killing off, violations are reported but left running."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
|
||||
return_value={100: 570},
|
||||
@ -52,6 +56,7 @@ class TestEnforceAllowedGame:
|
||||
assert result == [(100, 570)]
|
||||
|
||||
def test_allowed_none(self) -> None:
|
||||
"""With nothing running, an empty allow set finds no violations."""
|
||||
result = enforce_allowed_game(set(), kill_unauthorized=True)
|
||||
assert result == []
|
||||
|
||||
@ -77,11 +82,13 @@ class TestKillProcess:
|
||||
"""Tests for kill_process."""
|
||||
|
||||
def test_kill_success(self) -> None:
|
||||
"""A live process gets signalled once."""
|
||||
with patch("steam_backlog_enforcer.enforcer.os.kill") as mock_kill:
|
||||
kill_process(123, 440)
|
||||
mock_kill.assert_called_once()
|
||||
|
||||
def test_process_already_gone(self) -> None:
|
||||
"""An already-exited process does not raise."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.os.kill",
|
||||
side_effect=ProcessLookupError,
|
||||
@ -89,6 +96,7 @@ class TestKillProcess:
|
||||
kill_process(123, 440) # Should not raise
|
||||
|
||||
def test_permission_error(self) -> None:
|
||||
"""A permission error while killing does not raise."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.os.kill",
|
||||
side_effect=PermissionError,
|
||||
@ -100,11 +108,13 @@ class TestSendNotification:
|
||||
"""Tests for send_notification."""
|
||||
|
||||
def test_sends(self) -> None:
|
||||
"""A notification runs notify-send once."""
|
||||
with patch("steam_backlog_enforcer.enforcer.subprocess.run") as mock_run:
|
||||
send_notification("Title", "Body")
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_handles_missing_notify_send(self) -> None:
|
||||
"""A missing notify-send binary does not raise."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.subprocess.run",
|
||||
side_effect=FileNotFoundError,
|
||||
@ -112,6 +122,7 @@ class TestSendNotification:
|
||||
send_notification("Title", "Body") # Should not raise
|
||||
|
||||
def test_handles_os_error(self) -> None:
|
||||
"""An OS error from notify-send does not raise."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.subprocess.run",
|
||||
side_effect=OSError,
|
||||
@ -130,6 +141,7 @@ class TestSendNotificationPrivilegeDrop:
|
||||
_ENV = "steam_backlog_enforcer._desktop_env"
|
||||
|
||||
def test_root_drops_to_desktop_user(self) -> None:
|
||||
"""As root, notify-send runs as the desktop user on their session bus."""
|
||||
with (
|
||||
patch(f"{self._ENV}.os.geteuid", return_value=0),
|
||||
patch(f"{self._ENV}.desktop_uid", return_value=1000),
|
||||
@ -143,6 +155,7 @@ class TestSendNotificationPrivilegeDrop:
|
||||
assert "DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus" in argv
|
||||
|
||||
def test_non_root_does_not_wrap(self) -> None:
|
||||
"""A non-root caller runs notify-send directly."""
|
||||
with (
|
||||
patch(f"{self._ENV}.os.geteuid", return_value=1000),
|
||||
patch(f"{self._PKG}.resolve_desktop_user", return_value="kuhy"),
|
||||
|
||||
@ -14,6 +14,7 @@ class TestKillProcessesByName:
|
||||
"""Tests for kill_processes_by_name."""
|
||||
|
||||
def test_kills_matching_pids(self) -> None:
|
||||
"""Every matching process is killed and reported with its name."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.enforcer.get_pids_by_process_names",
|
||||
@ -26,6 +27,7 @@ class TestKillProcessesByName:
|
||||
assert mock_kill.call_count == 2
|
||||
|
||||
def test_no_matches_returns_empty(self) -> None:
|
||||
"""No matching process means nothing killed."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.get_pids_by_process_names",
|
||||
return_value={},
|
||||
@ -34,6 +36,7 @@ class TestKillProcessesByName:
|
||||
assert result == []
|
||||
|
||||
def test_process_already_gone_not_included(self) -> None:
|
||||
"""A process that exited before the kill is not reported."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.enforcer.get_pids_by_process_names",
|
||||
@ -48,6 +51,7 @@ class TestKillProcessesByName:
|
||||
assert result == []
|
||||
|
||||
def test_permission_error_not_included(self) -> None:
|
||||
"""A process we may not kill is not reported."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.enforcer.get_pids_by_process_names",
|
||||
@ -66,12 +70,14 @@ class TestKillPidByName:
|
||||
"""Tests for _kill_pid_by_name."""
|
||||
|
||||
def test_success_returns_true(self) -> None:
|
||||
"""A delivered kill reports True."""
|
||||
with patch("steam_backlog_enforcer.enforcer.os.kill") as mock_kill:
|
||||
result = _kill_pid_by_name(123, "lutris")
|
||||
assert result is True
|
||||
mock_kill.assert_called_once()
|
||||
|
||||
def test_process_already_gone_returns_false(self) -> None:
|
||||
"""A process that already exited reports False."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.os.kill",
|
||||
side_effect=ProcessLookupError,
|
||||
@ -80,6 +86,7 @@ class TestKillPidByName:
|
||||
assert result is False
|
||||
|
||||
def test_permission_error_returns_false(self) -> None:
|
||||
"""A process we may not kill reports False."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.enforcer.os.kill",
|
||||
side_effect=PermissionError,
|
||||
|
||||
@ -14,6 +14,10 @@ from steam_backlog_enforcer._steam_errors import SteamUnavailableError
|
||||
from steam_backlog_enforcer._steam_launch import (
|
||||
ensure_steam_debug_port,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._desktop_launch_patches import (
|
||||
fresh_launch_patches,
|
||||
launch_patches,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -43,24 +47,19 @@ class TestEnsureSteamDebugPort:
|
||||
CDP port that no process was ever going to open.
|
||||
"""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch.steam_is_installed",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
|
||||
) as mock_launch,
|
||||
launch_patches(
|
||||
"_launch_steam_with_debug",
|
||||
_steam_has_debug_port=False,
|
||||
steam_is_installed=False,
|
||||
) as mocks,
|
||||
pytest.raises(SteamUnavailableError, match="not installed"),
|
||||
):
|
||||
ensure_steam_debug_port()
|
||||
|
||||
mock_launch.assert_not_called()
|
||||
mocks["_launch_steam_with_debug"].assert_not_called()
|
||||
|
||||
def test_already_available(self) -> None:
|
||||
"""An open debug port needs no launch and does not raise."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=True,
|
||||
@ -68,98 +67,29 @@ class TestEnsureSteamDebugPort:
|
||||
ensure_steam_debug_port()
|
||||
|
||||
def test_starts_fresh(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._is_steam_running",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
"""A stopped Steam is launched with the debug port and waited on."""
|
||||
with fresh_launch_patches(_is_steam_running=False):
|
||||
ensure_steam_debug_port()
|
||||
|
||||
def test_restarts_running_steam(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._is_steam_running",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._shutdown_steam",
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
|
||||
return_value=True,
|
||||
),
|
||||
):
|
||||
"""A running Steam without the port is shut down and relaunched."""
|
||||
with fresh_launch_patches("_shutdown_steam", _is_steam_running=True):
|
||||
ensure_steam_debug_port()
|
||||
|
||||
def test_cdp_timeout(self) -> None:
|
||||
"""A CDP port that never opens raises a timeout."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._is_steam_running",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
|
||||
return_value=False,
|
||||
fresh_launch_patches(
|
||||
cdp_ready=False, collections_ready=None, _is_steam_running=False
|
||||
),
|
||||
pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"),
|
||||
):
|
||||
ensure_steam_debug_port()
|
||||
|
||||
def test_collections_timeout(self) -> None:
|
||||
"""Collections that never load raise a timeout."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._steam_has_debug_port",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._is_steam_running",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._launch_steam_with_debug",
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_cdp_ready",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_launch._wait_for_collections_ready",
|
||||
return_value=False,
|
||||
),
|
||||
fresh_launch_patches(collections_ready=False, _is_steam_running=False),
|
||||
pytest.raises(
|
||||
RuntimeError, match="Timed out waiting for Steam collections"
|
||||
),
|
||||
|
||||
@ -49,6 +49,7 @@ class TestEnsureSteamRunning:
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
def test_already_running(self) -> None:
|
||||
"""A running Steam (pgrep exit 0) is left alone."""
|
||||
mock_result = MagicMock(returncode=0)
|
||||
with patch(
|
||||
"steam_backlog_enforcer._steam_client.subprocess.run",
|
||||
@ -57,6 +58,7 @@ class TestEnsureSteamRunning:
|
||||
_ensure_steam_running()
|
||||
|
||||
def test_not_running_starts_as_non_root(self) -> None:
|
||||
"""A stopped Steam is launched directly when not root."""
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with (
|
||||
patch(
|
||||
@ -76,6 +78,7 @@ class TestEnsureSteamRunning:
|
||||
mock_popen.assert_called_once()
|
||||
|
||||
def test_not_running_starts_as_root(self) -> None:
|
||||
"""A stopped Steam is launched as the desktop user when root."""
|
||||
mock_result = MagicMock(returncode=1)
|
||||
mock_pw = MagicMock()
|
||||
mock_pw.pw_uid = 1000
|
||||
@ -156,6 +159,7 @@ class TestEnsureSteamRunning:
|
||||
mock_sleep.assert_not_called()
|
||||
|
||||
def test_pgrep_not_found(self) -> None:
|
||||
"""A missing pgrep does not raise."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_client.subprocess.run",
|
||||
@ -171,6 +175,7 @@ class TestEnsureSteamRunning:
|
||||
_ensure_steam_running()
|
||||
|
||||
def test_steam_executable_not_found(self) -> None:
|
||||
"""A missing steam executable does not raise."""
|
||||
mock_result = MagicMock(returncode=1)
|
||||
with (
|
||||
patch(
|
||||
|
||||
@ -42,18 +42,21 @@ def _hide_and_capture(allowed: set[int]) -> tuple[list[int], list[int]]:
|
||||
|
||||
|
||||
def test_exception_is_kept_visible() -> None:
|
||||
"""An approved exception stays visible beside the assigned game."""
|
||||
visible, to_hide = _hide_and_capture({_ASSIGNED})
|
||||
assert visible == [_ASSIGNED, _EXCEPTION]
|
||||
assert to_hide == [_OTHER]
|
||||
|
||||
|
||||
def test_caller_set_is_not_mutated() -> None:
|
||||
"""The caller allowed-set is not mutated by adding the exception."""
|
||||
allowed = {_ASSIGNED}
|
||||
_hide_and_capture(allowed)
|
||||
assert allowed == {_ASSIGNED}
|
||||
|
||||
|
||||
def test_exception_is_exempt_from_tampering() -> None:
|
||||
"""An approved exception counts as legitimately played."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer._scanning_tampering.get_approved_exception_ids",
|
||||
return_value=frozenset({_EXCEPTION}),
|
||||
|
||||
@ -11,6 +11,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install
|
||||
from steam_backlog_enforcer import _fast_install_progress as fip
|
||||
from steam_backlog_enforcer._fast_install import _RUNS, _DetachedRun
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -25,11 +26,14 @@ def _failed_run(tmp_path: Path, app_id: int = 7) -> None:
|
||||
fip.track(app_id, "Baldur's Gate 3", log, offset=0, now=0.0)
|
||||
proc = MagicMock(pid=4321)
|
||||
proc.poll.return_value = 1
|
||||
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log))
|
||||
_RUNS.append(_DetachedRun(app_id, proc, log))
|
||||
|
||||
|
||||
class TestFailedRunsStayListed:
|
||||
"""A failed daemon run stays on the dashboard until superseded."""
|
||||
|
||||
def test_status_carries_the_reason(self, tmp_path: Path) -> None:
|
||||
"""A failed run is listed as failed with its exit code and log tail."""
|
||||
_failed_run(tmp_path)
|
||||
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
|
||||
_fast_install.poll_fast_installs()
|
||||
@ -45,6 +49,7 @@ class TestFailedRunsStayListed:
|
||||
]
|
||||
|
||||
def test_live_runs_have_no_error(self, tmp_path: Path) -> None:
|
||||
"""A live run carries no error."""
|
||||
fip.track(8, "G", tmp_path / "log", offset=0, now=0.0)
|
||||
fip.poll_trackers({8})
|
||||
assert fip.fast_install_status(now=1.0)[0]["error"] is None
|
||||
@ -59,6 +64,7 @@ class TestFailedRunsStayListed:
|
||||
assert fip.fast_install_status(now=9.0) == []
|
||||
|
||||
def test_a_new_run_replaces_the_failure(self, tmp_path: Path) -> None:
|
||||
"""Tracking a new run for the game replaces the failed entry."""
|
||||
_failed_run(tmp_path)
|
||||
with patch(f"{PKG}.is_game_fully_installed", return_value=False):
|
||||
_fast_install.poll_fast_installs()
|
||||
|
||||
@ -35,6 +35,7 @@ def _console(tmp_path: Path, run_log: Path) -> Path:
|
||||
|
||||
|
||||
def test_reason_is_the_run_logs_error_lines(tmp_path: Path) -> None:
|
||||
"""The reason is the run log's error lines joined into one line."""
|
||||
run_log = tmp_path / "run-1086940.log"
|
||||
hello = "2026-10-23 22:13:21,605 INFO steam_installer: hi\n"
|
||||
run_log.write_text(hello + _REFUSAL)
|
||||
@ -45,10 +46,12 @@ def test_reason_is_the_run_logs_error_lines(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
def test_without_a_run_log_the_last_console_line_is_shown(tmp_path: Path) -> None:
|
||||
"""Without a run log the console's last line names the failure."""
|
||||
console = tmp_path / "fast_install-0.log"
|
||||
console.write_text("Traceback (most recent call last):\nOSError: disk full\n")
|
||||
assert failure_reason(console, 1) == "exit 1: OSError: disk full"
|
||||
|
||||
|
||||
def test_an_unreadable_console_log_still_names_the_exit(tmp_path: Path) -> None:
|
||||
"""An unreadable console log still names the exit status."""
|
||||
assert failure_reason(tmp_path / "missing.log", 3) == "exit 3"
|
||||
|
||||
@ -65,16 +65,19 @@ class TestFastInstallRunning:
|
||||
],
|
||||
)
|
||||
def test_detects_installer(self, tmp_path: Path, cmdline: bytes) -> None:
|
||||
"""Both the run.sh wrapper and the Python module count as the installer."""
|
||||
proc = self._proc(tmp_path, {"self": b"", "10": None, "11": cmdline})
|
||||
with patch(f"{GUARD}.Path", return_value=proc):
|
||||
assert fast_install_running() is True
|
||||
|
||||
def test_nothing_running(self, tmp_path: Path) -> None:
|
||||
"""Other processes and unreadable entries do not count."""
|
||||
proc = self._proc(tmp_path, {"self": b"", "10": None, "11": b"steam\0-silent"})
|
||||
with patch(f"{GUARD}.Path", return_value=proc):
|
||||
assert fast_install_running() is False
|
||||
|
||||
def test_assert_raises_while_running(self) -> None:
|
||||
"""The guard raises while an installer is running."""
|
||||
with (
|
||||
patch(f"{GUARD}.fast_install_running", return_value=True),
|
||||
pytest.raises(FastInstallInProgressError),
|
||||
@ -82,9 +85,11 @@ class TestFastInstallRunning:
|
||||
assert_no_fast_install()
|
||||
|
||||
def test_assert_passes_when_idle(self) -> None:
|
||||
"""With no installer running the guard passes."""
|
||||
assert_no_fast_install()
|
||||
|
||||
def test_error_degrades_like_other_steam_errors(self) -> None:
|
||||
"""The installer error is caught wherever Steam being unavailable is."""
|
||||
assert issubclass(FastInstallInProgressError, SteamUnavailableError)
|
||||
|
||||
|
||||
@ -92,26 +97,32 @@ class TestFullyInstalled:
|
||||
"""Only a finished, settled manifest counts as fully installed."""
|
||||
|
||||
def test_missing(self) -> None:
|
||||
"""No manifest means not installed."""
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_installed(self) -> None:
|
||||
"""A settled manifest with the installed flag counts."""
|
||||
_manifest(1, 4)
|
||||
assert _steam_state.is_game_fully_installed(1) is True
|
||||
|
||||
def test_steam_still_downloading(self) -> None:
|
||||
"""A manifest still downloading through Steam does not count."""
|
||||
_manifest(1, 1042, to_download=10)
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_flag_set_but_bytes_in_flight(self) -> None:
|
||||
"""The installed flag with bytes still in flight does not count."""
|
||||
_manifest(1, 1044, to_download=10)
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_no_state_flags(self) -> None:
|
||||
"""A manifest without StateFlags does not count."""
|
||||
path = _steam_state.STEAMAPPS_PATH / "appmanifest_1.acf"
|
||||
path.write_text('"AppState"\n{\n}\n')
|
||||
assert _steam_state.is_game_fully_installed(1) is False
|
||||
|
||||
def test_update_check_sees_a_transfer(self) -> None:
|
||||
"""Bytes left to download mark a Steam update as in progress."""
|
||||
_manifest(7, 1026, to_download=10)
|
||||
assert _steam_state.steam_update_in_progress() is True
|
||||
|
||||
@ -120,6 +131,7 @@ class TestInstallGameFastPath:
|
||||
"""install_game prefers the installer and never reopens Steam under it."""
|
||||
|
||||
def test_own_run_in_progress(self) -> None:
|
||||
"""install_game does not restart its own installer run already going."""
|
||||
with (
|
||||
patch(f"{GI}.fast_install_running", return_value=True),
|
||||
patch(f"{GI}.is_fast_installing", return_value=True),
|
||||
@ -129,6 +141,7 @@ class TestInstallGameFastPath:
|
||||
fast.assert_not_called()
|
||||
|
||||
def test_other_run_defers(self) -> None:
|
||||
"""Another installer run makes install_game defer, not trigger Steam."""
|
||||
with (
|
||||
patch(f"{GI}.fast_install_running", return_value=True),
|
||||
patch(f"{GI}.is_fast_installing", return_value=False),
|
||||
@ -138,6 +151,7 @@ class TestInstallGameFastPath:
|
||||
trigger.assert_not_called()
|
||||
|
||||
def test_fast_install_wins(self) -> None:
|
||||
"""A successful fast install skips the Steam install trigger."""
|
||||
with (
|
||||
patch(f"{GI}.fast_install", return_value=True),
|
||||
patch(f"{GI}._trigger_steam_install") as trigger,
|
||||
@ -146,6 +160,7 @@ class TestInstallGameFastPath:
|
||||
trigger.assert_not_called()
|
||||
|
||||
def test_steams_own_download_is_left_running(self) -> None:
|
||||
"""A Steam download already under way is neither triggered nor rewritten."""
|
||||
_manifest(1, 1026, to_download=10)
|
||||
before = (_steam_state.STEAMAPPS_PATH / "appmanifest_1.acf").read_text()
|
||||
with (
|
||||
@ -162,6 +177,7 @@ class TestSteamStaysClosed:
|
||||
"""Every Steam start path defers to a live installer."""
|
||||
|
||||
def test_ensure_steam_running(self) -> None:
|
||||
"""Steam is not started while an installer runs."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._steam_client.steam_is_installed",
|
||||
@ -177,6 +193,7 @@ class TestSteamStaysClosed:
|
||||
spawn.assert_not_called()
|
||||
|
||||
def test_debug_port(self) -> None:
|
||||
"""The debug-port launch refuses while an installer runs."""
|
||||
with (
|
||||
patch(f"{GUARD}.fast_install_running", return_value=True),
|
||||
patch(f"{LAUNCH}._launch_steam_with_debug") as launch,
|
||||
@ -186,6 +203,7 @@ class TestSteamStaysClosed:
|
||||
launch.assert_not_called()
|
||||
|
||||
def test_restart(self) -> None:
|
||||
"""A Steam restart is skipped while an installer runs."""
|
||||
with (
|
||||
patch(f"{LAUNCH}.game_is_running", return_value=False),
|
||||
patch(f"{LAUNCH}.steam_update_in_progress", return_value=False),
|
||||
@ -200,6 +218,7 @@ class TestReinstallPollsRuns:
|
||||
"""The loop reaps detached runs even when no game is missing."""
|
||||
|
||||
def test_polls_every_pass(self) -> None:
|
||||
"""Fast installs are polled on every pass, even with nothing missing."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._enforce_steps.steam_library_ready",
|
||||
|
||||
@ -11,6 +11,7 @@ from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, _fast_install_cmd, _steam_state
|
||||
from steam_backlog_enforcer._fast_install import _RUNS, _DetachedRun, _refusal
|
||||
|
||||
PKG = "steam_backlog_enforcer._fast_install"
|
||||
CMD = "steam_backlog_enforcer._fast_install_cmd"
|
||||
@ -20,6 +21,7 @@ class TestInstallerPath:
|
||||
"""run.sh is resolved from the desktop user's passwd home."""
|
||||
|
||||
def test_found_in_user_home(self, tmp_path: Path) -> None:
|
||||
"""run.sh is found under the desktop user's passwd home."""
|
||||
installer = tmp_path / "src" / "steam-game-installer" / "run.sh"
|
||||
installer.parent.mkdir(parents=True)
|
||||
installer.write_text("#!/bin/bash\n")
|
||||
@ -33,16 +35,19 @@ class TestInstallerPath:
|
||||
assert _fast_install_cmd.installer_path("kuhy") == installer
|
||||
|
||||
def test_missing_file_is_none(self, tmp_path: Path) -> None:
|
||||
"""A home without run.sh yields no installer."""
|
||||
with patch(
|
||||
f"{CMD}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)
|
||||
):
|
||||
assert _fast_install_cmd.installer_path("kuhy") is None
|
||||
|
||||
def test_unknown_user_is_none(self) -> None:
|
||||
"""A user unknown to passwd yields no installer."""
|
||||
with patch(f"{CMD}.pwd.getpwnam", side_effect=KeyError("nobody")):
|
||||
assert _fast_install_cmd.installer_path("nobody") is None
|
||||
|
||||
def test_no_user_uses_own_home(self, tmp_path: Path) -> None:
|
||||
"""With no user the process's own home is searched."""
|
||||
with patch(f"{CMD}.Path.home", return_value=tmp_path):
|
||||
assert _fast_install_cmd.installer_path(None) is None
|
||||
|
||||
@ -56,14 +61,17 @@ class TestSteamAccountName:
|
||||
(config / "loginusers.vdf").write_text(content)
|
||||
|
||||
def test_reads_account_name(self) -> None:
|
||||
"""The AccountName field is read from loginusers.vdf."""
|
||||
self._write('"users"\n{\n\t"7656"\n\t{\n\t\t"AccountName"\t\t"krzys"\n')
|
||||
assert _fast_install_cmd.steam_account_name() == "krzys"
|
||||
|
||||
def test_no_account_field(self) -> None:
|
||||
"""A loginusers.vdf without AccountName yields no name."""
|
||||
self._write('"users"\n{\n}\n')
|
||||
assert _fast_install_cmd.steam_account_name() is None
|
||||
|
||||
def test_missing_file(self) -> None:
|
||||
"""A missing loginusers.vdf yields no name."""
|
||||
assert _fast_install_cmd.steam_account_name() is None
|
||||
|
||||
|
||||
@ -71,10 +79,12 @@ class TestRefusal:
|
||||
"""Closing Steam is refused while it would destroy something."""
|
||||
|
||||
def test_game_running(self) -> None:
|
||||
"""A running game is a reason to refuse closing Steam."""
|
||||
with patch(f"{PKG}.game_is_running", return_value=True):
|
||||
assert "game is running" in (_fast_install._refusal() or "")
|
||||
assert "game is running" in (_refusal() or "")
|
||||
|
||||
def test_other_download_does_not_refuse(self) -> None:
|
||||
"""Another game's download does not refuse closing Steam."""
|
||||
# The installer closes Steam with ``steam -shutdown``, which Steam
|
||||
# survives mid-download; another transfer is no reason to skip.
|
||||
(_steam_state.STEAMAPPS_PATH / "appmanifest_9.acf").write_text(
|
||||
@ -83,13 +93,14 @@ class TestRefusal:
|
||||
)
|
||||
assert _steam_state.steam_update_in_progress() is True
|
||||
with patch(f"{PKG}.game_is_running", return_value=False):
|
||||
assert _fast_install._refusal() is None
|
||||
assert _refusal() is None
|
||||
|
||||
|
||||
class TestBuildCmd:
|
||||
"""The argv pins every choice the installer would otherwise prompt for."""
|
||||
|
||||
def test_with_account(self) -> None:
|
||||
"""A known account adds ``--username`` to the pinned argv."""
|
||||
with (
|
||||
patch(f"{CMD}.steam_account_name", return_value="krzys"),
|
||||
patch(f"{CMD}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd),
|
||||
@ -109,6 +120,7 @@ class TestBuildCmd:
|
||||
]
|
||||
|
||||
def test_without_account_and_drops_privileges(self) -> None:
|
||||
"""No account means no username, and the argv runs as the user."""
|
||||
with (
|
||||
patch(f"{CMD}.steam_account_name", return_value=None),
|
||||
patch(f"{CMD}.desktop_user_cmd", return_value=["wrapped"]) as wrap,
|
||||
@ -125,9 +137,8 @@ class TestIsFastInstalling:
|
||||
"""Only this process's own live runs count."""
|
||||
|
||||
def test_tracks_runs(self) -> None:
|
||||
"""Only app ids with a live run of this process count as installing."""
|
||||
assert _fast_install.is_fast_installing(5) is False
|
||||
_fast_install._RUNS.append(
|
||||
_fast_install._DetachedRun(5, MagicMock(), Path("/nonexistent"))
|
||||
)
|
||||
_RUNS.append(_DetachedRun(5, MagicMock(), Path("/nonexistent")))
|
||||
assert _fast_install.is_fast_installing(5) is True
|
||||
assert _fast_install.is_fast_installing(6) is False
|
||||
|
||||
@ -13,6 +13,12 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, _fast_install_progress, config
|
||||
from steam_backlog_enforcer._fast_install import (
|
||||
_FAILED,
|
||||
_RUNS,
|
||||
_DetachedRun,
|
||||
_spawn_detached,
|
||||
)
|
||||
from steam_backlog_enforcer._progress import use_progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@ -22,6 +28,7 @@ PKG = "steam_backlog_enforcer._fast_install"
|
||||
|
||||
|
||||
def _ready(tmp_path: Path) -> Path:
|
||||
"""Write a stub installer script and return its path."""
|
||||
installer = tmp_path / "run.sh"
|
||||
installer.write_text("#!/bin/bash\n")
|
||||
return installer
|
||||
@ -41,7 +48,7 @@ def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> Magi
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.write_bytes(log_text)
|
||||
_fast_install_progress.track(app_id, label, log, offset=0, now=0.0)
|
||||
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log))
|
||||
_RUNS.append(_DetachedRun(app_id, proc, log))
|
||||
|
||||
return MagicMock(side_effect=spawn)
|
||||
|
||||
@ -49,7 +56,17 @@ def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> Magi
|
||||
class TestJobWaitsForInstaller:
|
||||
"""A non-root, non-terminal caller blocks until the run is reaped."""
|
||||
|
||||
def _install(self, tmp_path: Path, spawn: MagicMock, euid: int) -> bool:
|
||||
def _install(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
spawn: MagicMock,
|
||||
euid: int,
|
||||
echo: MagicMock | None = None,
|
||||
) -> bool:
|
||||
"""Run fast_install as a non-terminal caller with *euid* and *spawn*.
|
||||
|
||||
*echo* stands in for the installer's echo so a test can read it.
|
||||
"""
|
||||
with (
|
||||
patch(f"{PKG}.installer_path", return_value=_ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
@ -57,30 +74,34 @@ class TestJobWaitsForInstaller:
|
||||
patch(f"{PKG}.os.geteuid", return_value=euid),
|
||||
patch(f"{PKG}._spawn_detached", spawn),
|
||||
patch(f"{PKG}.time.sleep"),
|
||||
patch(f"{PKG}._echo") as self.echo,
|
||||
patch(f"{PKG}._echo", echo if echo is not None else MagicMock()),
|
||||
):
|
||||
return _fast_install.fast_install(7, "G")
|
||||
|
||||
def test_success_after_waiting(self, tmp_path: Path) -> None:
|
||||
"""A run that exits 0 after polling reports success and is reaped."""
|
||||
assert self._install(tmp_path, _fake_spawn(None, None, 0), 1000) is True
|
||||
assert _fast_install._RUNS == []
|
||||
assert _RUNS == []
|
||||
|
||||
def test_failure_falls_back(self, tmp_path: Path) -> None:
|
||||
"""A non-zero exit reports failure and remembers the app as failed."""
|
||||
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
|
||||
assert {7} == set(_fast_install._FAILED)
|
||||
assert {7} == set(_FAILED)
|
||||
|
||||
def test_failure_is_an_error_line_with_the_reason(self, tmp_path: Path) -> None:
|
||||
"""A job turns a line starting "Failed" into its failed verdict."""
|
||||
log = b"Refusing to mark G as installed:\nRestarting the Steam client...\n"
|
||||
echo = MagicMock()
|
||||
with patch(f"{PKG}._fip.current_progress"):
|
||||
self._install(tmp_path, _fake_spawn(None, 1, log_text=log), 1000)
|
||||
echoed = [c.args[0] for c in self.echo.call_args_list]
|
||||
self._install(tmp_path, _fake_spawn(None, 1, log_text=log), 1000, echo)
|
||||
echoed = [c.args[0] for c in echo.call_args_list]
|
||||
assert any(
|
||||
line.startswith("Failed: fast install of G") and "exit 1" in line
|
||||
for line in echoed
|
||||
), echoed
|
||||
|
||||
def test_progress_is_reported_while_waiting(self, tmp_path: Path) -> None:
|
||||
"""The waiting caller forwards depot progress from the log."""
|
||||
text = b"Shutting down the Steam client...\r\nDownloading via depot\r\n"
|
||||
text += b"Processing depot 1\r\nDownloading depot 1\r\n"
|
||||
text += b" 46.90% /x/a.pak (657.13 Mbit/s)\r\n"
|
||||
@ -93,6 +114,7 @@ class TestJobWaitsForInstaller:
|
||||
progress.advance.assert_called_with("G", step=46)
|
||||
|
||||
def test_daemon_returns_at_once(self, tmp_path: Path) -> None:
|
||||
"""The root daemon does not wait; the run stays tracked."""
|
||||
assert self._install(tmp_path, _fake_spawn(), 0) is True
|
||||
assert _fast_install.is_fast_installing(7)
|
||||
|
||||
@ -101,6 +123,7 @@ class TestLogIsPerUser:
|
||||
"""A root-owned daemon log never blocks the desktop user's spawn."""
|
||||
|
||||
def test_unwritable_daemon_log_is_not_used(self) -> None:
|
||||
"""A desktop-user spawn writes its own log, not the root daemon one."""
|
||||
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
daemon_log = config.CONFIG_DIR / "fast_install.log"
|
||||
daemon_log.write_text("root's\n")
|
||||
@ -109,11 +132,12 @@ class TestLogIsPerUser:
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
_spawn_detached(["run.sh"], 9)
|
||||
popen.assert_called_once()
|
||||
assert _fast_install._RUNS[0].log_path != daemon_log
|
||||
assert _RUNS[0].log_path != daemon_log
|
||||
|
||||
def test_progress_starts_at_the_current_end(self) -> None:
|
||||
"""Progress ignores the previous run's lines already in the log."""
|
||||
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log = config.CONFIG_DIR / "fast_install-1000.log"
|
||||
log.write_bytes(b"Restarting the Steam client...\r\n") # the last run
|
||||
@ -121,7 +145,7 @@ class TestLogIsPerUser:
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}.subprocess.Popen"),
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9, "G")
|
||||
_spawn_detached(["run.sh"], 9, "G")
|
||||
_fast_install_progress.poll_trackers({9})
|
||||
progress = _fast_install_progress.progress_of(9, now=0.0)
|
||||
assert progress is not None
|
||||
|
||||
@ -1,35 +1,20 @@
|
||||
"""Tests for _fast_install_progress: reading a live installer log.
|
||||
|
||||
The fixture is a verbatim excerpt of a real Baldur's Gate 3 run (the per-file
|
||||
lines of the big depots trimmed). It is stored with LF endings and written
|
||||
back with CRLF, which is what the installer's pty actually produces.
|
||||
The fixture is a verbatim excerpt of a real Baldur's Gate 3 run, see
|
||||
``_fast_install_excerpt``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _fast_install_progress as fip
|
||||
from steam_backlog_enforcer.tests._fast_install_excerpt import upto
|
||||
|
||||
MOD = "steam_backlog_enforcer._fast_install_progress"
|
||||
EXCERPT = (Path(__file__).parent / "fast_install_log_excerpt.txt").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
LINES = EXCERPT.splitlines()
|
||||
|
||||
|
||||
def _upto(marker: str, *, extra: int = 0) -> str:
|
||||
"""The excerpt through the line equal to *marker*, else the first it starts.
|
||||
|
||||
Exact first: ``Downloading depot 228989`` is also the start of the earlier
|
||||
``Downloading depot 228989 manifest``.
|
||||
"""
|
||||
exact = [i for i, line in enumerate(LINES) if line == marker]
|
||||
index = (exact or [i for i, ln in enumerate(LINES) if ln.startswith(marker)])[0]
|
||||
return "".join(f"{line}\r\n" for line in LINES[: index + 1 + extra])
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _tracker(tmp_path: Path, text: str, *, offset: int = 0) -> fip.InstallTracker:
|
||||
@ -44,20 +29,24 @@ class TestPhases:
|
||||
"""Each installer stage the log announces becomes a phase."""
|
||||
|
||||
def test_nothing_yet_is_starting(self, tmp_path: Path) -> None:
|
||||
"""An empty log reads as starting with the elapsed time."""
|
||||
snap = _tracker(tmp_path, "").snapshot(now=103.0)
|
||||
assert snap == fip.InstallProgress(fip.STARTING, None, 3)
|
||||
|
||||
def test_shutting_steam_down(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Shutting down")).snapshot(now=112.0)
|
||||
"""The shutdown line reads as shutting Steam down, no percent."""
|
||||
snap = _tracker(tmp_path, upto("Shutting down")).snapshot(now=112.0)
|
||||
assert snap.phase == "shutting Steam down"
|
||||
assert snap.percent is None
|
||||
assert snap.describe() == "shutting Steam down, 12s elapsed"
|
||||
|
||||
def test_connecting_before_any_depot(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Logging ")).snapshot(now=100.0)
|
||||
"""The login line reads as connecting to Steam."""
|
||||
snap = _tracker(tmp_path, upto("Logging ")).snapshot(now=100.0)
|
||||
assert snap.phase == "connecting to Steam"
|
||||
|
||||
def test_adopting_steams_partial_download(self, tmp_path: Path) -> None:
|
||||
"""The hardlink line reads as adopting the partial download."""
|
||||
line = (
|
||||
"Steam already staged 71.53 GB for this app (161 files). Hardlinking "
|
||||
"it into place so depotdownloader verifies instead of refetching.\r\n"
|
||||
@ -66,42 +55,49 @@ class TestPhases:
|
||||
assert snap.phase == "adopting Steam's partial download"
|
||||
|
||||
def test_first_depot_percent(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto(" 46.90%")).snapshot(now=100.0)
|
||||
"""A first-depot percent line reads as downloading 1/8 at that percent."""
|
||||
snap = _tracker(tmp_path, upto(" 46.90%")).snapshot(now=100.0)
|
||||
assert snap.phase == "downloading depot 1/8"
|
||||
assert snap.percent == pytest.approx(46.90)
|
||||
assert snap.downloading
|
||||
|
||||
def test_new_depot_resets_the_percent(self, tmp_path: Path) -> None:
|
||||
"""A new depot drops the previous depot percent."""
|
||||
# Pre-allocating lines only: the previous depot's 100% must not show.
|
||||
text = _upto("Downloading depot 228990", extra=3)
|
||||
text = upto("Downloading depot 228990", extra=3)
|
||||
snap = _tracker(tmp_path, text).snapshot(now=100.0)
|
||||
assert snap.phase == "downloading depot 2/8"
|
||||
assert snap.percent is None
|
||||
assert snap.describe() == "downloading depot 2/8, 0s elapsed"
|
||||
|
||||
def test_percent_outside_a_depot_is_ignored(self, tmp_path: Path) -> None:
|
||||
text = _upto("Shutting down") + " 46.90% /x/y.cmd (657.13 Mbit/s)\r\n"
|
||||
"""A percent line outside any depot does not set a percent."""
|
||||
text = upto("Shutting down") + " 46.90% /x/y.cmd (657.13 Mbit/s)\r\n"
|
||||
snap = _tracker(tmp_path, text).snapshot(now=100.0)
|
||||
assert snap.phase == "shutting Steam down"
|
||||
assert snap.percent is None
|
||||
|
||||
def test_percent_is_per_depot(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto(" 92.49%")).snapshot(now=100.0)
|
||||
"""The percent belongs to the current depot."""
|
||||
snap = _tracker(tmp_path, upto(" 92.49%")).snapshot(now=100.0)
|
||||
assert snap.phase == "downloading depot 3/8"
|
||||
assert snap.percent == pytest.approx(92.49)
|
||||
|
||||
def test_finishing_after_the_download(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Checking for owned DLC")).snapshot(now=0)
|
||||
"""The DLC check after the download reads as finishing manifest."""
|
||||
snap = _tracker(tmp_path, upto("Checking for owned DLC")).snapshot(now=0)
|
||||
assert snap.phase == "finishing manifest"
|
||||
assert snap.percent is None
|
||||
assert not snap.downloading
|
||||
|
||||
def test_whole_run_ends_restarting_steam(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Restarting")).snapshot(now=100.0)
|
||||
"""A full run ends in the restarting Steam phase."""
|
||||
snap = _tracker(tmp_path, upto("Restarting")).snapshot(now=100.0)
|
||||
assert snap.phase == "restarting Steam"
|
||||
|
||||
def test_describe_with_percent_and_minutes(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto(" 99.35%")).snapshot(now=100.0 + 312)
|
||||
"""describe floors the percent and formats minutes and seconds."""
|
||||
snap = _tracker(tmp_path, upto(" 99.35%")).snapshot(now=100.0 + 312)
|
||||
assert snap.describe() == "99% (downloading depot 3/8), 5m 12s elapsed"
|
||||
|
||||
|
||||
@ -109,22 +105,24 @@ class TestIncrementalRead:
|
||||
"""Only bytes written since the spawn are read, a line at a time."""
|
||||
|
||||
def test_previous_run_before_offset_is_ignored(self, tmp_path: Path) -> None:
|
||||
previous = _upto("Restarting") # the last run ended at 100%...
|
||||
"""Log bytes before the spawn offset are not read."""
|
||||
previous = upto("Restarting") # the last run ended at 100%...
|
||||
log = tmp_path / "fast_install-1000.log"
|
||||
log.write_bytes(previous.encode())
|
||||
tracker = fip.InstallTracker(log, offset=log.stat().st_size, started=0.0)
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=1.0).phase == fip.STARTING
|
||||
with log.open("ab") as out: # ...and the new one has just begun.
|
||||
out.write(_upto("Shutting down").encode())
|
||||
out.write(upto("Shutting down").encode())
|
||||
tracker.poll()
|
||||
snap = tracker.snapshot(now=1.0)
|
||||
assert snap.phase == "shutting Steam down"
|
||||
assert snap.percent is None
|
||||
|
||||
def test_partial_line_waits_for_its_end(self, tmp_path: Path) -> None:
|
||||
"""An unterminated line is parsed only once it ends."""
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(_upto("Downloading depot 228989").encode() + b" 46.9")
|
||||
log.write_bytes(upto("Downloading depot 228989").encode() + b" 46.9")
|
||||
tracker = fip.InstallTracker(log, offset=0, started=0.0)
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=0).percent is None
|
||||
@ -134,104 +132,7 @@ class TestIncrementalRead:
|
||||
assert tracker.snapshot(now=0).percent == pytest.approx(46.90)
|
||||
|
||||
def test_missing_log_is_a_no_op(self, tmp_path: Path) -> None:
|
||||
"""A missing log file leaves the tracker starting."""
|
||||
tracker = fip.InstallTracker(tmp_path / "gone.log", offset=0, started=0.0)
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=0).phase == fip.STARTING
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Daemon runs are tracked by app id and exposed as status entries."""
|
||||
|
||||
def test_status_lists_live_runs(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(_upto(" 59.16%").encode())
|
||||
fip.track(7, "Baldur's Gate 3", log, offset=0, now=10.0)
|
||||
fip.poll_trackers({7})
|
||||
assert fip.fast_install_status(now=70.0) == [
|
||||
{
|
||||
"app_id": 7,
|
||||
"name": "Baldur's Gate 3",
|
||||
"phase": "downloading depot 1/8",
|
||||
"percent": 59.16,
|
||||
"elapsed_seconds": 60,
|
||||
"error": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_finished_runs_are_dropped(self, tmp_path: Path) -> None:
|
||||
fip.track(7, "G", tmp_path / "log", offset=0, now=0.0)
|
||||
fip.poll_trackers(set())
|
||||
assert fip.fast_install_status(now=1.0) == []
|
||||
assert fip.progress_of(7, now=1.0) is None
|
||||
|
||||
|
||||
class TestWaitReporter:
|
||||
"""A waiting web job moves its progress bar and prints a line every 30 s."""
|
||||
|
||||
def _reporter(self, tmp_path: Path, text: str) -> fip.WaitReporter:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(text.encode())
|
||||
fip.track(7, "G", log, offset=0, now=0.0)
|
||||
fip.poll_trackers({7})
|
||||
with patch(f"{MOD}._echo"):
|
||||
return fip.WaitReporter(7, "G", started=0.0)
|
||||
|
||||
def test_announces_the_wait(self) -> None:
|
||||
with patch(f"{MOD}._echo") as echo:
|
||||
fip.WaitReporter(7, "G", started=0.0)
|
||||
echo.assert_called_once_with(
|
||||
" Fast-installing G with steam-game-installer (Steam will close); "
|
||||
"progress every 30s..."
|
||||
)
|
||||
|
||||
def test_phase_then_percent_step(self, tmp_path: Path) -> None:
|
||||
reporter = self._reporter(tmp_path, _upto(" 99.35%"))
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo"),
|
||||
):
|
||||
reporter.report(now=5.0)
|
||||
reporter.report(now=10.0)
|
||||
# The phase is announced once; 99.35 floors so it never claims done.
|
||||
progress.phase.assert_called_once_with(
|
||||
"Fast-installing G: downloading depot 3/8", 100
|
||||
)
|
||||
assert progress.advance.call_args_list == [
|
||||
call("G", step=99),
|
||||
call("G", step=99),
|
||||
]
|
||||
|
||||
def test_indeterminate_phase_shows_elapsed(self, tmp_path: Path) -> None:
|
||||
reporter = self._reporter(tmp_path, _upto("Shutting down"))
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo"),
|
||||
):
|
||||
reporter.report(now=12.0)
|
||||
progress.phase.assert_called_once_with(
|
||||
"Fast-installing G: shutting Steam down", None
|
||||
)
|
||||
progress.advance.assert_called_once_with("⏱️ 12s", step=0)
|
||||
|
||||
def test_echo_is_throttled_to_30_seconds(self, tmp_path: Path) -> None:
|
||||
reporter = self._reporter(tmp_path, _upto(" 46.90%"))
|
||||
with patch(f"{MOD}.current_progress"), patch(f"{MOD}._echo") as echo:
|
||||
for now in (5.0, 10.0, 29.0, 31.0, 45.0, 62.0):
|
||||
reporter.report(now=now)
|
||||
assert echo.call_args_list == [
|
||||
call(" Fast-installing G: 46% (downloading depot 1/8), 31s elapsed"),
|
||||
call(" Fast-installing G: 46% (downloading depot 1/8), 1m 02s elapsed"),
|
||||
]
|
||||
|
||||
def test_untracked_run_reports_nothing(self) -> None:
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo") as echo,
|
||||
):
|
||||
fip.WaitReporter(99, "G", started=0.0).report(now=60.0)
|
||||
progress.phase.assert_not_called()
|
||||
progress.advance.assert_not_called()
|
||||
assert echo.call_count == 1 # the announcement only
|
||||
|
||||
121
steam_backlog_enforcer/tests/test_fast_install_registry.py
Normal file
121
steam_backlog_enforcer/tests/test_fast_install_registry.py
Normal file
@ -0,0 +1,121 @@
|
||||
"""Tests for _fast_install_progress: the run registry and the wait reporter.
|
||||
|
||||
Split from test_fast_install_progress.py; both read the recorded installer
|
||||
log excerpt in ``_fast_install_excerpt``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install_progress as fip
|
||||
from steam_backlog_enforcer.tests._fast_install_excerpt import MOD, upto
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Daemon runs are tracked by app id and exposed as status entries."""
|
||||
|
||||
def test_status_lists_live_runs(self, tmp_path: Path) -> None:
|
||||
"""A tracked live run is listed with phase, percent and elapsed time."""
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(upto(" 59.16%").encode())
|
||||
fip.track(7, "Baldur's Gate 3", log, offset=0, now=10.0)
|
||||
fip.poll_trackers({7})
|
||||
assert fip.fast_install_status(now=70.0) == [
|
||||
{
|
||||
"app_id": 7,
|
||||
"name": "Baldur's Gate 3",
|
||||
"phase": "downloading depot 1/8",
|
||||
"percent": 59.16,
|
||||
"elapsed_seconds": 60,
|
||||
"error": None,
|
||||
}
|
||||
]
|
||||
|
||||
def test_finished_runs_are_dropped(self, tmp_path: Path) -> None:
|
||||
"""A run no longer live is dropped from status and progress."""
|
||||
fip.track(7, "G", tmp_path / "log", offset=0, now=0.0)
|
||||
fip.poll_trackers(set())
|
||||
assert fip.fast_install_status(now=1.0) == []
|
||||
assert fip.progress_of(7, now=1.0) is None
|
||||
|
||||
|
||||
class TestWaitReporter:
|
||||
"""A waiting web job moves its progress bar and prints a line every 30 s."""
|
||||
|
||||
def _reporter(self, tmp_path: Path, text: str) -> fip.WaitReporter:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(text.encode())
|
||||
fip.track(7, "G", log, offset=0, now=0.0)
|
||||
fip.poll_trackers({7})
|
||||
with patch(f"{MOD}._echo"):
|
||||
return fip.WaitReporter(7, "G", started=0.0)
|
||||
|
||||
def test_announces_the_wait(self) -> None:
|
||||
"""Creating a reporter announces the fast install once."""
|
||||
with patch(f"{MOD}._echo") as echo:
|
||||
fip.WaitReporter(7, "G", started=0.0)
|
||||
echo.assert_called_once_with(
|
||||
" Fast-installing G with steam-game-installer (Steam will close); "
|
||||
"progress every 30s..."
|
||||
)
|
||||
|
||||
def test_phase_then_percent_step(self, tmp_path: Path) -> None:
|
||||
"""The phase is set once and the floored percent advances the bar."""
|
||||
reporter = self._reporter(tmp_path, upto(" 99.35%"))
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo"),
|
||||
):
|
||||
reporter.report(now=5.0)
|
||||
reporter.report(now=10.0)
|
||||
# The phase is announced once; 99.35 floors so it never claims done.
|
||||
progress.phase.assert_called_once_with(
|
||||
"Fast-installing G: downloading depot 3/8", 100
|
||||
)
|
||||
assert progress.advance.call_args_list == [
|
||||
call("G", step=99),
|
||||
call("G", step=99),
|
||||
]
|
||||
|
||||
def test_indeterminate_phase_shows_elapsed(self, tmp_path: Path) -> None:
|
||||
"""A phase without percent shows the elapsed time."""
|
||||
reporter = self._reporter(tmp_path, upto("Shutting down"))
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo"),
|
||||
):
|
||||
reporter.report(now=12.0)
|
||||
progress.phase.assert_called_once_with(
|
||||
"Fast-installing G: shutting Steam down", None
|
||||
)
|
||||
progress.advance.assert_called_once_with("⏱️ 12s", step=0)
|
||||
|
||||
def test_echo_is_throttled_to_30_seconds(self, tmp_path: Path) -> None:
|
||||
"""Progress lines are echoed at most every 30 s."""
|
||||
reporter = self._reporter(tmp_path, upto(" 46.90%"))
|
||||
with patch(f"{MOD}.current_progress"), patch(f"{MOD}._echo") as echo:
|
||||
for now in (5.0, 10.0, 29.0, 31.0, 45.0, 62.0):
|
||||
reporter.report(now=now)
|
||||
assert echo.call_args_list == [
|
||||
call(" Fast-installing G: 46% (downloading depot 1/8), 31s elapsed"),
|
||||
call(" Fast-installing G: 46% (downloading depot 1/8), 1m 02s elapsed"),
|
||||
]
|
||||
|
||||
def test_untracked_run_reports_nothing(self) -> None:
|
||||
"""An untracked run moves no bar and echoes only the announcement."""
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo") as echo,
|
||||
):
|
||||
fip.WaitReporter(99, "G", started=0.0).report(now=60.0)
|
||||
progress.phase.assert_not_called()
|
||||
progress.advance.assert_not_called()
|
||||
assert echo.call_count == 1 # the announcement only
|
||||
@ -12,6 +12,12 @@ from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, config
|
||||
from steam_backlog_enforcer._fast_install import (
|
||||
_FAILED,
|
||||
_RUNS,
|
||||
_DetachedRun,
|
||||
_spawn_detached,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -23,7 +29,7 @@ def _run(app_id: int, returncode: int | None, log_path: Path) -> MagicMock:
|
||||
"""Register a fake detached run whose poll() answers *returncode*."""
|
||||
proc = MagicMock(pid=4321)
|
||||
proc.poll.return_value = returncode
|
||||
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log_path))
|
||||
_RUNS.append(_DetachedRun(app_id, proc, log_path))
|
||||
return proc
|
||||
|
||||
|
||||
@ -31,18 +37,21 @@ class TestPollFastInstalls:
|
||||
"""Detached runs are reaped, failures remembered, stalls interrupted."""
|
||||
|
||||
def test_success_is_reaped(self, tmp_path: Path) -> None:
|
||||
"""A run that exits 0 is dropped and not marked failed."""
|
||||
_run(1, 0, tmp_path / "log")
|
||||
_fast_install.poll_fast_installs()
|
||||
assert _fast_install._RUNS == []
|
||||
assert not _fast_install._FAILED
|
||||
assert _RUNS == []
|
||||
assert not _FAILED
|
||||
|
||||
def test_failure_is_remembered(self, tmp_path: Path) -> None:
|
||||
"""A run that exits non-zero is dropped and remembered as failed."""
|
||||
_run(2, 3, tmp_path / "log")
|
||||
_fast_install.poll_fast_installs()
|
||||
assert _fast_install._RUNS == []
|
||||
assert {2} == set(_fast_install._FAILED)
|
||||
assert _RUNS == []
|
||||
assert {2} == set(_FAILED)
|
||||
|
||||
def test_live_and_chatty_is_left_alone(self, tmp_path: Path) -> None:
|
||||
"""A live run with fresh log output is not interrupted."""
|
||||
log = tmp_path / "log"
|
||||
log.write_text("progress\n")
|
||||
_run(3, None, log)
|
||||
@ -52,12 +61,14 @@ class TestPollFastInstalls:
|
||||
assert _fast_install.is_fast_installing(3)
|
||||
|
||||
def test_live_without_log_is_left_alone(self, tmp_path: Path) -> None:
|
||||
"""A live run with no log yet is not interrupted."""
|
||||
_run(4, None, tmp_path / "missing.log")
|
||||
with patch(f"{PKG}.os.killpg") as killpg:
|
||||
_fast_install.poll_fast_installs()
|
||||
killpg.assert_not_called()
|
||||
|
||||
def test_stalled_run_gets_sigint(self, tmp_path: Path) -> None:
|
||||
"""A run silent past STALL_SECONDS gets SIGINT and stays tracked."""
|
||||
log = tmp_path / "log"
|
||||
log.write_text("Enter account password: ")
|
||||
old = log.stat().st_mtime - _fast_install.STALL_SECONDS - 60
|
||||
@ -75,27 +86,29 @@ class TestSpawnDetached:
|
||||
"""The daemon's spawn: own session, no stdin, output to the log."""
|
||||
|
||||
def test_as_user_spawns_directly(self) -> None:
|
||||
"""As a user the installer runs detached, logging to a per-user file."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
_spawn_detached(["run.sh"], 9)
|
||||
args, kwargs = popen.call_args
|
||||
assert args[0] == ["run.sh"]
|
||||
assert kwargs["stdin"] == _fast_install.subprocess.DEVNULL
|
||||
assert kwargs["start_new_session"] is True
|
||||
run = _fast_install._RUNS[0]
|
||||
run = _RUNS[0]
|
||||
assert run.app_id == 9
|
||||
assert run.log_path == config.CONFIG_DIR / "fast_install-1000.log"
|
||||
assert run.log_path.exists()
|
||||
|
||||
def test_as_root_escapes_the_service_cgroup(self) -> None:
|
||||
"""As root the installer is wrapped in a systemd-run scope."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}.shutil.which", return_value="/usr/bin/systemd-run"),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
_spawn_detached(["run.sh"], 9)
|
||||
assert popen.call_args.args[0] == [
|
||||
"systemd-run",
|
||||
"--scope",
|
||||
@ -105,12 +118,13 @@ class TestSpawnDetached:
|
||||
]
|
||||
|
||||
def test_as_root_without_systemd_run(self) -> None:
|
||||
"""As root without systemd-run the installer runs unwrapped."""
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{PKG}.shutil.which", return_value=None),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
_spawn_detached(["run.sh"], 9)
|
||||
assert popen.call_args.args[0] == ["run.sh"]
|
||||
|
||||
|
||||
@ -118,21 +132,25 @@ class TestFastInstall:
|
||||
"""fast_install picks a mode, or tells the caller to fall back."""
|
||||
|
||||
def _ready(self, tmp_path: Path) -> Path:
|
||||
"""Write a stub installer script and return its path."""
|
||||
installer = tmp_path / "run.sh"
|
||||
installer.write_text("#!/bin/bash\n")
|
||||
return installer
|
||||
|
||||
def test_previously_failed_goes_straight_to_fallback(self) -> None:
|
||||
_fast_install._FAILED[7] = "exit 1"
|
||||
"""An app that failed before skips the installer lookup."""
|
||||
_FAILED[7] = "exit 1"
|
||||
with patch(f"{PKG}.installer_path") as find:
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
find.assert_not_called()
|
||||
|
||||
def test_no_installer(self) -> None:
|
||||
"""Without an installer the caller falls back."""
|
||||
# The autouse guard already hides the real installer.
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
|
||||
def test_refused(self, tmp_path: Path) -> None:
|
||||
"""A refusal reason is echoed and nothing is run."""
|
||||
with (
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value="a game is running"),
|
||||
@ -144,6 +162,7 @@ class TestFastInstall:
|
||||
assert "a game is running" in echo.call_args.args[0]
|
||||
|
||||
def test_no_terminal_spawns_detached(self, tmp_path: Path) -> None:
|
||||
"""Without a terminal the installer is spawned detached."""
|
||||
with (
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
@ -156,6 +175,7 @@ class TestFastInstall:
|
||||
run.assert_not_called()
|
||||
|
||||
def test_terminal_runs_in_foreground(self, tmp_path: Path) -> None:
|
||||
"""With a terminal the installer runs in the foreground."""
|
||||
with (
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
@ -164,9 +184,10 @@ class TestFastInstall:
|
||||
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)),
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is True
|
||||
assert not _fast_install._FAILED
|
||||
assert not _FAILED
|
||||
|
||||
def test_foreground_failure_falls_back(self, tmp_path: Path) -> None:
|
||||
"""A foreground non-zero exit marks failure and echoes the code."""
|
||||
with (
|
||||
patch(f"{PKG}.installer_path", return_value=self._ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
@ -175,5 +196,5 @@ class TestFastInstall:
|
||||
patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=2)),
|
||||
):
|
||||
assert _fast_install.fast_install(7, "G") is False
|
||||
assert {7} == set(_fast_install._FAILED)
|
||||
assert {7} == set(_FAILED)
|
||||
assert "exit 2" in echo.call_args.args[0]
|
||||
|
||||
@ -2,145 +2,111 @@
|
||||
|
||||
The real-kernel tests watch a fresh ``tmp_path`` directory, never the real
|
||||
screen-locker tree. The parsing branches the kernel will not produce on demand
|
||||
(queue overflow, a watch torn down) are fed through a pipe carrying hand-packed
|
||||
``inotify_event`` records, which is byte-for-byte what ``os.read`` returns.
|
||||
live in ``test_file_wake_events``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _file_wake
|
||||
from steam_backlog_enforcer._file_wake import FileWake, open_file_wake
|
||||
from steam_backlog_enforcer.tests._file_wake_timing import (
|
||||
LONG,
|
||||
PKG,
|
||||
SHORT,
|
||||
timed_wait,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
_PKG = "steam_backlog_enforcer._file_wake"
|
||||
# Far above real latency so CI cannot flake; elapsed asserts pin "early".
|
||||
_LONG = 5.0
|
||||
_SHORT = 0.05
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def log(tmp_path: Path) -> Path:
|
||||
@pytest.fixture(name="log")
|
||||
def fixture_log(tmp_path: Path) -> Path:
|
||||
"""A ``log.json`` path inside a directory of its own."""
|
||||
directory = tmp_path / "screen_locker"
|
||||
directory.mkdir()
|
||||
return directory / "log.json"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def wake(log: Path) -> Iterator[FileWake]:
|
||||
@pytest.fixture(name="wake")
|
||||
def fixture_wake(log: Path) -> Iterator[FileWake]:
|
||||
"""A live watcher on ``log``, closed afterwards."""
|
||||
watcher = open_file_wake([log])
|
||||
yield watcher
|
||||
watcher.close()
|
||||
|
||||
|
||||
def _timed_wait(watcher: FileWake, timeout: float) -> tuple[bool, float]:
|
||||
"""Run ``watcher.wait`` and measure how long it took."""
|
||||
start = time.monotonic()
|
||||
woke = watcher.wait(timeout)
|
||||
return woke, time.monotonic() - start
|
||||
|
||||
|
||||
def _event(wd: int, mask: int, name: bytes = b"") -> bytes:
|
||||
"""Pack one ``struct inotify_event`` with a NUL-padded name."""
|
||||
padded = name + b"\0" * (16 - len(name) % 16) if name else b""
|
||||
return _file_wake._EVENT_HEADER.pack(wd, mask, 0, len(padded)) + padded
|
||||
|
||||
|
||||
type Piped = Callable[[dict[int, frozenset[str]]], tuple[FileWake, int]]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def piped() -> Iterator[Piped]:
|
||||
"""Build FileWakes reading a non-blocking pipe instead of inotify.
|
||||
|
||||
Teardown goes through ``FileWake.close`` (idempotent), never a raw
|
||||
``os.close`` of the read end: a watcher that degraded already closed it,
|
||||
and the number may since belong to some unrelated descriptor.
|
||||
"""
|
||||
made: list[tuple[FileWake, int]] = []
|
||||
|
||||
def make(names: dict[int, frozenset[str]]) -> tuple[FileWake, int]:
|
||||
read_fd, write_fd = os.pipe()
|
||||
os.set_blocking(read_fd, False)
|
||||
made.append((FileWake(read_fd, names), write_fd))
|
||||
return made[-1]
|
||||
|
||||
yield make
|
||||
for watcher, write_fd in made:
|
||||
watcher.close()
|
||||
os.close(write_fd)
|
||||
|
||||
|
||||
class TestRealInotify:
|
||||
"""Against the kernel, in a tmp directory."""
|
||||
|
||||
def test_atomic_rename_wakes(self, log: Path, wake: FileWake) -> None:
|
||||
"""An atomic rename onto the watched file wakes the wait early."""
|
||||
tmp = log.with_name("log.json.tmp")
|
||||
tmp.write_text("{}")
|
||||
# Drain the temp file's own events: they must not count as a wake.
|
||||
assert wake.wait(_SHORT) is False
|
||||
assert wake.wait(SHORT) is False
|
||||
tmp.replace(log)
|
||||
woke, elapsed = _timed_wait(wake, _LONG)
|
||||
woke, elapsed = timed_wait(wake, LONG)
|
||||
assert woke is True
|
||||
assert elapsed < 1.0
|
||||
|
||||
def test_in_place_write_wakes(self, log: Path, wake: FileWake) -> None:
|
||||
"""Writing the watched file in place wakes the wait early."""
|
||||
log.write_text("{}")
|
||||
woke, elapsed = _timed_wait(wake, _LONG)
|
||||
woke, elapsed = timed_wait(wake, LONG)
|
||||
assert woke is True
|
||||
assert elapsed < 1.0
|
||||
|
||||
def test_other_file_does_not_wake(self, log: Path, wake: FileWake) -> None:
|
||||
"""A write to a sibling file leaves the wait to time out."""
|
||||
log.with_name("other.json").write_text("{}")
|
||||
woke, elapsed = _timed_wait(wake, _SHORT)
|
||||
woke, elapsed = timed_wait(wake, SHORT)
|
||||
assert woke is False
|
||||
assert elapsed >= _SHORT
|
||||
assert elapsed >= SHORT
|
||||
|
||||
def test_times_out_without_events(self, wake: FileWake) -> None:
|
||||
woke, elapsed = _timed_wait(wake, _SHORT)
|
||||
"""With no events the wait runs its full timeout."""
|
||||
woke, elapsed = timed_wait(wake, SHORT)
|
||||
assert woke is False
|
||||
assert elapsed >= _SHORT
|
||||
assert elapsed >= SHORT
|
||||
|
||||
def test_two_files_in_one_directory_share_a_watch(self, log: Path) -> None:
|
||||
"""Two files in one directory both wake through a shared watch."""
|
||||
other = log.with_name("ledger.json")
|
||||
watcher = open_file_wake([log, other])
|
||||
try:
|
||||
other.write_text("{}")
|
||||
assert watcher.wait(_LONG) is True
|
||||
assert watcher.wait(LONG) is True
|
||||
finally:
|
||||
watcher.close()
|
||||
|
||||
def test_one_missing_directory_keeps_the_other_watch(
|
||||
self, log: Path, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A missing directory warns but the other watch still wakes."""
|
||||
missing = tmp_path / "gone" / "log.json"
|
||||
with caplog.at_level(logging.WARNING, logger=_PKG):
|
||||
with caplog.at_level(logging.WARNING, logger=PKG):
|
||||
watcher = open_file_wake([missing, log])
|
||||
try:
|
||||
assert "Cannot watch" in caplog.text
|
||||
log.write_text("{}")
|
||||
assert watcher.wait(_LONG) is True
|
||||
assert watcher.wait(LONG) is True
|
||||
finally:
|
||||
watcher.close()
|
||||
|
||||
def test_close_twice_is_harmless(self, wake: FileWake) -> None:
|
||||
"""Closing twice is safe and later waits fall back to sleep."""
|
||||
wake.close()
|
||||
wake.close()
|
||||
# conftest no-ops time.sleep process-wide, so assert the call instead.
|
||||
with patch(f"{_PKG}.time.sleep") as sleep:
|
||||
assert wake.wait(_SHORT) is False
|
||||
with patch(f"{PKG}.time.sleep") as sleep:
|
||||
assert wake.wait(SHORT) is False
|
||||
sleep.assert_called_once()
|
||||
|
||||
|
||||
@ -148,102 +114,41 @@ class TestSetupFallback:
|
||||
"""Every setup failure warns and degrades to a plain sleep."""
|
||||
|
||||
def _assert_plain_sleep(self, watcher: FileWake) -> None:
|
||||
with patch(f"{_PKG}.time.sleep") as sleep:
|
||||
assert watcher.wait(_LONG) is False
|
||||
"""Assert ``watcher.wait`` degrades to one full-length sleep."""
|
||||
with patch(f"{PKG}.time.sleep") as sleep:
|
||||
assert watcher.wait(LONG) is False
|
||||
sleep.assert_called_once()
|
||||
assert sleep.call_args.args[0] == pytest.approx(_LONG, abs=0.5)
|
||||
assert sleep.call_args.args[0] == pytest.approx(LONG, abs=0.5)
|
||||
|
||||
def test_unwatchable_directory(
|
||||
self, tmp_path: Path, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
with caplog.at_level(logging.WARNING, logger=_PKG):
|
||||
"""An unwatchable directory warns, names the file, and sleeps."""
|
||||
with caplog.at_level(logging.WARNING, logger=PKG):
|
||||
watcher = open_file_wake([tmp_path / "missing" / "log.json"])
|
||||
assert "Cannot watch" in caplog.text
|
||||
assert "log.json" in caplog.text
|
||||
self._assert_plain_sleep(watcher)
|
||||
|
||||
def test_no_libc(self, log: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""Missing libc warns and degrades to a plain sleep."""
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger=_PKG),
|
||||
patch(f"{_PKG}.ctypes.CDLL", side_effect=OSError("no libc")),
|
||||
caplog.at_level(logging.WARNING, logger=PKG),
|
||||
patch(f"{PKG}.ctypes.CDLL", side_effect=OSError("no libc")),
|
||||
):
|
||||
watcher = open_file_wake([log])
|
||||
assert "inotify unavailable (no libc)" in caplog.text
|
||||
self._assert_plain_sleep(watcher)
|
||||
|
||||
def test_init_fails(self, log: Path, caplog: pytest.LogCaptureFixture) -> None:
|
||||
"""A failed ``inotify_init1`` warns, adds no watch, and sleeps."""
|
||||
libc = MagicMock()
|
||||
libc.inotify_init1.return_value = -1
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger=_PKG),
|
||||
patch(f"{_PKG}.ctypes.CDLL", return_value=libc),
|
||||
caplog.at_level(logging.WARNING, logger=PKG),
|
||||
patch(f"{PKG}.ctypes.CDLL", return_value=libc),
|
||||
):
|
||||
watcher = open_file_wake([log])
|
||||
assert "inotify_init1 failed" in caplog.text
|
||||
libc.inotify_add_watch.assert_not_called()
|
||||
self._assert_plain_sleep(watcher)
|
||||
|
||||
|
||||
class TestEventParsing:
|
||||
"""Branches fed through a pipe of hand-packed events."""
|
||||
|
||||
def test_queue_overflow_wakes(self, piped: Piped) -> None:
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
# wd -1 with no name: an overflow says nothing about *which* file, so
|
||||
# it must count as "maybe changed" rather than be dropped.
|
||||
os.write(write_fd, _event(-1, _file_wake._IN_Q_OVERFLOW))
|
||||
woke, elapsed = _timed_wait(watcher, _LONG)
|
||||
assert woke is True
|
||||
assert elapsed < 1.0
|
||||
|
||||
def test_unrelated_event_on_unknown_wd_is_ignored(self, piped: Piped) -> None:
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, _event(7, _file_wake._IN_CLOSE_WRITE, b"log.json"))
|
||||
assert watcher.wait(_SHORT) is False
|
||||
|
||||
def test_removed_watch_degrades_with_warning(
|
||||
self, piped: Piped, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, _event(1, _file_wake._IN_IGNORED))
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger=_PKG),
|
||||
patch(f"{_PKG}.time.sleep") as sleep,
|
||||
):
|
||||
assert watcher.wait(_LONG) is False
|
||||
assert "the watched directory was removed" in caplog.text
|
||||
sleep.assert_called_once()
|
||||
|
||||
def test_one_removed_watch_of_two_keeps_watching(
|
||||
self, piped: Piped, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
names = {1: frozenset({"a.json"}), 2: frozenset({"b.json"})}
|
||||
watcher, write_fd = piped(names)
|
||||
os.write(write_fd, _event(1, _file_wake._IN_IGNORED))
|
||||
with caplog.at_level(logging.WARNING, logger=_PKG):
|
||||
assert watcher.wait(_SHORT) is False
|
||||
assert caplog.text == ""
|
||||
os.write(write_fd, _event(2, _file_wake._IN_MOVED_TO, b"b.json"))
|
||||
assert watcher.wait(_LONG) is True
|
||||
|
||||
def test_spurious_readiness_keeps_waiting(self, piped: Piped) -> None:
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, b"x")
|
||||
with patch(f"{_PKG}.os.read", side_effect=BlockingIOError):
|
||||
woke, elapsed = _timed_wait(watcher, _SHORT)
|
||||
assert woke is False
|
||||
assert elapsed >= _SHORT
|
||||
|
||||
def test_read_error_degrades_with_warning(
|
||||
self, piped: Piped, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, b"x")
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger=_PKG),
|
||||
patch(f"{_PKG}.os.read", side_effect=OSError("EIO")),
|
||||
patch(f"{_PKG}.time.sleep") as sleep,
|
||||
):
|
||||
assert watcher.wait(_LONG) is False
|
||||
assert "reading inotify events failed: EIO" in caplog.text
|
||||
sleep.assert_called_once()
|
||||
|
||||
135
steam_backlog_enforcer/tests/test_file_wake_events.py
Normal file
135
steam_backlog_enforcer/tests/test_file_wake_events.py
Normal file
@ -0,0 +1,135 @@
|
||||
"""Tests for _file_wake's event parsing, fed through a pipe.
|
||||
|
||||
The branches the kernel will not produce on demand (queue overflow, a watch
|
||||
torn down) are fed through a pipe carrying hand-packed ``inotify_event``
|
||||
records, which is byte-for-byte what ``os.read`` returns.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._file_wake import (
|
||||
_EVENT_HEADER,
|
||||
_IN_CLOSE_WRITE,
|
||||
_IN_IGNORED,
|
||||
_IN_MOVED_TO,
|
||||
_IN_Q_OVERFLOW,
|
||||
FileWake,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._file_wake_timing import (
|
||||
LONG,
|
||||
PKG,
|
||||
SHORT,
|
||||
timed_wait,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
|
||||
def _event(wd: int, mask: int, name: bytes = b"") -> bytes:
|
||||
"""Pack one ``struct inotify_event`` with a NUL-padded name."""
|
||||
padded = name + b"\0" * (16 - len(name) % 16) if name else b""
|
||||
return _EVENT_HEADER.pack(wd, mask, 0, len(padded)) + padded
|
||||
|
||||
|
||||
type Piped = Callable[[dict[int, frozenset[str]]], tuple[FileWake, int]]
|
||||
|
||||
|
||||
@pytest.fixture(name="piped")
|
||||
def fixture_piped() -> Iterator[Piped]:
|
||||
"""Build FileWakes reading a non-blocking pipe instead of inotify.
|
||||
|
||||
Teardown goes through ``FileWake.close`` (idempotent), never a raw
|
||||
``os.close`` of the read end: a watcher that degraded already closed it,
|
||||
and the number may since belong to some unrelated descriptor.
|
||||
"""
|
||||
made: list[tuple[FileWake, int]] = []
|
||||
|
||||
def make(names: dict[int, frozenset[str]]) -> tuple[FileWake, int]:
|
||||
read_fd, write_fd = os.pipe()
|
||||
os.set_blocking(read_fd, False)
|
||||
made.append((FileWake(read_fd, names), write_fd))
|
||||
return made[-1]
|
||||
|
||||
yield make
|
||||
for watcher, write_fd in made:
|
||||
watcher.close()
|
||||
os.close(write_fd)
|
||||
|
||||
|
||||
class TestEventParsing:
|
||||
"""Branches fed through a pipe of hand-packed events."""
|
||||
|
||||
def test_queue_overflow_wakes(self, piped: Piped) -> None:
|
||||
"""A queue overflow counts as a possible change and wakes."""
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
# wd -1 with no name: an overflow says nothing about *which* file, so
|
||||
# it must count as "maybe changed" rather than be dropped.
|
||||
os.write(write_fd, _event(-1, _IN_Q_OVERFLOW))
|
||||
woke, elapsed = timed_wait(watcher, LONG)
|
||||
assert woke is True
|
||||
assert elapsed < 1.0
|
||||
|
||||
def test_unrelated_event_on_unknown_wd_is_ignored(self, piped: Piped) -> None:
|
||||
"""An event on an unknown watch descriptor does not wake."""
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, _event(7, _IN_CLOSE_WRITE, b"log.json"))
|
||||
assert watcher.wait(SHORT) is False
|
||||
|
||||
def test_removed_watch_degrades_with_warning(
|
||||
self, piped: Piped, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Losing the only watch warns and degrades to a sleep."""
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, _event(1, _IN_IGNORED))
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger=PKG),
|
||||
patch(f"{PKG}.time.sleep") as sleep,
|
||||
):
|
||||
assert watcher.wait(LONG) is False
|
||||
assert "the watched directory was removed" in caplog.text
|
||||
sleep.assert_called_once()
|
||||
|
||||
def test_one_removed_watch_of_two_keeps_watching(
|
||||
self, piped: Piped, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""Losing one of two watches stays silent and keeps waking."""
|
||||
names = {1: frozenset({"a.json"}), 2: frozenset({"b.json"})}
|
||||
watcher, write_fd = piped(names)
|
||||
os.write(write_fd, _event(1, _IN_IGNORED))
|
||||
with caplog.at_level(logging.WARNING, logger=PKG):
|
||||
assert watcher.wait(SHORT) is False
|
||||
assert caplog.text == ""
|
||||
os.write(write_fd, _event(2, _IN_MOVED_TO, b"b.json"))
|
||||
assert watcher.wait(LONG) is True
|
||||
|
||||
def test_spurious_readiness_keeps_waiting(self, piped: Piped) -> None:
|
||||
"""A readable fd with nothing to read keeps waiting."""
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, b"x")
|
||||
with patch(f"{PKG}.os.read", side_effect=BlockingIOError):
|
||||
woke, elapsed = timed_wait(watcher, SHORT)
|
||||
assert woke is False
|
||||
assert elapsed >= SHORT
|
||||
|
||||
def test_read_error_degrades_with_warning(
|
||||
self, piped: Piped, caplog: pytest.LogCaptureFixture
|
||||
) -> None:
|
||||
"""A read error warns and degrades to a sleep."""
|
||||
watcher, write_fd = piped({1: frozenset({"log.json"})})
|
||||
os.write(write_fd, b"x")
|
||||
with (
|
||||
caplog.at_level(logging.WARNING, logger=PKG),
|
||||
patch(f"{PKG}.os.read", side_effect=OSError("EIO")),
|
||||
patch(f"{PKG}.time.sleep") as sleep,
|
||||
):
|
||||
assert watcher.wait(LONG) is False
|
||||
assert "reading inotify events failed: EIO" in caplog.text
|
||||
sleep.assert_called_once()
|
||||
@ -16,41 +16,34 @@ from unittest.mock import patch
|
||||
import earned_time
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _ledger_earners, _leetcode_bonus, _workout_budget
|
||||
from steam_backlog_enforcer import _ledger_earners
|
||||
from steam_backlog_enforcer._budget_resolve import resolve_budget
|
||||
from steam_backlog_enforcer._ledger_earners import first_credits
|
||||
from steam_backlog_enforcer.config import Config
|
||||
from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today
|
||||
from steam_backlog_enforcer.tests._ledger_fixtures import KEY, credit, write_ledger
|
||||
from steam_backlog_enforcer.tests._piano_earner import (
|
||||
ANSWER,
|
||||
HOUR,
|
||||
LEETCODE,
|
||||
WORKOUT,
|
||||
earner_caches_cleared,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._piano_earner import (
|
||||
PIANO as BASE_PIANO,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
_WORKOUT = "steam_backlog_enforcer._workout_budget._fetch_workout_today"
|
||||
_LEETCODE = "steam_backlog_enforcer._leetcode_bonus.read_ledger_solved_today"
|
||||
_ANSWER = "steam_backlog_enforcer._budget_resolve.ledger_answer"
|
||||
_HOUR = 3600.0
|
||||
_DAY = timedelta(days=1)
|
||||
|
||||
|
||||
def _any_credit(_row: dict[str, object], _window: tuple[float, float]) -> bool:
|
||||
"""Count every verified credit: the reader's own dating is earned_time's."""
|
||||
return True
|
||||
|
||||
|
||||
# Penalised since well before the suite's pinned day, never confirmed by kuhy:
|
||||
# without a real credit it is a new gate and costs nothing.
|
||||
PIANO = earned_time.Earner(
|
||||
name="piano",
|
||||
label="Piano",
|
||||
gaming_minutes=30,
|
||||
shutdown_minutes=30,
|
||||
ledger=".local/share/piano_guard/ledger.json",
|
||||
match=_any_credit,
|
||||
penalty_from=AFTER_CUT - 5 * _DAY,
|
||||
)
|
||||
PIANO = replace(BASE_PIANO, penalty_from=AFTER_CUT - 5 * _DAY)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@ -60,15 +53,12 @@ def _clear_caches() -> Iterator[None]:
|
||||
Yields:
|
||||
None, with the caches empty before and after.
|
||||
"""
|
||||
for module in (_ledger_earners, _workout_budget, _leetcode_bonus):
|
||||
module.reset_cache()
|
||||
yield
|
||||
for module in (_ledger_earners, _workout_budget, _leetcode_bonus):
|
||||
module.reset_cache()
|
||||
with earner_caches_cleared():
|
||||
yield
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def key(tmp_path: Path) -> Iterator[Path]:
|
||||
@pytest.fixture(name="key")
|
||||
def fixture_key(tmp_path: Path) -> Iterator[Path]:
|
||||
"""Sign and verify the stand-in's ledger with a throwaway key.
|
||||
|
||||
Args:
|
||||
@ -96,9 +86,9 @@ def _base(day: date) -> float:
|
||||
with (
|
||||
pin_today(day),
|
||||
patch("earned_time.EARNERS", (*earned_time.EARNERS, PIANO)),
|
||||
patch(_WORKOUT, return_value=False),
|
||||
patch(_LEETCODE, return_value=False),
|
||||
patch(_ANSWER, return_value=False),
|
||||
patch(WORKOUT, return_value=False),
|
||||
patch(LEETCODE, return_value=False),
|
||||
patch(ANSWER, return_value=False),
|
||||
):
|
||||
return resolve_budget(Config()).base_seconds
|
||||
|
||||
@ -145,7 +135,8 @@ class TestTheBudget:
|
||||
"""The cut waits for the day after the first real credit."""
|
||||
|
||||
def test_a_gate_that_never_paid_out_costs_nothing(self) -> None:
|
||||
assert _base(AFTER_CUT) == 4 * _HOUR
|
||||
"""An earner with no credit ever leaves the floor uncut."""
|
||||
assert _base(AFTER_CUT) == 4 * HOUR
|
||||
|
||||
def test_the_cut_starts_the_day_after_the_first_credit(
|
||||
self, tmp_path: Path, key: Path
|
||||
@ -158,8 +149,8 @@ class TestTheBudget:
|
||||
"""
|
||||
assert key.exists()
|
||||
_credit_on(tmp_path, AFTER_CUT)
|
||||
assert _base(AFTER_CUT) == 4 * _HOUR
|
||||
assert _base(AFTER_CUT + _DAY) == 3.5 * _HOUR
|
||||
assert _base(AFTER_CUT) == 4 * HOUR
|
||||
assert _base(AFTER_CUT + _DAY) == 3.5 * HOUR
|
||||
|
||||
def test_an_old_earned_time_resolves_as_before(
|
||||
self, monkeypatch: pytest.MonkeyPatch
|
||||
@ -170,4 +161,4 @@ class TestTheBudget:
|
||||
monkeypatch: pytest's attribute patcher.
|
||||
"""
|
||||
monkeypatch.delattr(earned_time, "maturity")
|
||||
assert _base(AFTER_CUT) == 3.5 * _HOUR
|
||||
assert _base(AFTER_CUT) == 3.5 * HOUR
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@ -81,18 +82,22 @@ class TestEcho:
|
||||
"""Tests for _echo."""
|
||||
|
||||
def test_default(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A message prints with a trailing newline."""
|
||||
_echo("hello")
|
||||
assert capsys.readouterr().out == "hello\n"
|
||||
|
||||
def test_custom_end(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""A custom end replaces the newline."""
|
||||
_echo("hi", end="")
|
||||
assert capsys.readouterr().out == "hi"
|
||||
|
||||
def test_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""No arguments prints a bare newline."""
|
||||
_echo()
|
||||
assert capsys.readouterr().out == "\n"
|
||||
|
||||
def test_flush(self, capsys: pytest.CaptureFixture[str]) -> None:
|
||||
"""flush=True still prints the message once."""
|
||||
_echo("x", flush=True)
|
||||
assert capsys.readouterr().out == "x\n"
|
||||
|
||||
@ -101,12 +106,14 @@ class TestTriggerSteamInstall:
|
||||
"""Tests for _trigger_steam_install."""
|
||||
|
||||
def test_success(self) -> None:
|
||||
"""A launched steam:// handler reports True."""
|
||||
with patch("steam_backlog_enforcer.game_install.subprocess.run") as mock_run:
|
||||
result = _trigger_steam_install(440, "TF2")
|
||||
assert result is True
|
||||
mock_run.assert_called_once()
|
||||
|
||||
def test_file_not_found(self) -> None:
|
||||
"""A missing handler binary reports False."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.game_install.subprocess.run",
|
||||
side_effect=FileNotFoundError,
|
||||
@ -115,6 +122,7 @@ class TestTriggerSteamInstall:
|
||||
assert result is False
|
||||
|
||||
def test_os_error(self) -> None:
|
||||
"""An OS error launching the handler reports False."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.game_install.subprocess.run",
|
||||
side_effect=OSError,
|
||||
@ -123,8 +131,7 @@ class TestTriggerSteamInstall:
|
||||
assert result is False
|
||||
|
||||
def test_timeout(self) -> None:
|
||||
import subprocess
|
||||
|
||||
"""A handler that times out reports False."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer.game_install.subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired("cmd", 15),
|
||||
@ -137,10 +144,12 @@ class TestGetRealUser:
|
||||
"""Tests for _get_real_user."""
|
||||
|
||||
def test_sudo_user(self) -> None:
|
||||
"""Under sudo the invoking user is the real user."""
|
||||
with patch.dict(os.environ, {"SUDO_USER": "alice", "USER": "root"}):
|
||||
assert _get_real_user() == "alice"
|
||||
|
||||
def test_regular_user(self) -> None:
|
||||
"""Without sudo USER is the real user."""
|
||||
with patch.dict(os.environ, {"USER": "bob"}, clear=False):
|
||||
env = os.environ.copy()
|
||||
env.pop("SUDO_USER", None)
|
||||
@ -152,6 +161,7 @@ class TestGetUidGid:
|
||||
"""Tests for _get_uid_gid_for_user."""
|
||||
|
||||
def test_known_user(self) -> None:
|
||||
"""A known user maps to their passwd uid and gid."""
|
||||
mock_pw = MagicMock()
|
||||
mock_pw.pw_uid = 1001
|
||||
mock_pw.pw_gid = 1001
|
||||
@ -162,6 +172,7 @@ class TestGetUidGid:
|
||||
assert _get_uid_gid_for_user("alice") == (1001, 1001)
|
||||
|
||||
def test_unknown_user(self) -> None:
|
||||
"""An unknown user falls back to 1000:1000."""
|
||||
with patch(
|
||||
"steam_backlog_enforcer._steam_client.pwd.getpwnam",
|
||||
side_effect=KeyError,
|
||||
@ -173,12 +184,14 @@ class TestIsGameInstalled:
|
||||
"""Tests for is_game_installed."""
|
||||
|
||||
def test_installed(self, tmp_path: Path) -> None:
|
||||
"""A present app manifest means the game is installed."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.touch()
|
||||
with patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path):
|
||||
assert is_game_installed(440) is True
|
||||
|
||||
def test_not_installed(self, tmp_path: Path) -> None:
|
||||
"""A missing app manifest means the game is not installed."""
|
||||
with patch("steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", tmp_path):
|
||||
assert is_game_installed(440) is False
|
||||
|
||||
@ -193,6 +206,7 @@ class TestTriggerSteamInstallPrivilegeDrop:
|
||||
_PKG = "steam_backlog_enforcer.game_install"
|
||||
|
||||
def test_root_drops_to_desktop_user(self) -> None:
|
||||
"""As root the handler runs via sudo as the desktop user."""
|
||||
with (
|
||||
patch(f"{self._PKG}.os.geteuid", return_value=0),
|
||||
patch(f"{self._PKG}._get_real_user", return_value="kuhy"),
|
||||
@ -207,6 +221,7 @@ class TestTriggerSteamInstallPrivilegeDrop:
|
||||
assert argv[-1] == "steam://install/440"
|
||||
|
||||
def test_non_root_does_not_wrap(self) -> None:
|
||||
"""As a user the handler runs without sudo."""
|
||||
with (
|
||||
patch(f"{self._PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{self._PKG}._get_real_user", return_value="kuhy"),
|
||||
|
||||
@ -9,7 +9,8 @@ import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer.game_install import install_game
|
||||
from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed
|
||||
from steam_backlog_enforcer.game_install import _LIBRARY_WARNED, install_game
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -28,6 +29,7 @@ class TestInstallGameLibraryGuard:
|
||||
_PKG = "steam_backlog_enforcer.game_install"
|
||||
|
||||
def test_skips_when_library_missing(self, tmp_path: Path) -> None:
|
||||
"""With no Steam library, install neither starts Steam nor triggers."""
|
||||
missing = tmp_path / "steamapps" # deliberately not created
|
||||
with (
|
||||
patch(f"{self._PKG}.STEAMAPPS_PATH", missing),
|
||||
@ -43,6 +45,7 @@ class TestInstallGameLibraryGuard:
|
||||
mock_trigger.assert_not_called()
|
||||
|
||||
def test_proceeds_when_library_present(self, tmp_path: Path) -> None:
|
||||
"""With a library present, install goes ahead and succeeds."""
|
||||
with (
|
||||
patch(f"{self._PKG}.STEAMAPPS_PATH", tmp_path),
|
||||
patch(f"{self._PKG}.steam_library_ready", return_value=True),
|
||||
@ -63,8 +66,7 @@ class TestReinstallMissingAllowedGuard:
|
||||
_PKG = "steam_backlog_enforcer._enforce_steps"
|
||||
|
||||
def test_skips_when_library_missing(self) -> None:
|
||||
from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed
|
||||
|
||||
"""With no library, reinstall never even lists allowed games."""
|
||||
with (
|
||||
patch(f"{self._PKG}.steam_library_ready", return_value=False),
|
||||
patch(f"{self._PKG}.allowed_games") as mock_allowed,
|
||||
@ -76,8 +78,7 @@ class TestReinstallMissingAllowedGuard:
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_proceeds_when_library_present(self) -> None:
|
||||
from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed
|
||||
|
||||
"""With a library, a missing allowed game is reinstalled."""
|
||||
with (
|
||||
patch(f"{self._PKG}.steam_library_ready", return_value=True),
|
||||
patch(f"{self._PKG}.allowed_games", return_value=[(440, "TF2")]),
|
||||
@ -99,8 +100,7 @@ class TestLibraryWarningLatch:
|
||||
_PKG = "steam_backlog_enforcer.game_install"
|
||||
|
||||
def test_warns_once_then_debugs(self, caplog: pytest.LogCaptureFixture) -> None:
|
||||
from steam_backlog_enforcer.game_install import _LIBRARY_WARNED
|
||||
|
||||
"""Repeated misses warn once, then log at debug."""
|
||||
_LIBRARY_WARNED.discard("missing")
|
||||
with (
|
||||
patch(f"{self._PKG}.steam_library_ready", return_value=False),
|
||||
@ -118,8 +118,6 @@ class TestLibraryWarningLatch:
|
||||
|
||||
def test_latch_resets_when_library_returns(self) -> None:
|
||||
"""Signing in to Steam must re-arm the warning for a later outage."""
|
||||
from steam_backlog_enforcer.game_install import _LIBRARY_WARNED
|
||||
|
||||
_LIBRARY_WARNED.add("missing")
|
||||
with (
|
||||
patch(f"{self._PKG}.steam_library_ready", return_value=True),
|
||||
|
||||
@ -24,6 +24,7 @@ class TestRemoveGameDirs:
|
||||
"""Tests for _remove_game_dirs."""
|
||||
|
||||
def test_removes_install_dir(self, tmp_path: Path) -> None:
|
||||
"""An existing install dir is deleted and reported as success."""
|
||||
install_dir = tmp_path / "common" / "MyGame"
|
||||
install_dir.mkdir(parents=True)
|
||||
(install_dir / "game.exe").touch()
|
||||
@ -33,17 +34,20 @@ class TestRemoveGameDirs:
|
||||
assert not install_dir.exists()
|
||||
|
||||
def test_install_dir_none(self, tmp_path: Path) -> None:
|
||||
"""No install dir to remove still succeeds."""
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
result = _remove_game_dirs(None, 440)
|
||||
assert result is True
|
||||
|
||||
def test_install_dir_not_exists(self, tmp_path: Path) -> None:
|
||||
"""A missing install dir still succeeds."""
|
||||
missing = tmp_path / "common" / "Missing"
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
result = _remove_game_dirs(missing, 440)
|
||||
assert result is True
|
||||
|
||||
def test_install_dir_remove_fails(self, tmp_path: Path) -> None:
|
||||
"""An rmtree failure on the install dir reports failure."""
|
||||
install_dir = tmp_path / "common" / "MyGame"
|
||||
install_dir.mkdir(parents=True)
|
||||
with (
|
||||
@ -54,6 +58,7 @@ class TestRemoveGameDirs:
|
||||
assert result is False
|
||||
|
||||
def test_removes_cache_dirs(self, tmp_path: Path) -> None:
|
||||
"""The app's shadercache and compatdata dirs are deleted."""
|
||||
for subdir in ("shadercache", "compatdata"):
|
||||
(tmp_path / subdir / "440").mkdir(parents=True)
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
@ -63,6 +68,7 @@ class TestRemoveGameDirs:
|
||||
assert not (tmp_path / "compatdata" / "440").exists()
|
||||
|
||||
def test_cache_dir_remove_oserror_suppressed(self, tmp_path: Path) -> None:
|
||||
"""A cache-dir rmtree failure does not fail the removal."""
|
||||
(tmp_path / "shadercache" / "440").mkdir(parents=True)
|
||||
call_count = 0
|
||||
|
||||
@ -84,6 +90,7 @@ class TestUninstallGame:
|
||||
"""Tests for uninstall_game."""
|
||||
|
||||
def test_success(self, tmp_path: Path) -> None:
|
||||
"""Manifest and install dir present: uninstall succeeds."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8")
|
||||
install_dir = tmp_path / "common" / "TF2"
|
||||
@ -93,6 +100,7 @@ class TestUninstallGame:
|
||||
assert result is True
|
||||
|
||||
def test_manifest_removal_fails(self) -> None:
|
||||
"""A failed manifest removal fails the uninstall."""
|
||||
mock_manifest = MagicMock()
|
||||
mock_manifest.exists.return_value = True
|
||||
mock_manifest.unlink.side_effect = OSError
|
||||
@ -106,6 +114,7 @@ class TestUninstallGame:
|
||||
assert result is False
|
||||
|
||||
def test_game_dirs_removal_fails(self) -> None:
|
||||
"""A failed game-dir removal fails the uninstall."""
|
||||
with (
|
||||
patch(f"{GAME_UNINSTALL_PKG}._read_install_dir", return_value=None),
|
||||
patch(f"{GAME_UNINSTALL_PKG}._remove_manifest", return_value=True),
|
||||
@ -119,6 +128,7 @@ class TestUninstallOtherGames:
|
||||
"""Tests for uninstall_other_games."""
|
||||
|
||||
def test_keeps_allowed(self) -> None:
|
||||
"""Allowed games stay; every other game is uninstalled."""
|
||||
with (
|
||||
patch(
|
||||
f"{GAME_UNINSTALL_PKG}.get_installed_games",
|
||||
@ -133,6 +143,7 @@ class TestUninstallOtherGames:
|
||||
mock_uninstall.assert_called_once_with(730, "CS")
|
||||
|
||||
def test_skips_protected(self) -> None:
|
||||
"""Protected apps such as redistributables are never uninstalled."""
|
||||
with (
|
||||
patch(
|
||||
f"{GAME_UNINSTALL_PKG}.get_installed_games",
|
||||
@ -145,6 +156,7 @@ class TestUninstallOtherGames:
|
||||
mock_uninstall.assert_not_called()
|
||||
|
||||
def test_uninstall_fails(self) -> None:
|
||||
"""A failed uninstall is not counted."""
|
||||
with (
|
||||
patch(
|
||||
f"{GAME_UNINSTALL_PKG}.get_installed_games",
|
||||
@ -156,6 +168,7 @@ class TestUninstallOtherGames:
|
||||
assert count == 0
|
||||
|
||||
def test_all_allowed_or_protected(self) -> None:
|
||||
"""Nothing to remove means no uninstall calls."""
|
||||
with (
|
||||
patch(
|
||||
f"{GAME_UNINSTALL_PKG}.get_installed_games",
|
||||
@ -172,6 +185,7 @@ class TestUninstallProgressNames:
|
||||
"""The progress bar names only games that are actually removed."""
|
||||
|
||||
def test_protected_and_allowed_never_shown(self) -> None:
|
||||
"""Only the game actually removed shows in the progress bar."""
|
||||
shown: list[str] = []
|
||||
|
||||
def fake_tracked(
|
||||
|
||||
@ -23,6 +23,7 @@ class TestInstallGame:
|
||||
"""Tests for install_game."""
|
||||
|
||||
def test_already_installed(self, tmp_path: Path) -> None:
|
||||
"""A fully installed game returns True without a fast install."""
|
||||
(tmp_path / "appmanifest_440.acf").write_text('"StateFlags"\t\t"4"\n')
|
||||
with (
|
||||
patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", tmp_path),
|
||||
@ -32,6 +33,7 @@ class TestInstallGame:
|
||||
fast.assert_not_called()
|
||||
|
||||
def test_use_steam_protocol_success(self, tmp_path: Path) -> None:
|
||||
"""The steam:// protocol path succeeds when the trigger does."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
@ -46,6 +48,7 @@ class TestInstallGame:
|
||||
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
|
||||
|
||||
def test_use_steam_protocol_fallback(self, tmp_path: Path) -> None:
|
||||
"""A failed protocol trigger falls back to writing the manifest."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
@ -65,6 +68,7 @@ class TestInstallGame:
|
||||
assert (tmp_path / "appmanifest_440.acf").exists()
|
||||
|
||||
def test_manifest_write_as_root(self, tmp_path: Path) -> None:
|
||||
"""Writing the manifest as root chowns it to the real user."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
@ -89,6 +93,7 @@ class TestInstallGame:
|
||||
mock_chown.assert_called_once()
|
||||
|
||||
def test_manifest_write_failure(self, tmp_path: Path) -> None:
|
||||
"""An unwritable steamapps dir makes the install fail."""
|
||||
# Make steamapps path not writable
|
||||
with (
|
||||
patch(
|
||||
@ -104,6 +109,7 @@ class TestInstallGame:
|
||||
assert install_game(440, "TF2", "s1") is False
|
||||
|
||||
def test_empty_game_name(self, tmp_path: Path) -> None:
|
||||
"""An empty game name still installs."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
@ -118,6 +124,7 @@ class TestInstallGame:
|
||||
assert install_game(440, "", "s1") is True
|
||||
|
||||
def test_manifest_not_root_no_chown(self, tmp_path: Path) -> None:
|
||||
"""A non-root install does not chown the manifest."""
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
|
||||
@ -159,6 +166,7 @@ class TestGetInstalledGames:
|
||||
"""Tests for get_installed_games."""
|
||||
|
||||
def test_parses_manifests(self, tmp_path: Path) -> None:
|
||||
"""App id and name are read from each manifest."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n')
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
@ -166,6 +174,7 @@ class TestGetInstalledGames:
|
||||
assert result == [(440, "Team Fortress 2")]
|
||||
|
||||
def test_no_name(self, tmp_path: Path) -> None:
|
||||
"""A manifest without a name gets an Unknown placeholder."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"appid"\t\t"440"\n')
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
@ -173,11 +182,13 @@ class TestGetInstalledGames:
|
||||
assert result == [(440, "Unknown (440)")]
|
||||
|
||||
def test_empty_dir(self, tmp_path: Path) -> None:
|
||||
"""An empty steamapps dir lists no games."""
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
result = get_installed_games()
|
||||
assert result == []
|
||||
|
||||
def test_no_appid_match(self, tmp_path: Path) -> None:
|
||||
"""A manifest without an app id is skipped."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"name"\t\t"NoAppId"\n')
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
@ -189,6 +200,7 @@ class TestReadInstallDir:
|
||||
"""Tests for _read_install_dir."""
|
||||
|
||||
def test_reads_dir(self, tmp_path: Path) -> None:
|
||||
"""installdir resolves under steamapps/common."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"installdir"\t\t"Team Fortress 2"\n')
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
@ -196,16 +208,19 @@ class TestReadInstallDir:
|
||||
assert result == tmp_path / "common" / "Team Fortress 2"
|
||||
|
||||
def test_no_match(self, tmp_path: Path) -> None:
|
||||
"""A manifest without installdir yields None."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.write_text('"appid"\t\t"440"\n')
|
||||
with patch("steam_backlog_enforcer.game_uninstall.STEAMAPPS_PATH", tmp_path):
|
||||
assert _read_install_dir(manifest) is None
|
||||
|
||||
def test_missing_file(self, tmp_path: Path) -> None:
|
||||
"""A missing manifest yields None."""
|
||||
manifest = tmp_path / "nonexistent.acf"
|
||||
assert _read_install_dir(manifest) is None
|
||||
|
||||
def test_os_error(self, tmp_path: Path) -> None:
|
||||
def test_os_error(self) -> None:
|
||||
"""A read error yields None."""
|
||||
manifest = MagicMock()
|
||||
manifest.exists.return_value = True
|
||||
manifest.read_text.side_effect = OSError
|
||||
@ -216,16 +231,19 @@ class TestRemoveManifest:
|
||||
"""Tests for _remove_manifest."""
|
||||
|
||||
def test_removes(self, tmp_path: Path) -> None:
|
||||
"""An existing manifest is deleted."""
|
||||
manifest = tmp_path / "appmanifest_440.acf"
|
||||
manifest.touch()
|
||||
assert _remove_manifest(manifest, "TF2", 440) is True
|
||||
assert not manifest.exists()
|
||||
|
||||
def test_already_gone(self, tmp_path: Path) -> None:
|
||||
"""A manifest already gone counts as removed."""
|
||||
manifest = tmp_path / "nonexistent.acf"
|
||||
assert _remove_manifest(manifest, "TF2", 440) is True
|
||||
|
||||
def test_os_error(self) -> None:
|
||||
"""An unlink error reports failure."""
|
||||
manifest = MagicMock()
|
||||
manifest.exists.return_value = True
|
||||
manifest.unlink.side_effect = OSError
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user