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:
Krzysztof Rudnicki 2026-11-11 12:17:07 +01:00
parent 1e452b0fcd
commit d2565416aa
278 changed files with 6976 additions and 6217 deletions

View File

@ -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]

View File

@ -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:

View File

@ -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:

View File

@ -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:

View File

@ -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)

View 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())

View 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)

View 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)

View 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

View 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

View 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)

View File

@ -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

View 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])

View 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

View 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,
)

View 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

View 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))

View File

@ -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"

View File

@ -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:

View 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

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

View 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

View 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),
}

View 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

View File

@ -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()

View File

@ -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),

View 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()

View 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

View 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()}

View 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

View 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

View 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

View 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

View 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)

View 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,
}
}

View 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()

View File

@ -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)))

View File

@ -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()

View File

@ -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)

View File

@ -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."""

View File

@ -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"}

View File

@ -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",

View File

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

View File

@ -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([]) == ([], [])

View File

@ -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 == []

View File

@ -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())

View File

@ -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]

View File

@ -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,

View File

@ -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",

View File

@ -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(

View 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)

View File

@ -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,

View 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

View File

@ -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)

View File

@ -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 (

View File

@ -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

View File

@ -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

View File

@ -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}

View File

@ -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()

View File

@ -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),
):

View File

@ -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

View File

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

View File

@ -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

View File

@ -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()

View 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",

View File

@ -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

View File

@ -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]}')

View File

@ -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 (

View File

@ -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

View File

@ -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)

View File

@ -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)

View File

@ -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

View File

@ -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()

View File

@ -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

View File

@ -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()

View File

@ -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)),

View File

@ -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

View File

@ -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,

View File

@ -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

View File

@ -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=[]),

View File

@ -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"),

View File

@ -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,

View File

@ -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"
),

View File

@ -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(

View File

@ -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}),

View File

@ -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()

View File

@ -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"

View File

@ -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",

View File

@ -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

View File

@ -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

View File

@ -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

View 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

View File

@ -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]

View File

@ -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()

View 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()

View File

@ -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

View File

@ -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"),

View File

@ -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),

View File

@ -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(

View File

@ -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