diff --git a/pyproject.toml b/pyproject.toml index 7d7a187..a00a921 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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] diff --git a/steam_backlog_enforcer/_ctl_control.py b/steam_backlog_enforcer/_ctl_control.py index 581f37a..2f5af98 100644 --- a/steam_backlog_enforcer/_ctl_control.py +++ b/steam_backlog_enforcer/_ctl_control.py @@ -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: diff --git a/steam_backlog_enforcer/_ctl_server.py b/steam_backlog_enforcer/_ctl_server.py index 01fad02..6d1fa0e 100644 --- a/steam_backlog_enforcer/_ctl_server.py +++ b/steam_backlog_enforcer/_ctl_server.py @@ -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: diff --git a/steam_backlog_enforcer/_hltb_types.py b/steam_backlog_enforcer/_hltb_types.py index 39e3d06..4a4b9cb 100644 --- a/steam_backlog_enforcer/_hltb_types.py +++ b/steam_backlog_enforcer/_hltb_types.py @@ -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: diff --git a/steam_backlog_enforcer/_steam_api_client.py b/steam_backlog_enforcer/_steam_api_client.py index 0d1c165..7075b51 100644 --- a/steam_backlog_enforcer/_steam_api_client.py +++ b/steam_backlog_enforcer/_steam_api_client.py @@ -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) diff --git a/steam_backlog_enforcer/tests/_budget_earners.py b/steam_backlog_enforcer/tests/_budget_earners.py new file mode 100644 index 0000000..df455cf --- /dev/null +++ b/steam_backlog_enforcer/tests/_budget_earners.py @@ -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()) diff --git a/steam_backlog_enforcer/tests/_cmd_done_cases.py b/steam_backlog_enforcer/tests/_cmd_done_cases.py new file mode 100644 index 0000000..1789151 --- /dev/null +++ b/steam_backlog_enforcer/tests/_cmd_done_cases.py @@ -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) diff --git a/steam_backlog_enforcer/tests/_cmd_playtime_patches.py b/steam_backlog_enforcer/tests/_cmd_playtime_patches.py new file mode 100644 index 0000000..052b0f5 --- /dev/null +++ b/steam_backlog_enforcer/tests/_cmd_playtime_patches.py @@ -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) diff --git a/steam_backlog_enforcer/tests/_config_paths.py b/steam_backlog_enforcer/tests/_config_paths.py new file mode 100644 index 0000000..220ae2a --- /dev/null +++ b/steam_backlog_enforcer/tests/_config_paths.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_desktop_launch_patches.py b/steam_backlog_enforcer/tests/_desktop_launch_patches.py new file mode 100644 index 0000000..03674b3 --- /dev/null +++ b/steam_backlog_enforcer/tests/_desktop_launch_patches.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_enforce_loop_once.py b/steam_backlog_enforcer/tests/_enforce_loop_once.py new file mode 100644 index 0000000..65a978e --- /dev/null +++ b/steam_backlog_enforcer/tests/_enforce_loop_once.py @@ -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) diff --git a/steam_backlog_enforcer/tests/_fake_xlib.py b/steam_backlog_enforcer/tests/_fake_xlib.py index db1ca6e..8d08c1c 100644 --- a/steam_backlog_enforcer/tests/_fake_xlib.py +++ b/steam_backlog_enforcer/tests/_fake_xlib.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_fast_install_excerpt.py b/steam_backlog_enforcer/tests/_fast_install_excerpt.py new file mode 100644 index 0000000..890afe4 --- /dev/null +++ b/steam_backlog_enforcer/tests/_fast_install_excerpt.py @@ -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]) diff --git a/steam_backlog_enforcer/tests/_file_wake_timing.py b/steam_backlog_enforcer/tests/_file_wake_timing.py new file mode 100644 index 0000000..261ae1a --- /dev/null +++ b/steam_backlog_enforcer/tests/_file_wake_timing.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_half_done_game.py b/steam_backlog_enforcer/tests/_half_done_game.py new file mode 100644 index 0000000..957bfc0 --- /dev/null +++ b/steam_backlog_enforcer/tests/_half_done_game.py @@ -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, + ) diff --git a/steam_backlog_enforcer/tests/_hltb_leisure_runs.py b/steam_backlog_enforcer/tests/_hltb_leisure_runs.py new file mode 100644 index 0000000..39ff7c3 --- /dev/null +++ b/steam_backlog_enforcer/tests/_hltb_leisure_runs.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_hltb_search_fakes.py b/steam_backlog_enforcer/tests/_hltb_search_fakes.py new file mode 100644 index 0000000..cba3904 --- /dev/null +++ b/steam_backlog_enforcer/tests/_hltb_search_fakes.py @@ -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)) diff --git a/steam_backlog_enforcer/tests/_isolate_incidents.py b/steam_backlog_enforcer/tests/_isolate_incidents.py index bd1644e..0dd0336 100644 --- a/steam_backlog_enforcer/tests/_isolate_incidents.py +++ b/steam_backlog_enforcer/tests/_isolate_incidents.py @@ -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" diff --git a/steam_backlog_enforcer/tests/_ledger_fixtures.py b/steam_backlog_enforcer/tests/_ledger_fixtures.py index 0a7afcc..049d96d 100644 --- a/steam_backlog_enforcer/tests/_ledger_fixtures.py +++ b/steam_backlog_enforcer/tests/_ledger_fixtures.py @@ -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: diff --git a/steam_backlog_enforcer/tests/_leisure_kill_world.py b/steam_backlog_enforcer/tests/_leisure_kill_world.py new file mode 100644 index 0000000..ebc67c9 --- /dev/null +++ b/steam_backlog_enforcer/tests/_leisure_kill_world.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_main_dispatch_world.py b/steam_backlog_enforcer/tests/_main_dispatch_world.py new file mode 100644 index 0000000..8262018 --- /dev/null +++ b/steam_backlog_enforcer/tests/_main_dispatch_world.py @@ -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")) diff --git a/steam_backlog_enforcer/tests/_main_patches.py b/steam_backlog_enforcer/tests/_main_patches.py new file mode 100644 index 0000000..772e066 --- /dev/null +++ b/steam_backlog_enforcer/tests/_main_patches.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_manual_pick_rows.py b/steam_backlog_enforcer/tests/_manual_pick_rows.py new file mode 100644 index 0000000..2f5bc1e --- /dev/null +++ b/steam_backlog_enforcer/tests/_manual_pick_rows.py @@ -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), + } diff --git a/steam_backlog_enforcer/tests/_no_approved_exceptions.py b/steam_backlog_enforcer/tests/_no_approved_exceptions.py new file mode 100644 index 0000000..35f4a69 --- /dev/null +++ b/steam_backlog_enforcer/tests/_no_approved_exceptions.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_no_fast_install.py b/steam_backlog_enforcer/tests/_no_fast_install.py index b320c74..4d52463 100644 --- a/steam_backlog_enforcer/tests/_no_fast_install.py +++ b/steam_backlog_enforcer/tests/_no_fast_install.py @@ -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() diff --git a/steam_backlog_enforcer/tests/_no_real_effects.py b/steam_backlog_enforcer/tests/_no_real_effects.py index 66fc8d3..d199152 100644 --- a/steam_backlog_enforcer/tests/_no_real_effects.py +++ b/steam_backlog_enforcer/tests/_no_real_effects.py @@ -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), diff --git a/steam_backlog_enforcer/tests/_piano_earner.py b/steam_backlog_enforcer/tests/_piano_earner.py new file mode 100644 index 0000000..30a9261 --- /dev/null +++ b/steam_backlog_enforcer/tests/_piano_earner.py @@ -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() diff --git a/steam_backlog_enforcer/tests/_pick_completion_helpers.py b/steam_backlog_enforcer/tests/_pick_completion_helpers.py new file mode 100644 index 0000000..1b684c2 --- /dev/null +++ b/steam_backlog_enforcer/tests/_pick_completion_helpers.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_playtime_history_files.py b/steam_backlog_enforcer/tests/_playtime_history_files.py new file mode 100644 index 0000000..6f3516b --- /dev/null +++ b/steam_backlog_enforcer/tests/_playtime_history_files.py @@ -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()} diff --git a/steam_backlog_enforcer/tests/_polls_helpers.py b/steam_backlog_enforcer/tests/_polls_helpers.py new file mode 100644 index 0000000..7b07d14 --- /dev/null +++ b/steam_backlog_enforcer/tests/_polls_helpers.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_protondb_cache.py b/steam_backlog_enforcer/tests/_protondb_cache.py new file mode 100644 index 0000000..473c0f9 --- /dev/null +++ b/steam_backlog_enforcer/tests/_protondb_cache.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_scanning_games.py b/steam_backlog_enforcer/tests/_scanning_games.py new file mode 100644 index 0000000..58fe97f --- /dev/null +++ b/steam_backlog_enforcer/tests/_scanning_games.py @@ -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 diff --git a/steam_backlog_enforcer/tests/_stats_cmd_env.py b/steam_backlog_enforcer/tests/_stats_cmd_env.py new file mode 100644 index 0000000..dd79f4f --- /dev/null +++ b/steam_backlog_enforcer/tests/_stats_cmd_env.py @@ -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.`` 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 diff --git a/steam_backlog_enforcer/tests/_stats_games.py b/steam_backlog_enforcer/tests/_stats_games.py new file mode 100644 index 0000000..ad00a5e --- /dev/null +++ b/steam_backlog_enforcer/tests/_stats_games.py @@ -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) diff --git a/steam_backlog_enforcer/tests/_web_dataset_factories.py b/steam_backlog_enforcer/tests/_web_dataset_factories.py new file mode 100644 index 0000000..d79b07d --- /dev/null +++ b/steam_backlog_enforcer/tests/_web_dataset_factories.py @@ -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, + } + } diff --git a/steam_backlog_enforcer/tests/_web_process_state.py b/steam_backlog_enforcer/tests/_web_process_state.py new file mode 100644 index 0000000..6146a33 --- /dev/null +++ b/steam_backlog_enforcer/tests/_web_process_state.py @@ -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() diff --git a/steam_backlog_enforcer/tests/_zero_ach.py b/steam_backlog_enforcer/tests/_zero_ach.py index c627ebb..1796219 100644 --- a/steam_backlog_enforcer/tests/_zero_ach.py +++ b/steam_backlog_enforcer/tests/_zero_ach.py @@ -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))) diff --git a/steam_backlog_enforcer/tests/test_achievement_skip.py b/steam_backlog_enforcer/tests/test_achievement_skip.py index 6f5f261..630071f 100644 --- a/steam_backlog_enforcer/tests/test_achievement_skip.py +++ b/steam_backlog_enforcer/tests/test_achievement_skip.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_actions.py b/steam_backlog_enforcer/tests/test_actions.py index 65fb1a4..da50de0 100644 --- a/steam_backlog_enforcer/tests/test_actions.py +++ b/steam_backlog_enforcer/tests/test_actions.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_actions_status_payload.py b/steam_backlog_enforcer/tests/test_actions_status_payload.py index c3302f2..0e66765 100644 --- a/steam_backlog_enforcer/tests/test_actions_status_payload.py +++ b/steam_backlog_enforcer/tests/test_actions_status_payload.py @@ -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.""" diff --git a/steam_backlog_enforcer/tests/test_assignment_progress.py b/steam_backlog_enforcer/tests/test_assignment_progress.py index 1477370..8e166bb 100644 --- a/steam_backlog_enforcer/tests/test_assignment_progress.py +++ b/steam_backlog_enforcer/tests/test_assignment_progress.py @@ -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"} diff --git a/steam_backlog_enforcer/tests/test_attribution_labels.py b/steam_backlog_enforcer/tests/test_attribution_labels.py index ee21282..c8201d1 100644 --- a/steam_backlog_enforcer/tests/test_attribution_labels.py +++ b/steam_backlog_enforcer/tests/test_attribution_labels.py @@ -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", diff --git a/steam_backlog_enforcer/tests/test_backups.py b/steam_backlog_enforcer/tests/test_backups.py index 1509405..5a683f5 100644 --- a/steam_backlog_enforcer/tests/test_backups.py +++ b/steam_backlog_enforcer/tests/test_backups.py @@ -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") diff --git a/steam_backlog_enforcer/tests/test_budget_games.py b/steam_backlog_enforcer/tests/test_budget_games.py index 9944322..6ddbfc8 100644 --- a/steam_backlog_enforcer/tests/test_budget_games.py +++ b/steam_backlog_enforcer/tests/test_budget_games.py @@ -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([]) == ([], []) diff --git a/steam_backlog_enforcer/tests/test_budget_log_tail.py b/steam_backlog_enforcer/tests/test_budget_log_tail.py index 116f205..61a4e8b 100644 --- a/steam_backlog_enforcer/tests/test_budget_log_tail.py +++ b/steam_backlog_enforcer/tests/test_budget_log_tail.py @@ -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 == [] diff --git a/steam_backlog_enforcer/tests/test_budget_resolve.py b/steam_backlog_enforcer/tests/test_budget_resolve.py index dbbac7c..a483892 100644 --- a/steam_backlog_enforcer/tests/test_budget_resolve.py +++ b/steam_backlog_enforcer/tests/test_budget_resolve.py @@ -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()) diff --git a/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py b/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py index fed0325..047fc13 100644 --- a/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py +++ b/steam_backlog_enforcer/tests/test_budget_resolve_leisure.py @@ -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] diff --git a/steam_backlog_enforcer/tests/test_budget_resolve_reading.py b/steam_backlog_enforcer/tests/test_budget_resolve_reading.py index 73f67a4..7cf152c 100644 --- a/steam_backlog_enforcer/tests/test_budget_resolve_reading.py +++ b/steam_backlog_enforcer/tests/test_budget_resolve_reading.py @@ -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, diff --git a/steam_backlog_enforcer/tests/test_budget_view.py b/steam_backlog_enforcer/tests/test_budget_view.py index ada75fe..7971f15 100644 --- a/steam_backlog_enforcer/tests/test_budget_view.py +++ b/steam_backlog_enforcer/tests/test_budget_view.py @@ -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", diff --git a/steam_backlog_enforcer/tests/test_budget_view_collection.py b/steam_backlog_enforcer/tests/test_budget_view_collection.py index f8ca1b5..b03f638 100644 --- a/steam_backlog_enforcer/tests/test_budget_view_collection.py +++ b/steam_backlog_enforcer/tests/test_budget_view_collection.py @@ -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( diff --git a/steam_backlog_enforcer/tests/test_cmd_done_hltb.py b/steam_backlog_enforcer/tests/test_cmd_done_hltb.py new file mode 100644 index 0000000..0615121 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_cmd_done_hltb.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_cmd_done_part2.py b/steam_backlog_enforcer/tests/test_cmd_done_part2.py index c9c4134..763b48d 100644 --- a/steam_backlog_enforcer/tests/test_cmd_done_part2.py +++ b/steam_backlog_enforcer/tests/test_cmd_done_part2.py @@ -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, diff --git a/steam_backlog_enforcer/tests/test_cmd_gaming_unblock.py b/steam_backlog_enforcer/tests/test_cmd_gaming_unblock.py new file mode 100644 index 0000000..fe8a75c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_cmd_gaming_unblock.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_cmd_playtime.py b/steam_backlog_enforcer/tests/test_cmd_playtime.py index 3e9d4b5..b30da11 100644 --- a/steam_backlog_enforcer/tests/test_cmd_playtime.py +++ b/steam_backlog_enforcer/tests/test_cmd_playtime.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_cmd_serve.py b/steam_backlog_enforcer/tests/test_cmd_serve.py index ba50800..d204c61 100644 --- a/steam_backlog_enforcer/tests/test_cmd_serve.py +++ b/steam_backlog_enforcer/tests/test_cmd_serve.py @@ -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 ( diff --git a/steam_backlog_enforcer/tests/test_command_catalog.py b/steam_backlog_enforcer/tests/test_command_catalog.py index afd5943..3b3d844 100644 --- a/steam_backlog_enforcer/tests/test_command_catalog.py +++ b/steam_backlog_enforcer/tests/test_command_catalog.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_command_gate.py b/steam_backlog_enforcer/tests/test_command_gate.py index 430e00d..55c437b 100644 --- a/steam_backlog_enforcer/tests/test_command_gate.py +++ b/steam_backlog_enforcer/tests/test_command_gate.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_command_params.py b/steam_backlog_enforcer/tests/test_command_params.py index cdc3bce..cf03845 100644 --- a/steam_backlog_enforcer/tests/test_command_params.py +++ b/steam_backlog_enforcer/tests/test_command_params.py @@ -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} diff --git a/steam_backlog_enforcer/tests/test_config.py b/steam_backlog_enforcer/tests/test_config.py index a2b3760..65894d8 100644 --- a/steam_backlog_enforcer/tests/test_config.py +++ b/steam_backlog_enforcer/tests/test_config.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_config_interactive_setup.py b/steam_backlog_enforcer/tests/test_config_interactive_setup.py index 93628d1..d436c55 100644 --- a/steam_backlog_enforcer/tests/test_config_interactive_setup.py +++ b/steam_backlog_enforcer/tests/test_config_interactive_setup.py @@ -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), ): diff --git a/steam_backlog_enforcer/tests/test_ctl_client.py b/steam_backlog_enforcer/tests/test_ctl_client.py index 55e92bd..1eb504b 100644 --- a/steam_backlog_enforcer/tests/test_ctl_client.py +++ b/steam_backlog_enforcer/tests/test_ctl_client.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_ctl_context.py b/steam_backlog_enforcer/tests/test_ctl_context.py index ea8c3c7..a928408 100644 --- a/steam_backlog_enforcer/tests/test_ctl_context.py +++ b/steam_backlog_enforcer/tests/test_ctl_context.py @@ -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") diff --git a/steam_backlog_enforcer/tests/test_ctl_control.py b/steam_backlog_enforcer/tests/test_ctl_control.py index 6f8f80c..5944b87 100644 --- a/steam_backlog_enforcer/tests/test_ctl_control.py +++ b/steam_backlog_enforcer/tests/test_ctl_control.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_ctl_ops.py b/steam_backlog_enforcer/tests/test_ctl_ops.py index 59bbd1b..e044c74 100644 --- a/steam_backlog_enforcer/tests/test_ctl_ops.py +++ b/steam_backlog_enforcer/tests/test_ctl_ops.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py b/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py index 7a426c9..85feeb1 100644 --- a/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py +++ b/steam_backlog_enforcer/tests/test_ctl_ops_privileged.py @@ -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", diff --git a/steam_backlog_enforcer/tests/test_ctl_pending.py b/steam_backlog_enforcer/tests/test_ctl_pending.py index 14ab6ff..06f0298 100644 --- a/steam_backlog_enforcer/tests/test_ctl_pending.py +++ b/steam_backlog_enforcer/tests/test_ctl_pending.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_ctl_protocol.py b/steam_backlog_enforcer/tests/test_ctl_protocol.py index f65baef..3372eb6 100644 --- a/steam_backlog_enforcer/tests/test_ctl_protocol.py +++ b/steam_backlog_enforcer/tests/test_ctl_protocol.py @@ -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]}') diff --git a/steam_backlog_enforcer/tests/test_ctl_reset_ops.py b/steam_backlog_enforcer/tests/test_ctl_reset_ops.py index cbaee0f..41fd45c 100644 --- a/steam_backlog_enforcer/tests/test_ctl_reset_ops.py +++ b/steam_backlog_enforcer/tests/test_ctl_reset_ops.py @@ -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 ( diff --git a/steam_backlog_enforcer/tests/test_ctl_restart.py b/steam_backlog_enforcer/tests/test_ctl_restart.py index 390abca..1550fbf 100644 --- a/steam_backlog_enforcer/tests/test_ctl_restart.py +++ b/steam_backlog_enforcer/tests/test_ctl_restart.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_ctl_server.py b/steam_backlog_enforcer/tests/test_ctl_server.py index 8721688..5c2f126 100644 --- a/steam_backlog_enforcer/tests/test_ctl_server.py +++ b/steam_backlog_enforcer/tests/test_ctl_server.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_ctl_server_internals.py b/steam_backlog_enforcer/tests/test_ctl_server_internals.py index 7a5cf07..aa48663 100644 --- a/steam_backlog_enforcer/tests/test_ctl_server_internals.py +++ b/steam_backlog_enforcer/tests/test_ctl_server_internals.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_desktop_env.py b/steam_backlog_enforcer/tests/test_desktop_env.py index dc05238..fee7f62 100644 --- a/steam_backlog_enforcer/tests/test_desktop_env.py +++ b/steam_backlog_enforcer/tests/test_desktop_env.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_desktop_session_gating.py b/steam_backlog_enforcer/tests/test_desktop_session_gating.py index adbdef3..305be14 100644 --- a/steam_backlog_enforcer/tests/test_desktop_session_gating.py +++ b/steam_backlog_enforcer/tests/test_desktop_session_gating.py @@ -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/ 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/.""" 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() diff --git a/steam_backlog_enforcer/tests/test_desktop_user_cmd.py b/steam_backlog_enforcer/tests/test_desktop_user_cmd.py index f5b3004..f9b7a4e 100644 --- a/steam_backlog_enforcer/tests/test_desktop_user_cmd.py +++ b/steam_backlog_enforcer/tests/test_desktop_user_cmd.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_do_enforce.py b/steam_backlog_enforcer/tests/test_do_enforce.py index 1798965..aab7ac2 100644 --- a/steam_backlog_enforcer/tests/test_do_enforce.py +++ b/steam_backlog_enforcer/tests/test_do_enforce.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_enforce_demo.py b/steam_backlog_enforcer/tests/test_enforce_demo.py index 63341bc..38219a0 100644 --- a/steam_backlog_enforcer/tests/test_enforce_demo.py +++ b/steam_backlog_enforcer/tests/test_enforce_demo.py @@ -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)), diff --git a/steam_backlog_enforcer/tests/test_enforce_loop.py b/steam_backlog_enforcer/tests/test_enforce_loop.py index 95013df..36e4461 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_enforce_loop_part2.py b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py index 4e60b60..590fdf1 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop_part2.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py @@ -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, diff --git a/steam_backlog_enforcer/tests/test_enforce_loop_part3.py b/steam_backlog_enforcer/tests/test_enforce_loop_part3.py index aaad4fb..de223f6 100644 --- a/steam_backlog_enforcer/tests/test_enforce_loop_part3.py +++ b/steam_backlog_enforcer/tests/test_enforce_loop_part3.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_enforce_steps.py b/steam_backlog_enforcer/tests/test_enforce_steps.py index 3732e69..6b9aaf8 100644 --- a/steam_backlog_enforcer/tests/test_enforce_steps.py +++ b/steam_backlog_enforcer/tests/test_enforce_steps.py @@ -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=[]), diff --git a/steam_backlog_enforcer/tests/test_enforcer.py b/steam_backlog_enforcer/tests/test_enforcer.py index b715dc1..3fdf248 100644 --- a/steam_backlog_enforcer/tests/test_enforcer.py +++ b/steam_backlog_enforcer/tests/test_enforcer.py @@ -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"), diff --git a/steam_backlog_enforcer/tests/test_enforcer_part2.py b/steam_backlog_enforcer/tests/test_enforcer_part2.py index 3eb1490..c42af88 100644 --- a/steam_backlog_enforcer/tests/test_enforcer_part2.py +++ b/steam_backlog_enforcer/tests/test_enforcer_part2.py @@ -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, diff --git a/steam_backlog_enforcer/tests/test_ensure_debug_port.py b/steam_backlog_enforcer/tests/test_ensure_debug_port.py index 3deba79..11c7f0a 100644 --- a/steam_backlog_enforcer/tests/test_ensure_debug_port.py +++ b/steam_backlog_enforcer/tests/test_ensure_debug_port.py @@ -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" ), diff --git a/steam_backlog_enforcer/tests/test_ensure_steam_running.py b/steam_backlog_enforcer/tests/test_ensure_steam_running.py index 39d3160..5af7b8e 100644 --- a/steam_backlog_enforcer/tests/test_ensure_steam_running.py +++ b/steam_backlog_enforcer/tests/test_ensure_steam_running.py @@ -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( diff --git a/steam_backlog_enforcer/tests/test_exception_visibility.py b/steam_backlog_enforcer/tests/test_exception_visibility.py index f36f344..e03daa7 100644 --- a/steam_backlog_enforcer/tests/test_exception_visibility.py +++ b/steam_backlog_enforcer/tests/test_exception_visibility.py @@ -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}), diff --git a/steam_backlog_enforcer/tests/test_fast_install_dashboard.py b/steam_backlog_enforcer/tests/test_fast_install_dashboard.py index 3cb8971..05366cd 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_dashboard.py +++ b/steam_backlog_enforcer/tests/test_fast_install_dashboard.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_fast_install_failure.py b/steam_backlog_enforcer/tests/test_fast_install_failure.py index 3db8aa2..bb43c79 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_failure.py +++ b/steam_backlog_enforcer/tests/test_fast_install_failure.py @@ -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" diff --git a/steam_backlog_enforcer/tests/test_fast_install_guards.py b/steam_backlog_enforcer/tests/test_fast_install_guards.py index 0852aa7..0af249d 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_guards.py +++ b/steam_backlog_enforcer/tests/test_fast_install_guards.py @@ -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", diff --git a/steam_backlog_enforcer/tests/test_fast_install_helpers.py b/steam_backlog_enforcer/tests/test_fast_install_helpers.py index b149515..d34421b 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_helpers.py +++ b/steam_backlog_enforcer/tests/test_fast_install_helpers.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_fast_install_job_wait.py b/steam_backlog_enforcer/tests/test_fast_install_job_wait.py index f939eef..d50ccae 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_job_wait.py +++ b/steam_backlog_enforcer/tests/test_fast_install_job_wait.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_fast_install_progress.py b/steam_backlog_enforcer/tests/test_fast_install_progress.py index 286be48..d321e44 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_progress.py +++ b/steam_backlog_enforcer/tests/test_fast_install_progress.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_fast_install_registry.py b/steam_backlog_enforcer/tests/test_fast_install_registry.py new file mode 100644 index 0000000..527e9ad --- /dev/null +++ b/steam_backlog_enforcer/tests/test_fast_install_registry.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_fast_install_runs.py b/steam_backlog_enforcer/tests/test_fast_install_runs.py index a17dfa3..6f75859 100644 --- a/steam_backlog_enforcer/tests/test_fast_install_runs.py +++ b/steam_backlog_enforcer/tests/test_fast_install_runs.py @@ -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] diff --git a/steam_backlog_enforcer/tests/test_file_wake.py b/steam_backlog_enforcer/tests/test_file_wake.py index 8bcc42f..2e34005 100644 --- a/steam_backlog_enforcer/tests/test_file_wake.py +++ b/steam_backlog_enforcer/tests/test_file_wake.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_file_wake_events.py b/steam_backlog_enforcer/tests/test_file_wake_events.py new file mode 100644 index 0000000..f710a0b --- /dev/null +++ b/steam_backlog_enforcer/tests/test_file_wake_events.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_first_credits.py b/steam_backlog_enforcer/tests/test_first_credits.py index d70aa2a..6e1e5ae 100644 --- a/steam_backlog_enforcer/tests/test_first_credits.py +++ b/steam_backlog_enforcer/tests/test_first_credits.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_game_install.py b/steam_backlog_enforcer/tests/test_game_install.py index 7ba92a8..7fe0eb3 100644 --- a/steam_backlog_enforcer/tests/test_game_install.py +++ b/steam_backlog_enforcer/tests/test_game_install.py @@ -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"), diff --git a/steam_backlog_enforcer/tests/test_game_install_guard.py b/steam_backlog_enforcer/tests/test_game_install_guard.py index 78aaa94..4689ec0 100644 --- a/steam_backlog_enforcer/tests/test_game_install_guard.py +++ b/steam_backlog_enforcer/tests/test_game_install_guard.py @@ -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), diff --git a/steam_backlog_enforcer/tests/test_game_install_part2.py b/steam_backlog_enforcer/tests/test_game_install_part2.py index 99f79ef..a8480ea 100644 --- a/steam_backlog_enforcer/tests/test_game_install_part2.py +++ b/steam_backlog_enforcer/tests/test_game_install_part2.py @@ -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( diff --git a/steam_backlog_enforcer/tests/test_game_install_part3.py b/steam_backlog_enforcer/tests/test_game_install_part3.py index 62ddc42..15cd67c 100644 --- a/steam_backlog_enforcer/tests/test_game_install_part3.py +++ b/steam_backlog_enforcer/tests/test_game_install_part3.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_game_install_queue.py b/steam_backlog_enforcer/tests/test_game_install_queue.py index 266995b..b74d4db 100644 --- a/steam_backlog_enforcer/tests/test_game_install_queue.py +++ b/steam_backlog_enforcer/tests/test_game_install_queue.py @@ -20,6 +20,8 @@ PKG = "steam_backlog_enforcer.game_install" class TestQueueBeforeFastInstall: + """A fresh install is queued with Steam before the fast installer runs.""" + def test_fast_install_gets_a_manifest_to_finish(self, tmp_path: Path) -> None: """steam-game-installer only finishes a manifest it finds, so a fresh install is queued with Steam before the installer runs, not after.""" diff --git a/steam_backlog_enforcer/tests/test_game_names.py b/steam_backlog_enforcer/tests/test_game_names.py index f49f956..22f6a68 100644 --- a/steam_backlog_enforcer/tests/test_game_names.py +++ b/steam_backlog_enforcer/tests/test_game_names.py @@ -29,17 +29,20 @@ class TestNormalizeGameName: """Tests for _normalize_game_name.""" def test_lowercases_and_strips_punctuation(self) -> None: + """Names lowercase and lose punctuation and spaces.""" assert ( _normalize_game_name("Kingdom Come: Deliverance II") == "kingdomcomedeliverance2" ) def test_matches_no_space_variant(self) -> None: + """A name written without spaces normalises like the spaced one.""" assert _normalize_game_name("KingdomComeDeliverance2") == _normalize_game_name( "Kingdom Come: Deliverance II" ) def test_spelled_out_number_matches_digit(self) -> None: + """A spelled-out number normalises like its digit.""" assert _normalize_game_name("Portal Two") == _normalize_game_name("Portal 2") @@ -47,6 +50,7 @@ class TestProtectedNameStems: """Tests for _protected_name_stems.""" def test_returns_allowed_game_names(self) -> None: + """Allowed game names are returned and blank names are dropped.""" with ( patch("steam_backlog_enforcer.config.State.load", return_value=MagicMock()), patch( @@ -58,6 +62,7 @@ class TestProtectedNameStems: assert stems == ["Kingdom Come: Deliverance II"] def test_returns_empty_list_when_state_cannot_load(self) -> None: + """A state that cannot load yields no protected names.""" with patch( "steam_backlog_enforcer.config.State.load", side_effect=RuntimeError("boom") ): @@ -69,9 +74,11 @@ class TestIsProtectedName: """Tests for _is_protected_name.""" def test_blank_candidate_is_not_protected(self) -> None: + """A blank candidate is never protected.""" assert _is_protected_name(" ") is False def test_matches_exact_normalized_name(self) -> None: + """A candidate matching a protected name after normalising is protected.""" with patch( f"{GAME_NAMES_PKG}._protected_name_stems", return_value=["Kingdom Come: Deliverance II"], @@ -79,6 +86,7 @@ class TestIsProtectedName: assert _is_protected_name("KingdomComeDeliverance2") is True def test_matches_typo_via_fuzzy_ratio(self) -> None: + """A close misspelling of a protected name is protected.""" with patch( f"{GAME_NAMES_PKG}._protected_name_stems", return_value=["Kingdom Come: Deliverance II"], @@ -86,6 +94,7 @@ class TestIsProtectedName: assert _is_protected_name("Kingdom Come Delievarnce 2") is True def test_no_match_for_unrelated_name(self) -> None: + """An unrelated name is not protected.""" with patch( f"{GAME_NAMES_PKG}._protected_name_stems", return_value=["Kingdom Come: Deliverance II"], @@ -93,6 +102,7 @@ class TestIsProtectedName: assert _is_protected_name("Cogmind") is False def test_skips_blank_protected_name(self) -> None: + """Blank protected names are skipped and the rest still match.""" with patch( f"{GAME_NAMES_PKG}._protected_name_stems", return_value=["", "Cogmind"] ): @@ -103,6 +113,7 @@ class TestRemoveGameDirsProtectedName: """Tests for the _is_protected_name safety net inside _remove_game_dirs.""" def test_refuses_to_remove_protected_name(self, tmp_path: Path) -> None: + """A protected install dir is left on disk and reported as not removed.""" install_dir = tmp_path / "common" / "Kingdom Come: Deliverance II" install_dir.mkdir(parents=True) (install_dir / "game.exe").touch() diff --git a/steam_backlog_enforcer/tests/test_gaming_days.py b/steam_backlog_enforcer/tests/test_gaming_days.py index f9d9eb9..5834a32 100644 --- a/steam_backlog_enforcer/tests/test_gaming_days.py +++ b/steam_backlog_enforcer/tests/test_gaming_days.py @@ -30,6 +30,7 @@ class TestNoCarryOver: """Unspent time never moves to a later day (carry-over dropped 2026-10-18).""" def test_a_saturday_leftover_is_not_passed_on(self) -> None: + """Rolling a Saturday with time left gives Sunday a fresh state.""" sun = roll_over( PlaytimeState(day_key=SAT, seconds=HOUR, budget_seconds=8 * HOUR), day_key=SUN, @@ -38,9 +39,11 @@ class TestNoCarryOver: assert "carry" not in vars(sun) def test_rules_have_no_carry(self) -> None: + """PlaytimeRules has no carry_seconds field.""" assert "carry_seconds" not in {f.name for f in fields(PlaytimeRules)} def test_a_legacy_carry_is_taken_out_of_todays_high_water(self) -> None: + """A legacy carry is subtracted from today's stored budget on load.""" # The live file on 2026-10-18 held 6.0h earned + 9994s carried into the # day's high-water mark; held_today would otherwise keep paying it out. legacy = { @@ -62,6 +65,7 @@ class TestNoCarryOver: def test_a_ledger_owing_today_nothing_leaves_the_budget( self, carry: object ) -> None: + """A carry ledger that owes today nothing leaves the stored budget unchanged.""" legacy = { "schema_version": 1, "day_key": SUN, @@ -80,13 +84,16 @@ class TestHeldToday: NOW = datetime(2026, 10, 5, 0, 30, tzinfo=LOCAL) # still gaming day SUN def test_no_state_holds_nothing(self) -> None: + """No stored state holds no budget.""" assert held_today(None, self.NOW) == 0.0 def test_same_gaming_day_returns_the_high_water(self) -> None: + """A record from the current gaming day holds its high-water budget.""" stored = PlaytimeState(day_key=SUN, budget_seconds=8 * HOUR) assert held_today(stored, self.NOW) == 8 * HOUR def test_yesterdays_record_holds_nothing(self) -> None: + """A record from an earlier gaming day holds nothing.""" stored = PlaytimeState(day_key=SAT, budget_seconds=8 * HOUR) assert held_today(stored, self.NOW) == 0.0 @@ -95,6 +102,7 @@ class TestRulesHold: """rules_for never prices the day below what it already granted.""" def test_midnight_reset_is_held_and_says_so(self) -> None: + """A lower live budget is raised to the held one and the reason says so.""" with patch( "steam_backlog_enforcer._playtime_state.held_today", return_value=10 * HOUR, @@ -104,6 +112,7 @@ class TestRulesHold: assert rules.budget_reason.endswith("; held at 10.0h (earned earlier today)") def test_a_higher_live_answer_wins_silently(self) -> None: + """A live budget above the held one wins with no held note.""" with patch( "steam_backlog_enforcer._playtime_state.held_today", return_value=HOUR ): diff --git a/steam_backlog_enforcer/tests/test_gaming_reset.py b/steam_backlog_enforcer/tests/test_gaming_reset.py index 351a8f6..42d079a 100644 --- a/steam_backlog_enforcer/tests/test_gaming_reset.py +++ b/steam_backlog_enforcer/tests/test_gaming_reset.py @@ -25,7 +25,7 @@ def _today() -> str: def _audit_records() -> list[dict[str, object]]: path = _playtime_log.budget_log_path(demo=False) - return [json.loads(line) for line in path.read_text().splitlines()] + return [json.loads(line) for line in path.read_text(encoding="utf-8").splitlines()] def _spent_state(day_key: str) -> PlaytimeState: @@ -44,6 +44,7 @@ class TestResetToday: """Zero the counter, release mounts, keep earned time, leave a record.""" def test_zeroes_state_but_keeps_earned_time(self) -> None: + """Reset zeroes billed time and blocks but keeps earned budget.""" save_state(_spent_state(_today()), demo=False) with patch.object( _gaming_reset, "release_block", return_value=[Path("/m/steam")] @@ -61,6 +62,7 @@ class TestResetToday: assert stored.last_tick_at > 0 def test_audit_record_has_the_before_picture(self) -> None: + """The audit record captures what the day looked like before reset.""" save_state(_spent_state(_today()), demo=False) with patch.object(_gaming_reset, "release_block", return_value=[Path("/m")]): _gaming_reset.reset_today(source="cli") @@ -73,6 +75,7 @@ class TestResetToday: assert record["released"] == ["/m"] def test_stale_day_is_rolled_over_first(self) -> None: + """A previous day's state is rolled over before the reset applies.""" save_state(_spent_state("2000-01-01"), demo=False) with patch.object(_gaming_reset, "release_block", return_value=[]): outcome = _gaming_reset.reset_today(source="ctl") @@ -83,6 +86,7 @@ class TestResetToday: assert stored.day_key == _today() def test_no_stored_state(self) -> None: + """Reset with no stored state still writes a fresh state.""" with patch.object(_gaming_reset, "release_block", return_value=[]): outcome = _gaming_reset.reset_today(source="ctl") assert outcome.seconds_before == 0.0 @@ -90,6 +94,7 @@ class TestResetToday: assert load_state(demo=False) is not None def test_history_failure_does_not_undo_the_reset(self) -> None: + """A history write failure does not roll back the reset.""" save_state(_spent_state(_today()), demo=False) with ( patch.object(_gaming_reset, "release_block", return_value=[]), diff --git a/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py index e41d47e..b6f5ec0 100644 --- a/steam_backlog_enforcer/tests/test_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb.py @@ -13,6 +13,7 @@ from steam_backlog_enforcer._hltb_matching import _similarity from steam_backlog_enforcer._hltb_search_api import _get_auth_info, _get_hltb_search_url from steam_backlog_enforcer._hltb_types import ( _AuthInfo, + _HLTBExtras, load_hltb_cache, save_hltb_cache, ) @@ -58,7 +59,7 @@ class TestHltbCache: save_hltb_cache({440: 10.5}) assert cache_file.exists() - def test_save_cache_os_error(self, tmp_path: Path) -> None: + def test_save_cache_os_error(self) -> None: """Test save cache os error.""" with patch( "steam_backlog_enforcer._hltb_types._atomic_write", @@ -68,8 +69,6 @@ class TestHltbCache: def test_save_cache_full_extras_skips_existing_read(self, tmp_path: Path) -> None: """Test save cache full extras skips existing read.""" - from steam_backlog_enforcer._hltb_types import _HLTBExtras - cache_file = tmp_path / "hltb_cache.json" extras = _HLTBExtras( hltb_game_id={440: 1}, diff --git a/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py b/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py index 91921e7..150caee 100644 --- a/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py +++ b/steam_backlog_enforcer/tests/test_hltb_auth_refresh.py @@ -40,6 +40,7 @@ class _RotatingSession: self.posts: list[tuple[str | None, str | None]] = [] def get(self, *_a: object, **_k: object) -> FakeResp: + """Hand out a fresh token and honeypot pair for each /init call.""" self.gets += 1 n = self.gets return FakeResp( @@ -47,6 +48,7 @@ class _RotatingSession: ) def post(self, _url: str, *, headers: dict[str, str], data: str) -> FakeResp: + """Record the auth header and honeypot sent, answer with post_status.""" self.posts.append((headers.get("x-auth-token"), json.loads(data).get("hp"))) body = MATCH if self.post_status == 200 else None return FakeResp(self.post_status, body) diff --git a/steam_backlog_enforcer/tests/test_hltb_detail.py b/steam_backlog_enforcer/tests/test_hltb_detail.py index 2df234c..3c48d3a 100644 --- a/steam_backlog_enforcer/tests/test_hltb_detail.py +++ b/steam_backlog_enforcer/tests/test_hltb_detail.py @@ -3,7 +3,7 @@ from __future__ import annotations import asyncio -from typing import Any, Self +from typing import Any from unittest.mock import AsyncMock, patch import aiohttp @@ -23,26 +23,6 @@ from steam_backlog_enforcer._hltb_types import ( ) -class _FakeTextResponse: - """Async context manager mimicking aiohttp response for text.""" - - def __init__(self, status: int, text: str = "") -> None: - """Test init.""" - self.status = status - self._text = text - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def text(self) -> str: - """Test text.""" - return self._text - - class TestInternalHelpers: """Tests for internal helper coverage.""" diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py b/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py index 2295f49..56bd105 100644 --- a/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py +++ b/steam_backlog_enforcer/tests/test_hltb_detail_extract_comp100_avg_and_high.py @@ -2,31 +2,11 @@ from __future__ import annotations -from typing import Any, Self +from typing import Any from steam_backlog_enforcer._hltb_comp_extract import _extract_comp_100_avg_and_high -class _FakeTextResponse: - """Async context manager mimicking aiohttp response for text.""" - - def __init__(self, status: int, text: str = "") -> None: - """Test init.""" - self.status = status - self._text = text - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def text(self) -> str: - """Test text.""" - return self._text - - class TestExtractComp100AvgAndHigh: """Tests for _extract_comp_100_avg_and_high.""" diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py index 75c6cb3..278b9d9 100644 --- a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py +++ b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times.py @@ -3,33 +3,11 @@ from __future__ import annotations import asyncio -from typing import Any, Self -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any +from unittest.mock import MagicMock from steam_backlog_enforcer._hltb_detail import _fetch_leisure_times -from steam_backlog_enforcer._hltb_types import ( - HLTBResult, -) - - -class _FakeTextResponse: - """Async context manager mimicking aiohttp response for text.""" - - def __init__(self, status: int, text: str = "") -> None: - """Test init.""" - self.status = status - self._text = text - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def text(self) -> str: - """Test text.""" - return self._text +from steam_backlog_enforcer.tests._hltb_leisure_runs import run_leisure, tf2_result class TestFetchLeisureTimes: @@ -37,40 +15,18 @@ class TestFetchLeisureTimes: def test_updates_cache(self) -> None: """Test updates cache.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] + results = [tf2_result()] game_data: dict[str, Any] = { "game": [{"comp_100_h": 21243}], "relationships": [], } - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + cache = run_leisure(results, game_data) assert cache[440] == round(21243 / 3600, 2) assert results[0].completionist_hours == round(21243 / 3600, 2) def test_no_valid_results(self) -> None: """Test no valid results.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=0, - ), - ] + results = [tf2_result(hltb_game_id=0)] cache: dict[int, float] = {} asyncio.run(_fetch_leisure_times(results, cache, {}, None)) assert not cache @@ -83,68 +39,25 @@ class TestFetchLeisureTimes: def test_detail_returns_none(self) -> None: """Test detail returns none.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=None, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + results = [tf2_result()] + cache = run_leisure(results, None) assert not cache assert results[0].completionist_hours == 50.0 def test_negative_leisure(self) -> None: """Test negative leisure.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] + results = [tf2_result()] game_data: dict[str, Any] = {"game": [], "relationships": []} - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + cache = run_leisure(results, game_data) assert not cache assert results[0].completionist_hours == 50.0 def test_with_progress_cb(self) -> None: """Test with progress cb.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] game_data: dict[str, Any] = { "game": [{"comp_100_h": 3600}], "relationships": [], } - cache: dict[int, float] = {} cb = MagicMock() - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, cb)) + run_leisure([tf2_result()], game_data, progress_cb=cb) cb.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py index d16f6e5..311999b 100644 --- a/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py +++ b/steam_backlog_enforcer/tests/test_hltb_detail_fetch_leisure_times_save_interval.py @@ -2,36 +2,19 @@ from __future__ import annotations -import asyncio -from typing import Any, Self -from unittest.mock import AsyncMock, patch +from typing import Any +from unittest.mock import patch -from steam_backlog_enforcer._hltb_detail import _fetch_leisure_times from steam_backlog_enforcer._hltb_types import ( _SAVE_INTERVAL, HLTBResult, _HLTBExtras, ) - - -class _FakeTextResponse: - """Async context manager mimicking aiohttp response for text.""" - - def __init__(self, status: int, text: str = "") -> None: - """Test init.""" - self.status = status - self._text = text - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def text(self) -> str: - """Test text.""" - return self._text +from steam_backlog_enforcer.tests._hltb_leisure_runs import ( + helltaker_result, + run_leisure, + tf2_result, +) class TestFetchLeisureTimesGroup2: @@ -53,29 +36,13 @@ class TestFetchLeisureTimesGroup2: "game": [{"comp_100_h": 3600}], "relationships": [], } - cache: dict[int, float] = {} - with ( - patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ), - patch("steam_backlog_enforcer._hltb_detail.save_hltb_cache") as mock_save, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + with patch("steam_backlog_enforcer._hltb_detail.save_hltb_cache") as mock_save: + run_leisure(results, game_data) mock_save.assert_called_once() def test_dlc_detail_overrides_relationship_fallback(self) -> None: """Test dlc detail overrides relationship fallback.""" - results = [ - HLTBResult( - app_id=1289310, - game_name="Helltaker", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=78118, - ), - ] + results = [helltaker_result()] base_data: dict[str, Any] = { "game": [{"comp_100_h": 21243, "comp_100": 6846}], "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], @@ -84,13 +51,7 @@ class TestFetchLeisureTimesGroup2: "game": [{"comp_100_h": 12298, "comp_100": 4075}], "relationships": [], } - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - side_effect=[base_data, dlc_data], - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + cache = run_leisure(results, details=[base_data, dlc_data]) expected = round((21243 + 12298) / 3600, 2) assert cache[1289310] == expected @@ -98,26 +59,12 @@ class TestFetchLeisureTimesGroup2: def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None: """Test missing dlc detail keeps relationship fallback.""" - results = [ - HLTBResult( - app_id=1289310, - game_name="Helltaker", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=78118, - ), - ] + results = [helltaker_result()] base_data: dict[str, Any] = { "game": [{"comp_100_h": 21243, "comp_100": 6846}], "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], } - cache: dict[int, float] = {} - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - side_effect=[base_data, None], - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) + cache = run_leisure(results, details=[base_data, None]) expected = round((21243 + 4075) / 3600, 2) assert cache[1289310] == expected @@ -125,76 +72,31 @@ class TestFetchLeisureTimesGroup2: def test_extras_populated_with_rush_and_l100(self) -> None: """rush_h and l100 are stored in extras when game has comp_100 data.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] game_data: dict[str, Any] = { "game": [{"comp_100_h": 10800, "comp_100": 7200}], "relationships": [], } - cache: dict[int, float] = {} extras = _HLTBExtras(count_comp={440: 5}) - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) + run_leisure([tf2_result()], game_data, extras=extras) assert extras.rush[440] == round(7200 / 3600, 2) assert extras.leisure_100h[440] == round(10800 / 3600, 2) def test_with_explicit_extras(self) -> None: """Pass a pre-populated _HLTBExtras to cover the non-None extras branch.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] game_data: dict[str, Any] = { "game": [{"comp_100_h": 3600}], "relationships": [], } - cache: dict[int, float] = {} extras = _HLTBExtras(count_comp={440: 5}) - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) + cache = run_leisure([tf2_result()], game_data, extras=extras) assert cache[440] == 1.0 def test_count_comp_from_detail_page_stored_in_extras(self) -> None: """Line 254: extras.count_comp updated when game detail has count_comp > 0.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] game_data: dict[str, Any] = { "game": [{"comp_100_h": 3600, "count_comp": 99}], "relationships": [], } - cache: dict[int, float] = {} extras = _HLTBExtras() - with patch( - "steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) + run_leisure([tf2_result()], game_data, extras=extras) assert extras.count_comp[440] == 99 diff --git a/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py b/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py index 75c3a46..beec026 100644 --- a/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py +++ b/steam_backlog_enforcer/tests/test_hltb_fetch_hltb_detail_missing.py @@ -46,9 +46,8 @@ class TestFetchHltbDetailMissing: def add_rush( _games: object, cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, extras: _HLTBExtras | None = None, + **_unused: object, ) -> list[object]: """Test add rush.""" if extras is not None: @@ -104,9 +103,8 @@ class TestFetchHltbDetailMissing: def add_hours_and_rush( _games: object, cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, extras: _HLTBExtras | None = None, + **_unused: object, ) -> list[object]: """Test add hours and rush.""" if cache is not None: diff --git a/steam_backlog_enforcer/tests/test_hltb_pacing.py b/steam_backlog_enforcer/tests/test_hltb_pacing.py index ef6be64..e53308c 100644 --- a/steam_backlog_enforcer/tests/test_hltb_pacing.py +++ b/steam_backlog_enforcer/tests/test_hltb_pacing.py @@ -37,6 +37,7 @@ class _DetailSession: self.gets = 0 def get(self, _url: str, *, headers: object) -> FakeResp: + """Count the detail request and answer 404.""" del headers self.gets += 1 return FakeResp(404) diff --git a/steam_backlog_enforcer/tests/test_hltb_part2.py b/steam_backlog_enforcer/tests/test_hltb_part2.py index 8350343..256d2b8 100644 --- a/steam_backlog_enforcer/tests/test_hltb_part2.py +++ b/steam_backlog_enforcer/tests/test_hltb_part2.py @@ -56,8 +56,8 @@ class TestFetchHltbTimesCached: _games: object, cache: dict[int, float] | None = None, polls: dict[int, int] | None = None, - progress_cb: object = None, extras: _HLTBExtras | None = None, + **_unused: object, ) -> list[object]: """Test add to cache.""" if cache is not None: @@ -116,8 +116,8 @@ class TestFetchHltbTimesCached: _games: object, cache: dict[int, float] | None = None, polls: dict[int, int] | None = None, - progress_cb: object = None, extras: _HLTBExtras | None = None, + **_unused: object, ) -> list[object]: """Test add found.""" if cache is not None: diff --git a/steam_backlog_enforcer/tests/test_hltb_part3.py b/steam_backlog_enforcer/tests/test_hltb_part3.py index ff40828..310072b 100644 --- a/steam_backlog_enforcer/tests/test_hltb_part3.py +++ b/steam_backlog_enforcer/tests/test_hltb_part3.py @@ -12,9 +12,11 @@ class TestFetchHltbTimes: """Tests for fetch_hltb_times.""" def test_empty(self) -> None: + """No games means no lookups and an empty result.""" assert fetch_hltb_times([]) == [] def test_calls_batch(self) -> None: + """Results from the batch fetch are returned.""" mock_result = HLTBResult( app_id=440, game_name="TF2", completionist_hours=50.0, similarity=1.0 ) @@ -26,6 +28,7 @@ class TestFetchHltbTimes: assert len(results) == 1 def test_none_cache(self) -> None: + """Without a cache, an empty batch yields no results.""" with patch( "steam_backlog_enforcer.hltb._fetch_batch", return_value=[], @@ -34,6 +37,7 @@ class TestFetchHltbTimes: assert results == [] def test_explicit_cache(self) -> None: + """A passed cache does not invent results the batch did not return.""" with patch( "steam_backlog_enforcer.hltb._fetch_batch", return_value=[], diff --git a/steam_backlog_enforcer/tests/test_hltb_progress_cb.py b/steam_backlog_enforcer/tests/test_hltb_progress_cb.py index ec3105d..c08f785 100644 --- a/steam_backlog_enforcer/tests/test_hltb_progress_cb.py +++ b/steam_backlog_enforcer/tests/test_hltb_progress_cb.py @@ -9,7 +9,10 @@ from steam_backlog_enforcer._progress import use_progress class TestReportingProgressCb: + """reporting_progress_cb ties HLTB lookups to the active progress meter.""" + def test_starts_a_phase_and_advances_by_the_done_count(self) -> None: + """The callback starts a phase and advances by the done count.""" progress = MagicMock() with use_progress(progress): report = reporting_progress_cb("Fetching", 5, None) @@ -18,6 +21,7 @@ class TestReportingProgressCb: progress.advance.assert_called_once_with("Doom", step=2) def test_forwards_to_the_callers_own_meter(self) -> None: + """The callback also forwards every report to the caller's own meter.""" inner = MagicMock() with use_progress(MagicMock()): report = reporting_progress_cb("Fetching", 5, inner) diff --git a/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py b/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py index 277b8cc..2f3b9f9 100644 --- a/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py +++ b/steam_backlog_enforcer/tests/test_hltb_refetch_failure_keeps_cache.py @@ -9,7 +9,10 @@ from __future__ import annotations import json from unittest.mock import patch -from steam_backlog_enforcer import _hltb_confidence, _hltb_types, _scanning_confidence +from steam_backlog_enforcer import _hltb_confidence, _hltb_types +from steam_backlog_enforcer._scanning_confidence import ( + _refresh_candidate_confidence_batch, +) from steam_backlog_enforcer.steam_api import GameInfo _ENTRY = { @@ -56,7 +59,7 @@ def test_confidence_refresh_keeps_entry_on_failure() -> None: playtime_minutes=0, ) with patch.object(_hltb_confidence, "fetch_hltb_confidence"): - _scanning_confidence._refresh_candidate_confidence_batch([game], force=True) + _refresh_candidate_confidence_batch([game], force=True) assert _raw()["1"] == _ENTRY assert (game.comp_100_count, game.count_comp) == (100, 500) diff --git a/steam_backlog_enforcer/tests/test_hltb_search.py b/steam_backlog_enforcer/tests/test_hltb_search.py index 0b8cdc7..0941619 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search.py +++ b/steam_backlog_enforcer/tests/test_hltb_search.py @@ -2,61 +2,33 @@ from __future__ import annotations -import asyncio -from typing import TYPE_CHECKING, Any, Self +from typing import TYPE_CHECKING from unittest.mock import MagicMock import aiohttp -from steam_backlog_enforcer._hltb_search import ( - _search_one, - _SearchCtx, +from steam_backlog_enforcer.tests._hltb_search_fakes import ( + FakeResponse, + hit, + make_ctx, + make_session, + run_search, ) if TYPE_CHECKING: from collections.abc import Callable - -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - """Test init.""" - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def json(self) -> dict[str, Any]: - """Test json.""" - return self._json_data + from steam_backlog_enforcer._hltb_search import _SearchCtx -def _make_session(resp: _FakeResponse) -> MagicMock: - 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, +def _ctx( + session: MagicMock, *, progress_cb: Callable[..., object] | None = None ) -> _SearchCtx: - return _SearchCtx( - session=session, - search_url="https://example.com/search", - headers={}, - cache=cache if cache is not None else {}, - counter={"done": 0, "found": 0, "failed": 0}, - total=1, + """Return a search context whose counter also tracks failures.""" + return make_ctx( + session, progress_cb=progress_cb, + counter={"done": 0, "found": 0, "failed": 0}, ) @@ -65,49 +37,22 @@ class TestSearchOne: def test_found(self) -> None: """Test found.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 180000, - "game_id": 12345, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + ctx = _ctx(make_session(FakeResponse(200, hit("TF2", 180000, 12345)))) + result = run_search(ctx) assert result is not None assert result.app_id == 440 def test_found_without_game_id(self) -> None: """Found result with hltb_game_id=0 does not populate ctx.hltb_game_id.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 180000, - "game_id": 0, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + ctx = _ctx(make_session(FakeResponse(200, hit("TF2", 180000, 0)))) + result = run_search(ctx) assert result is not None assert 440 not in ctx.extras.hltb_game_id def test_not_found(self) -> None: """Test not found.""" - resp = _FakeResponse(200, {"data": []}) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + ctx = _ctx(make_session(FakeResponse(200, {"data": []}))) + result = run_search(ctx) assert result is None assert ctx.cache[440] == -1 @@ -115,17 +60,16 @@ class TestSearchOne: """Test error.""" session = MagicMock() session.post.side_effect = aiohttp.ClientError("fail") - ctx = _make_ctx(session) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + ctx = _ctx(session) + result = run_search(ctx) assert result is None assert 440 not in ctx.cache # a failed lookup is not a miss assert ctx.counter["failed"] == 1 def test_non_200(self) -> None: """Test non 200.""" - resp = _FakeResponse(500) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + ctx = _ctx(make_session(FakeResponse(500))) + result = run_search(ctx) assert result is None assert 440 not in ctx.cache # a failed lookup is not a miss assert ctx.counter["failed"] == 1 @@ -134,28 +78,21 @@ class TestSearchOne: """Test fallback name without year suffix.""" session = MagicMock() session.post.side_effect = [ - _FakeResponse(200, {"data": []}), - _FakeResponse( + FakeResponse(200, {"data": []}), + FakeResponse( 200, - { - "data": [ - { - "game_name": "Final Fantasy VII", - "game_alias": "", - "game_type": "game", - "comp_100": 141120, - "game_id": 435, - "comp_100_count": 746, - "count_comp": 10450, - } - ] - }, + hit( + "Final Fantasy VII", + 141120, + 435, + game_type="game", + comp_100_count=746, + count_comp=10450, + ), ), ] - ctx = _make_ctx(session) - result = asyncio.run( - _search_one(asyncio.Semaphore(1), ctx, 39140, "Final Fantasy VII (2013)") - ) + ctx = _ctx(session) + result = run_search(ctx, 39140, "Final Fantasy VII (2013)") assert result is not None assert result.app_id == 39140 assert result.comp_100_count == 746 @@ -164,27 +101,13 @@ class TestSearchOne: def test_with_progress_cb(self) -> None: """Test with progress cb.""" - resp = _FakeResponse(200, {"data": []}) cb = MagicMock() - ctx = _make_ctx(_make_session(resp), progress_cb=cb) - asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + ctx = _ctx(make_session(FakeResponse(200, {"data": []})), progress_cb=cb) + run_search(ctx) cb.assert_called_once() def test_low_similarity_skipped(self) -> None: """Test low similarity skipped.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Completely Different Name", - "game_alias": "", - "comp_100": 3600, - "game_id": 1, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + body = hit("Completely Different Name", 3600, 1) + result = run_search(_ctx(make_session(FakeResponse(200, body)))) assert result is None diff --git a/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py b/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py index 017770d..c150c3b 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_extract_leisure_hours.py @@ -2,39 +2,12 @@ from __future__ import annotations -from typing import Any, Self -from unittest.mock import MagicMock +from typing import Any from steam_backlog_enforcer._hltb_detail import _extract_leisure_hours from steam_backlog_enforcer._hltb_matching import _build_search_variants -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - """Test init.""" - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def json(self) -> dict[str, Any]: - """Test json.""" - return self._json_data - - -def _make_session(resp: _FakeResponse) -> MagicMock: - session = MagicMock() - session.post.return_value = resp - return session - - class TestExtractLeisureHours: """Tests for _extract_leisure_hours.""" diff --git a/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py index 8533f4d..65f2bce 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_fetch_batch_hltb.py @@ -3,62 +3,12 @@ from __future__ import annotations import asyncio -from typing import TYPE_CHECKING, Any, Self -from unittest.mock import AsyncMock, MagicMock, patch import pytest from steam_backlog_enforcer._hltb_failures import HLTBUnavailableError -from steam_backlog_enforcer._hltb_search import ( - _fetch_batch, - _SearchCtx, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - """Test init.""" - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def json(self) -> dict[str, Any]: - """Test json.""" - return self._json_data - - -def _make_session(resp: _FakeResponse) -> MagicMock: - 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, -) -> _SearchCtx: - return _SearchCtx( - session=session, - search_url="https://example.com/search", - headers={}, - cache=cache if cache is not None else {}, - counter={"done": 0, "found": 0}, - total=1, - progress_cb=progress_cb, - ) +from steam_backlog_enforcer._hltb_search import _fetch_batch +from steam_backlog_enforcer.tests._hltb_search_fakes import hltb_auth_patched class TestFetchBatchHltb: @@ -67,15 +17,7 @@ class TestFetchBatchHltb: def test_no_auth(self) -> None: """Test no auth.""" with ( - patch( - "steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=None, - ), + hltb_auth_patched(None), pytest.raises(HLTBUnavailableError, match="nothing refreshed"), ): asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) diff --git a/steam_backlog_enforcer/tests/test_hltb_search_part2.py b/steam_backlog_enforcer/tests/test_hltb_search_part2.py index 20d636e..9e90f3b 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_part2.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_part2.py @@ -2,44 +2,16 @@ from __future__ import annotations -import asyncio import json -from typing import Any, Self -from unittest.mock import AsyncMock, MagicMock, patch +from typing import Any from steam_backlog_enforcer._hltb_page_parse import _parse_game_page -from steam_backlog_enforcer._hltb_search import _fetch_batch from steam_backlog_enforcer._hltb_search_api import _pick_best_hltb_entry from steam_backlog_enforcer._hltb_types import ( HLTBResult, _AuthInfo, ) - - -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - """Test init.""" - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def json(self) -> dict[str, Any]: - """Test json.""" - return self._json_data - - -def _make_session(resp: _FakeResponse) -> MagicMock: - session = MagicMock() - session.post.return_value = resp - return session +from steam_backlog_enforcer.tests._hltb_search_fakes import fetch_tf2_batch class TestPickBestEntry: @@ -106,86 +78,27 @@ class TestPickBestEntry: def test_with_auth(self) -> None: """Test with auth.""" auth = _AuthInfo("token123", "ign_x", "ff") - with ( - patch( - "steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=auth, - ), - patch( - "steam_backlog_enforcer._hltb_search._search_one", - new_callable=AsyncMock, - return_value=HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ), - patch( - "steam_backlog_enforcer._hltb_search._fetch_leisure_times", - new_callable=AsyncMock, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert len(results) == 1 + found = HLTBResult( + app_id=440, + game_name="TF2", + completionist_hours=50.0, + similarity=1.0, + hltb_game_id=12345, + ) + results = fetch_tf2_batch(auth, found) + assert len(results) == 1 def test_with_auth_no_hp(self) -> None: """Test with auth no hp.""" auth = _AuthInfo("tok123") - with ( - patch( - "steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=auth, - ), - patch( - "steam_backlog_enforcer._hltb_search._search_one", - new_callable=AsyncMock, - return_value=None, - ), - patch( - "steam_backlog_enforcer._hltb_search._fetch_leisure_times", - new_callable=AsyncMock, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] + results = fetch_tf2_batch(auth, None) + assert results == [] def test_filters_none_results(self) -> None: """Test filters none results.""" auth = _AuthInfo("tok123") - with ( - patch( - "steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=auth, - ), - patch( - "steam_backlog_enforcer._hltb_search._search_one", - new_callable=AsyncMock, - return_value=None, - ), - patch( - "steam_backlog_enforcer._hltb_search._fetch_leisure_times", - new_callable=AsyncMock, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] + results = fetch_tf2_batch(auth, None) + assert results == [] class TestParseGamePage: diff --git a/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py b/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py index 0470bdc..51b7a66 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_url_memo.py @@ -4,43 +4,54 @@ from __future__ import annotations from unittest.mock import patch -from steam_backlog_enforcer import _hltb_search_api as api +from steam_backlog_enforcer._hltb_search_api import ( + _DEFAULT_SEARCH_URL, + _SEARCH_URL_TTL_SECONDS, + _get_hltb_search_url, + forget_hltb_search_url, +) _PKG = "steam_backlog_enforcer._hltb_search_api" _FOUND = "https://howlongtobeat.com/api/search/abc" class TestSearchUrlMemo: + """The discovered search URL is memoised with a TTL, never the fallback.""" + def test_a_discovered_url_is_reused_until_it_goes_stale(self) -> None: + """A fresh memo skips a second discovery.""" with ( patch(f"{_PKG}._discover_hltb_search_url", return_value=_FOUND) as find, patch(f"{_PKG}.time.monotonic", side_effect=[0.0, 10.0]), ): - assert api._get_hltb_search_url() == _FOUND - assert api._get_hltb_search_url() == _FOUND + assert _get_hltb_search_url() == _FOUND + assert _get_hltb_search_url() == _FOUND find.assert_called_once() def test_a_stale_memo_is_rediscovered(self) -> None: - later = api._SEARCH_URL_TTL_SECONDS + 1 + """A memo older than the TTL triggers a new discovery.""" + later = _SEARCH_URL_TTL_SECONDS + 1 with ( patch(f"{_PKG}._discover_hltb_search_url", return_value=_FOUND) as find, patch(f"{_PKG}.time.monotonic", side_effect=[0.0, later]), ): - api._get_hltb_search_url() - api._get_hltb_search_url() + _get_hltb_search_url() + _get_hltb_search_url() assert find.call_count == 2 def test_the_fallback_is_never_remembered(self) -> None: + """The default URL is not memoised, so discovery runs again.""" with patch( - f"{_PKG}._discover_hltb_search_url", return_value=api._DEFAULT_SEARCH_URL + f"{_PKG}._discover_hltb_search_url", return_value=_DEFAULT_SEARCH_URL ) as find: - api._get_hltb_search_url() - api._get_hltb_search_url() + _get_hltb_search_url() + _get_hltb_search_url() assert find.call_count == 2 def test_forgetting_forces_a_new_discovery(self) -> None: + """forget_hltb_search_url drops the memo.""" with patch(f"{_PKG}._discover_hltb_search_url", return_value=_FOUND) as find: - api._get_hltb_search_url() - api.forget_hltb_search_url() - api._get_hltb_search_url() + _get_hltb_search_url() + forget_hltb_search_url() + _get_hltb_search_url() assert find.call_count == 2 diff --git a/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py index 6f46e7d..31c5740 100644 --- a/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py +++ b/steam_backlog_enforcer/tests/test_hltb_search_zero_comp_100_skipped.py @@ -2,63 +2,19 @@ from __future__ import annotations -import asyncio -from typing import TYPE_CHECKING, Any, Self from unittest.mock import MagicMock, patch -from steam_backlog_enforcer._hltb_search import ( - _search_one, - _SearchCtx, -) from steam_backlog_enforcer._hltb_types import ( _SAVE_INTERVAL, ) - -if TYPE_CHECKING: - from collections.abc import Callable - - -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - """Test init.""" - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - """Test aenter.""" - return self - - async def __aexit__(self, *args: object) -> None: - """Test aexit.""" - - async def json(self) -> dict[str, Any]: - """Test json.""" - return self._json_data - - -def _make_session(resp: _FakeResponse) -> MagicMock: - 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, -) -> _SearchCtx: - return _SearchCtx( - session=session, - search_url="https://example.com/search", - headers={}, - cache=cache if cache is not None else {}, - counter={"done": 0, "found": 0}, - total=1, - progress_cb=progress_cb, - ) +from steam_backlog_enforcer.tests._hltb_search_fakes import ( + FakeResponse, + hit, + make_ctx, + make_session, + run_search, + search_tf2, +) class TestSearchOneGroup2: @@ -66,91 +22,35 @@ class TestSearchOneGroup2: def test_zero_comp_100_skipped(self) -> None: """Test zero comp 100 skipped.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 0, - "game_id": 1, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + result = search_tf2(hit("TF2", 0, 1)) assert result is None def test_alias_match(self) -> None: """Test alias match.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Team Fortress 2", - "game_alias": "TF2", - "comp_100": 180000, - "game_id": 12345, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + result = search_tf2(hit("Team Fortress 2", 180000, 12345, alias="TF2")) assert result is not None def test_full_edition_colon(self) -> None: """Test full edition colon.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2: Complete", - "game_alias": "", - "comp_100": 180000, - "game_id": 99, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + result = search_tf2(hit("TF2: Complete", 180000, 99)) assert result is not None def test_full_edition_dash(self) -> None: """Test full edition dash.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2 - Complete", - "game_alias": "", - "comp_100": 180000, - "game_id": 99, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + result = search_tf2(hit("TF2 - Complete", 180000, 99)) assert result is not None def test_save_interval(self) -> None: """Trigger the _SAVE_INTERVAL branch.""" - resp = _FakeResponse(200, {"data": []}) - ctx = _make_ctx(_make_session(resp)) + resp = FakeResponse(200, {"data": []}) + ctx = make_ctx(make_session(resp)) # Set done to one less than _SAVE_INTERVAL so it triggers save ctx.counter["done"] = _SAVE_INTERVAL - 1 with patch( "steam_backlog_enforcer._hltb_search_one.save_hltb_cache" ) as mock_save: - asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) + run_search(ctx) mock_save.assert_called_once() def test_colon_strip_fallback_rejects_cross_franchise_match(self) -> None: @@ -159,57 +59,43 @@ class TestSearchOneGroup2: "Vox Populi: Poland 2023" stripped to "Vox Populi" should NOT match "Vox Populi Vox Dei 2" (different game, low-similarity entry). """ - empty_resp = _FakeResponse(200, {"data": []}) - loose_resp = _FakeResponse( + empty_resp = FakeResponse(200, {"data": []}) + loose_resp = FakeResponse( 200, - { - "data": [ - { - "game_name": "Vox Populi Vox Dei 2", - "game_alias": "", - "game_type": "game", - "comp_100": 14400, - "comp_100_count": 9, - "count_comp": 57, - "game_id": 99999, - } - ] - }, + hit( + "Vox Populi Vox Dei 2", + 14400, + 99999, + game_type="game", + comp_100_count=9, + count_comp=57, + ), ) session = MagicMock() session.post.side_effect = [empty_resp, loose_resp] - ctx = _make_ctx(session) - result = asyncio.run( - _search_one(asyncio.Semaphore(1), ctx, 2590810, "Vox Populi: Poland 2023") - ) + ctx = make_ctx(session) + result = run_search(ctx, 2590810, "Vox Populi: Poland 2023") assert result is None def test_colon_strip_fallback_accepts_full_edition(self) -> None: """Colon-stripped fallback must still match when the HLTB entry is a full edition of the stripped name (name starts with stripped + ':'). """ - empty_resp = _FakeResponse(200, {"data": []}) - full_edition_resp = _FakeResponse( + empty_resp = FakeResponse(200, {"data": []}) + full_edition_resp = FakeResponse( 200, - { - "data": [ - { - "game_name": "Batman: Arkham Asylum", - "game_alias": "", - "game_type": "game", - "comp_100": 144000, - "comp_100_count": 300, - "count_comp": 5000, - "game_id": 11111, - } - ] - }, + hit( + "Batman: Arkham Asylum", + 144000, + 11111, + game_type="game", + comp_100_count=300, + count_comp=5000, + ), ) session = MagicMock() session.post.side_effect = [empty_resp, full_edition_resp] - ctx = _make_ctx(session) - result = asyncio.run( - _search_one(asyncio.Semaphore(1), ctx, 35140, "Batman: Arkham Asylum") - ) + ctx = make_ctx(session) + result = run_search(ctx, 35140, "Batman: Arkham Asylum") assert result is not None assert result.game_name == "Batman: Arkham Asylum" diff --git a/steam_backlog_enforcer/tests/test_jobs_control.py b/steam_backlog_enforcer/tests/test_jobs_control.py index 4687dae..8a36c8a 100644 --- a/steam_backlog_enforcer/tests/test_jobs_control.py +++ b/steam_backlog_enforcer/tests/test_jobs_control.py @@ -9,6 +9,7 @@ from unittest.mock import patch import pytest from steam_backlog_enforcer.jobs import _control +from steam_backlog_enforcer.jobs._control import _open_prompt_id from steam_backlog_enforcer.jobs._errors import JobError from steam_backlog_enforcer.jobs._events import ( ANSWERS_NAME, @@ -48,18 +49,18 @@ class TestOpenPromptId: def test_no_prompt(self) -> None: """A job that never asked has no open prompt.""" make_job() - assert _control._open_prompt_id(JOB_ID) is None + assert _open_prompt_id(JOB_ID) is None def test_unanswered_prompt_is_open(self) -> None: """The latest prompt without an answer.""" _waiting_job() - assert _control._open_prompt_id(JOB_ID) == "p1" + assert _open_prompt_id(JOB_ID) == "p1" def test_answered_prompt_is_closed(self) -> None: """An answer closes it.""" path = _waiting_job() append_jsonl(path / ANSWERS_NAME, {"prompt_id": "p1", "value": "x"}) - assert _control._open_prompt_id(JOB_ID) is None + assert _open_prompt_id(JOB_ID) is None class TestWriteAnswer: diff --git a/steam_backlog_enforcer/tests/test_jobs_prompter.py b/steam_backlog_enforcer/tests/test_jobs_prompter.py index edf4cf3..676754e 100644 --- a/steam_backlog_enforcer/tests/test_jobs_prompter.py +++ b/steam_backlog_enforcer/tests/test_jobs_prompter.py @@ -60,13 +60,13 @@ class TestWait: def test_cancel_marker_raises(self, tmp_path: Path) -> None: """A cancel request while blocked cancels the job.""" (tmp_path / CANCEL_NAME).touch() - with pytest.raises(JobCancelledError): - _prompter(tmp_path)._wait("p1") + with _ids(), pytest.raises(JobCancelledError): + _prompter(tmp_path).text("?") def test_zero_timeout_aborts(self, tmp_path: Path) -> None: """No answer in time aborts the prompt.""" - with pytest.raises(PromptAbortedError, match="No answer within"): - _prompter(tmp_path, timeout=0)._wait("p1") + with _ids(), pytest.raises(PromptAbortedError, match="No answer within"): + _prompter(tmp_path, timeout=0).text("?") def test_polls_until_answer_arrives(self, tmp_path: Path) -> None: """An answer written during the wait is picked up.""" @@ -78,16 +78,21 @@ class TestWait: append_jsonl(tmp_path / ANSWERS_NAME, {"prompt_id": "p1", "value": "later"}) clock.sleep.side_effect = answer_arrives - with patch(_TIME, clock): - assert prompter._wait("p1") == "later" + with _ids(), patch(_TIME, clock): + assert prompter.text("?") == "later" def test_other_prompts_answers_are_ignored(self, tmp_path: Path) -> None: """Only the line for this prompt id counts.""" prompter = _prompter(tmp_path, "x", timeout=10) clock = MagicMock() clock.monotonic.side_effect = [0.0, 1.0, 100.0] - with patch(_TIME, clock), pytest.raises(PromptAbortedError): - prompter._wait("p2") + # The question gets id p2, so p1's answer "x" must not satisfy it. + with ( + patch(_HEX, return_value="p2"), + patch(_TIME, clock), + pytest.raises(PromptAbortedError), + ): + prompter.text("?") class TestAsk: diff --git a/steam_backlog_enforcer/tests/test_jobs_registry.py b/steam_backlog_enforcer/tests/test_jobs_registry.py index 94b0158..e747ca3 100644 --- a/steam_backlog_enforcer/tests/test_jobs_registry.py +++ b/steam_backlog_enforcer/tests/test_jobs_registry.py @@ -11,6 +11,14 @@ from steam_backlog_enforcer._prompter import PromptAbortedError from steam_backlog_enforcer.config import State from steam_backlog_enforcer.jobs import _registry from steam_backlog_enforcer.jobs._errors import PrivilegedViaDaemonError +from steam_backlog_enforcer.jobs._registry import ( + _add_exception, + _assignment, + _cli, + _enforce_demo, + _restore_backup, + _with_args, +) from steam_backlog_enforcer.jobs._specs import JOB_FLAGS _PROGRESS = MagicMock() @@ -28,13 +36,13 @@ class TestAssignment: def test_no_game_assigned(self) -> None: """An empty name reads as ``None``.""" - data = _registry._assignment(State()) + data = _assignment(State()) assert data == {"current_app_id": None, "current_game_name": None} def test_game_assigned(self) -> None: """The assigned game is reported.""" state = State(current_app_id=620, current_game_name="Portal 2") - assert _registry._assignment(state)["current_game_name"] == "Portal 2" + assert _assignment(state)["current_game_name"] == "Portal 2" class TestCliWrappers: @@ -43,7 +51,7 @@ class TestCliWrappers: def test_cli_runs_command_and_reports_assignment(self) -> None: """The command gets the loaded config and state.""" command = MagicMock() - handler = _registry._cli(command) + handler = _cli(command) data = handler({}, _PROGRESS, _PROMPTER) config, state = command.call_args.args assert isinstance(state, State) @@ -56,7 +64,7 @@ class TestCliWrappers: def test_with_args_passes_the_param_as_argv(self) -> None: """One stringified positional arg.""" command = MagicMock() - handler = _registry._with_args(command, "app_id") + handler = _with_args(command, "app_id") handler({"app_id": 620}, _PROGRESS, _PROMPTER) assert command.call_args.args[2] == ["620"] @@ -67,7 +75,7 @@ class TestAddException: def test_builds_the_cli_argv(self) -> None: """Params become the argument list the CLI parses.""" with patch(_PREFIX + "cmd_add_exception") as command: - data = _registry._add_exception( + data = _add_exception( {"app_id": 620, "reason": "one two three four five"}, _PROGRESS, _PROMPTER, @@ -82,7 +90,7 @@ class TestEnforceDemo: def test_demo_runs_cmd_enforce(self) -> None: """The demo gets ``--demo`` and reports the exit code.""" with patch(_PREFIX + "cmd_enforce", return_value=0) as command: - data = _registry._enforce_demo({"demo": 1}, _PROGRESS, _PROMPTER) + data = _enforce_demo({"demo": 1}, _PROGRESS, _PROMPTER) assert command.call_args.args[2] == ["--demo"] assert data == {"exit_code": 0} @@ -92,13 +100,13 @@ class TestEnforceDemo: patch(_PREFIX + "cmd_enforce", return_value=1), pytest.raises(SystemExit) as exc, ): - _registry._enforce_demo({"demo": 1}, _PROGRESS, _PROMPTER) + _enforce_demo({"demo": 1}, _PROGRESS, _PROMPTER) assert exc.value.code == 1 def test_real_enforce_goes_to_daemon(self) -> None: """Without ``demo`` the job refuses.""" with pytest.raises(PrivilegedViaDaemonError): - _registry._enforce_demo({"demo": 0}, _PROGRESS, _PROMPTER) + _enforce_demo({"demo": 0}, _PROGRESS, _PROMPTER) class TestRestoreBackup: @@ -111,7 +119,7 @@ class TestRestoreBackup: patch(_PREFIX + "restore_backup") as restore, pytest.raises(PromptAbortedError), ): - _registry._restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) + _restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) restore.assert_not_called() def test_restore_without_previous_state(self) -> None: @@ -121,7 +129,7 @@ class TestRestoreBackup: patch(_PREFIX + "restore_backup", return_value=None), patch(_PREFIX + "_echo") as echo, ): - data = _registry._restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) + data = _restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) assert confirm.call_args.kwargs == {"backup_id": "b1"} echo.assert_called_once_with("Restored backup b1.") assert data == {"restored": "b1", "previous_backup": None} @@ -133,7 +141,7 @@ class TestRestoreBackup: patch(_PREFIX + "restore_backup", return_value=SimpleNamespace(id="b0")), patch(_PREFIX + "_echo") as echo, ): - data = _registry._restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) + data = _restore_backup({"backup_id": "b1"}, _PROGRESS, _PROMPTER) assert echo.call_count == 2 assert data == {"restored": "b1", "previous_backup": "b0"} diff --git a/steam_backlog_enforcer/tests/test_jobs_runner.py b/steam_backlog_enforcer/tests/test_jobs_runner.py index 02fc88a..4de4f00 100644 --- a/steam_backlog_enforcer/tests/test_jobs_runner.py +++ b/steam_backlog_enforcer/tests/test_jobs_runner.py @@ -27,6 +27,7 @@ from steam_backlog_enforcer.jobs._events import ( read_events, ) from steam_backlog_enforcer.jobs._job_io import JobLogSink +from steam_backlog_enforcer.jobs._runner import _run_command from steam_backlog_enforcer.tests._jobs_helpers import make_job if TYPE_CHECKING: @@ -63,7 +64,7 @@ def _run( sink = JobLogSink(writer) body = request or {"command": command, "params": {"x": 1}} with patch.dict(_runner.HANDLERS, {command: handler}), patch(_GATE): - return _runner._run_command(path, writer, sink, body), path + return _run_command(path, writer, sink, body), path class TestRunCommand: @@ -73,7 +74,7 @@ class TestRunCommand: """Commands outside the tables never run.""" path = make_job() writer = EventWriter(path) - outcome = _runner._run_command( + outcome = _run_command( path, writer, JobLogSink(writer), {"command": "frobnicate"} ) assert outcome.exit_code == _runner.EXIT_REFUSED @@ -141,7 +142,7 @@ class TestRunCommand: patch(_GATE, side_effect=SystemExit(1)), patch(_REASON, return_value=reason), ): - outcome = _runner._run_command(path, writer, sink, {"command": "scan"}) + outcome = _run_command(path, writer, sink, {"command": "scan"}) handler.assert_not_called() assert (outcome.summary, outcome.exit_code) == (summary, _runner.EXIT_REFUSED) diff --git a/steam_backlog_enforcer/tests/test_jobs_runner_parts.py b/steam_backlog_enforcer/tests/test_jobs_runner_parts.py index 551f331..881d2b0 100644 --- a/steam_backlog_enforcer/tests/test_jobs_runner_parts.py +++ b/steam_backlog_enforcer/tests/test_jobs_runner_parts.py @@ -10,7 +10,7 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer.jobs import _runner, _spawn, _store +from steam_backlog_enforcer.jobs import _spawn, _store from steam_backlog_enforcer.jobs._errors import JobCancelledError, JobError from steam_backlog_enforcer.jobs._events import ( RESULT_NAME, @@ -18,6 +18,16 @@ from steam_backlog_enforcer.jobs._events import ( read_events, ) from steam_backlog_enforcer.jobs._job_io import JobLogSink +from steam_backlog_enforcer.jobs._runner import ( + EXIT_FAILED, + EXIT_REFUSED, + _completed, + _failed, + _finish, + _hold_lock, + _on_sigterm, + _Outcome, +) from steam_backlog_enforcer.jobs._specs import JobFlags from steam_backlog_enforcer.tests._jobs_helpers import make_job @@ -26,8 +36,8 @@ if TYPE_CHECKING: from pathlib import Path -@pytest.fixture(autouse=True) -def sig() -> Iterator[MagicMock]: +@pytest.fixture(autouse=True, name="sig") +def fixture_sig() -> Iterator[MagicMock]: """Stand in for ``signal.signal`` so the test process keeps its handlers.""" with patch.object(signal, "signal") as fake: yield fake @@ -38,14 +48,14 @@ class TestOutcome: def test_ok_only_when_succeeded(self) -> None: """Only a succeeded state counts as ok.""" - assert _runner._Outcome("succeeded", "x", 0).ok is True - assert _runner._Outcome("failed", "x", 1).ok is False + assert _Outcome("succeeded", "x", 0).ok is True + assert _Outcome("failed", "x", 1).ok is False def test_failed_defaults_to_exit_one(self) -> None: """The helper builds a failed outcome.""" - outcome = _runner._failed("boom") - assert (outcome.state, outcome.exit_code) == ("failed", _runner.EXIT_FAILED) - assert _runner._failed("no", _runner.EXIT_REFUSED).exit_code == 2 + outcome = _failed("boom") + assert (outcome.state, outcome.exit_code) == ("failed", EXIT_FAILED) + assert _failed("no", EXIT_REFUSED).exit_code == 2 class TestHoldLock: @@ -53,14 +63,14 @@ class TestHoldLock: def test_read_only_job_holds_nothing(self) -> None: """Non-mutating jobs skip the lock.""" - assert _runner._hold_lock(JobFlags(mutating=False)) is None + assert _hold_lock(JobFlags(mutating=False)) is None def test_takes_the_lock_when_run_by_hand( self, monkeypatch: pytest.MonkeyPatch ) -> None: """No inherited descriptor: acquire, and make it non-inheritable.""" monkeypatch.delenv(_spawn.LOCK_FD_ENV, raising=False) - fd = _runner._hold_lock(JobFlags()) + fd = _hold_lock(JobFlags()) assert fd is not None try: assert os.get_inheritable(fd) is False @@ -77,7 +87,7 @@ class TestHoldLock: held = _store.acquire_mutating_lock() try: with pytest.raises(JobError) as info: - _runner._hold_lock(JobFlags()) + _hold_lock(JobFlags()) assert info.value.code == "busy" finally: os.close(held) @@ -89,7 +99,7 @@ class TestHoldLock: held = _store.acquire_mutating_lock() monkeypatch.setenv(_spawn.LOCK_FD_ENV, str(held)) try: - assert _runner._hold_lock(JobFlags()) == held + assert _hold_lock(JobFlags()) == held assert os.get_inheritable(held) is False assert _spawn.LOCK_FD_ENV not in os.environ finally: @@ -106,7 +116,7 @@ class TestHoldLock: monkeypatch.setenv(_spawn.LOCK_FD_ENV, str(fd)) try: with pytest.raises(JobError) as info: - _runner._hold_lock(JobFlags()) + _hold_lock(JobFlags()) assert info.value.code == "busy" assert "not the lock file" in info.value.message finally: @@ -118,7 +128,7 @@ class TestOnSigterm: def test_cancellable_raises_in_handler(self, sig: MagicMock) -> None: """The handler raises, and does nothing else.""" - _runner._on_sigterm(cancellable=True) + _on_sigterm(cancellable=True) sigterm, handler = sig.call_args.args assert sigterm == signal.SIGTERM with pytest.raises(JobCancelledError): @@ -126,7 +136,7 @@ class TestOnSigterm: def test_non_cancellable_ignores(self, sig: MagicMock) -> None: """SIGTERM is ignored for unsafe-to-stop commands.""" - _runner._on_sigterm(cancellable=False) + _on_sigterm(cancellable=False) sig.assert_called_once_with(signal.SIGTERM, signal.SIG_IGN) @@ -137,14 +147,14 @@ class TestCompleted: """``Error: ...`` then return is still a failed job.""" sink = JobLogSink(EventWriter(tmp_path)) sink("Error: no key\n") - outcome = _runner._completed(sink) + outcome = _completed(sink) assert (outcome.state, outcome.summary) == ("failed", "Error: no key") def test_summary_is_last_line(self, tmp_path: Path) -> None: """The last text line summarises; unflushed text is flushed.""" sink = JobLogSink(EventWriter(tmp_path)) sink("first\nlast") - outcome = _runner._completed(sink, {"a": 1}) + outcome = _completed(sink, {"a": 1}) assert (outcome.state, outcome.summary, outcome.data) == ( "succeeded", "last", @@ -153,7 +163,7 @@ class TestCompleted: def test_silent_command_says_done(self, tmp_path: Path) -> None: """No output at all reads as ``Done.``.""" - outcome = _runner._completed(JobLogSink(EventWriter(tmp_path))) + outcome = _completed(JobLogSink(EventWriter(tmp_path))) assert outcome.summary == "Done." assert outcome.exit_code == 0 @@ -164,8 +174,8 @@ class TestFinish: def test_writes_result_event_file_and_state(self, sig: MagicMock) -> None: """result event, result.json, then the final state.""" path = make_job(states=("running",)) - outcome = _runner._Outcome("succeeded", "ok", 0, {"when": object}) - _runner._finish(path, EventWriter(path), outcome) + outcome = _Outcome("succeeded", "ok", 0, {"when": object}) + _finish(path, EventWriter(path), outcome) events = read_events(path) assert [e["type"] for e in events[-2:]] == ["result", "state"] assert events[-2]["ok"] is True @@ -179,5 +189,5 @@ class TestFinish: def test_no_data_key_without_data(self) -> None: """A dataless outcome has no ``data`` field.""" path = make_job(states=("running",)) - _runner._finish(path, EventWriter(path), _runner._failed("bad")) + _finish(path, EventWriter(path), _failed("bad")) assert "data" not in read_events(path)[-2] diff --git a/steam_backlog_enforcer/tests/test_jobs_spawn.py b/steam_backlog_enforcer/tests/test_jobs_spawn.py index 287ef66..41ff1ef 100644 --- a/steam_backlog_enforcer/tests/test_jobs_spawn.py +++ b/steam_backlog_enforcer/tests/test_jobs_spawn.py @@ -17,6 +17,14 @@ from steam_backlog_enforcer.jobs._events import ( EventWriter, read_events, ) +from steam_backlog_enforcer.jobs._spawn import ( + _PACKAGE_ROOT, + _confirm_started, + _fail_unstarted, + _is_job_interpreter, + _output_tail, + _spawn_argv, +) from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job if TYPE_CHECKING: @@ -34,7 +42,7 @@ class TestSpawnArgv: def test_plain_without_systemd_unit(self, monkeypatch: pytest.MonkeyPatch) -> None: """Outside a unit the job is just the interpreter.""" monkeypatch.delenv("INVOCATION_ID", raising=False) - argv = _spawn._spawn_argv(make_job()) + argv = _spawn_argv(make_job()) assert argv[:3] == [sys.executable, "-m", "steam_backlog_enforcer.jobs"] assert argv[-2] == "run" @@ -42,7 +50,7 @@ class TestSpawnArgv: """Inside the server's unit the job gets its own scope.""" monkeypatch.setenv("INVOCATION_ID", "abc") with patch(_WHICH, return_value="/usr/bin/systemd-run"): - argv = _spawn._spawn_argv(make_job()) + argv = _spawn_argv(make_job()) assert argv[0] == "/usr/bin/systemd-run" assert f"--unit=sbe-job-{JOB_ID.lower()}" in argv assert argv[argv.index("--") + 1] == sys.executable @@ -53,7 +61,7 @@ class TestSpawnArgv: """No systemd-run on PATH means no wrapping.""" monkeypatch.setenv("INVOCATION_ID", "abc") with patch(_WHICH, return_value=None): - assert _spawn._spawn_argv(make_job())[0] == sys.executable + assert _spawn_argv(make_job())[0] == sys.executable class TestIsJobInterpreter: @@ -64,21 +72,21 @@ class TestIsJobInterpreter: fake = MagicMock() fake.return_value.read_bytes.return_value = f"{sys.executable}\0-m\0x".encode() with patch(_PATH, fake): - assert _spawn._is_job_interpreter(1) is True + assert _is_job_interpreter(1) is True def test_launcher_is_not(self) -> None: """systemd-run before it execs.""" fake = MagicMock() fake.return_value.read_bytes.return_value = b"/usr/bin/systemd-run\0--user" with patch(_PATH, fake): - assert _spawn._is_job_interpreter(1) is False + assert _is_job_interpreter(1) is False def test_missing_process(self) -> None: """A vanished process is not the interpreter.""" fake = MagicMock() fake.return_value.read_bytes.side_effect = FileNotFoundError with patch(_PATH, fake): - assert _spawn._is_job_interpreter(1) is False + assert _is_job_interpreter(1) is False class TestOutputTail: @@ -86,13 +94,13 @@ class TestOutputTail: def test_missing_log(self) -> None: """No output.log, no tail.""" - assert _spawn._output_tail(make_job()) == "" + assert _output_tail(make_job()) == "" def test_tail_is_stripped_and_capped(self) -> None: """Only the last characters survive.""" path = make_job() (path / OUTPUT_NAME).write_text("\n" + "x" * 1000 + "END\n", encoding="utf-8") - tail = _spawn._output_tail(path) + tail = _output_tail(path) assert len(tail) == 600 assert tail.endswith("END") @@ -104,7 +112,7 @@ class TestFailUnstarted: """The job ends failed, with the launcher output in its log.""" path = make_job(states=("queued",)) (path / OUTPUT_NAME).write_text("bus unreachable\n", encoding="utf-8") - err = _spawn._fail_unstarted(path, "Could not start.") + err = _fail_unstarted(path, "Could not start.") assert err.code == "op_failed" assert err.message == "Could not start. bus unreachable" events = read_events(path) @@ -125,14 +133,14 @@ class TestConfirmStarted: path = make_job() EventWriter(path).emit("result", ok=True, summary="done") proc = MagicMock(**{"poll.return_value": 0}) - _spawn._confirm_started(proc, path) + _confirm_started(proc, path) def test_launcher_exit_without_result_fails_job(self) -> None: """The launcher died before the job ran.""" path = make_job() proc = MagicMock(**{"poll.return_value": 3}) with pytest.raises(JobError) as info: - _spawn._confirm_started(proc, path) + _confirm_started(proc, path) assert info.value.code == "op_failed" assert "launcher exit 3" in info.value.message @@ -140,7 +148,7 @@ class TestConfirmStarted: """The launcher exec'd into the job's Python.""" proc = MagicMock(pid=9, **{"poll.return_value": None}) with patch.object(_spawn, "_is_job_interpreter", return_value=True): - _spawn._confirm_started(proc, make_job()) + _confirm_started(proc, make_job()) def test_waits_then_gives_up_quietly(self) -> None: """Past the deadline the reconciler takes over; no error here.""" @@ -151,7 +159,7 @@ class TestConfirmStarted: patch.object(_spawn, "_is_job_interpreter", return_value=False), patch(_CLOCK, clock), ): - _spawn._confirm_started(proc, make_job()) + _confirm_started(proc, make_job()) clock.sleep.assert_called_once() @@ -188,7 +196,7 @@ class TestSpawnJob: kwargs = popen.call_args.kwargs assert kwargs["pass_fds"] == () assert _spawn.LOCK_FD_ENV not in kwargs["env"] - assert kwargs["env"]["PYTHONPATH"] == str(_spawn._PACKAGE_ROOT) + assert kwargs["env"]["PYTHONPATH"] == str(_PACKAGE_ROOT) def test_popen_oserror_fails_job(self) -> None: """An exec failure marks the job failed and raises ``op_failed``.""" diff --git a/steam_backlog_enforcer/tests/test_jobs_store.py b/steam_backlog_enforcer/tests/test_jobs_store.py index 9039f4d..8dd17da 100644 --- a/steam_backlog_enforcer/tests/test_jobs_store.py +++ b/steam_backlog_enforcer/tests/test_jobs_store.py @@ -11,6 +11,7 @@ import pytest from steam_backlog_enforcer.jobs import _store from steam_backlog_enforcer.jobs._errors import JobError, PrivilegedViaDaemonError from steam_backlog_enforcer.jobs._events import PID_NAME, REQUEST_NAME, read_events +from steam_backlog_enforcer.jobs._store import _ID_PATTERN from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job _GATE = "steam_backlog_enforcer.jobs._store.lock_reason" @@ -47,7 +48,7 @@ class TestJobDir: def test_new_job_ids_are_valid_and_sortable() -> None: """Fresh ids match the pattern and are distinct.""" first, second = _store.new_job_id(), _store.new_job_id() - assert _store._ID_PATTERN.fullmatch(first) + assert _ID_PATTERN.fullmatch(first) assert first != second @@ -164,7 +165,7 @@ class TestCreateJob: """The child gets the lock fd; the server releases its copy after.""" seen: dict[str, object] = {} - def fake_spawn(path: object, lock_fd: int | None) -> int: + def fake_spawn(_path: object, lock_fd: int | None) -> int: seen["fd"] = lock_fd with pytest.raises(JobError): # Still held while spawning. _store.acquire_mutating_lock() diff --git a/steam_backlog_enforcer/tests/test_jobs_view.py b/steam_backlog_enforcer/tests/test_jobs_view.py index 7c65191..64cb974 100644 --- a/steam_backlog_enforcer/tests/test_jobs_view.py +++ b/steam_backlog_enforcer/tests/test_jobs_view.py @@ -15,6 +15,7 @@ from steam_backlog_enforcer.jobs._events import ( EventWriter, read_events, ) +from steam_backlog_enforcer.jobs._view import _pid_alive, _reconcile from steam_backlog_enforcer.tests._jobs_helpers import JOB_ID, make_job, write_result if TYPE_CHECKING: @@ -44,36 +45,36 @@ class TestPidAlive: def test_no_pid_file_is_unknown(self) -> None: """Before spawn there is nothing to check.""" - assert _view._pid_alive(make_job()) is None + assert _pid_alive(make_job()) is None def test_garbage_pid_is_unknown(self) -> None: """An unreadable pid file counts as not written yet.""" - assert _view._pid_alive(make_job(pid="soon")) is None + assert _pid_alive(make_job(pid="soon")) is None def test_missing_process_is_dead(self) -> None: """No /proc entry means the process is gone.""" path = make_job(pid="999999\n") with patch(_PATH, _proc(None)): - assert _view._pid_alive(path) is False + assert _pid_alive(path) is False def test_job_cmdline_is_alive(self) -> None: """Our module and our job id on the command line.""" path = make_job(pid="5\n") cmd = f"python\0-m\0steam_backlog_enforcer.jobs\0run\0/x/{JOB_ID}".encode() with patch(_PATH, _proc(cmd)): - assert _view._pid_alive(path) is True + assert _pid_alive(path) is True def test_recycled_pid_is_dead(self) -> None: """Some other program on the same pid is not the job.""" path = make_job(pid="5\n") with patch(_PATH, _proc(b"vim\0notes.txt")): - assert _view._pid_alive(path) is False + assert _pid_alive(path) is False def test_zombie_is_dead(self) -> None: """A zombie has an empty command line.""" path = make_job(pid="5\n") with patch(_PATH, _proc(b"")): - assert _view._pid_alive(path) is False + assert _pid_alive(path) is False class TestPidOf: @@ -96,33 +97,33 @@ class TestReconcile: def test_terminal_is_left_alone(self) -> None: """Finished jobs are never touched.""" path = make_job(states=("running", "succeeded")) - _view._reconcile(path, "succeeded") + _reconcile(path, "succeeded") assert len(read_events(path)) == 2 def test_fresh_job_without_pid_gets_grace(self) -> None: """Between mkdir and spawn the job is simply young.""" path = make_job() - _view._reconcile(path, "running") + _reconcile(path, "running") assert read_events(path)[-1]["state"] == "running" def test_old_job_without_pid_fails(self) -> None: """Past the grace period a pid-less job is dead.""" path = make_job() _age(path, 120) - _view._reconcile(path, "running") + _reconcile(path, "running") assert read_events(path)[-1]["state"] == "failed" def test_live_process_is_left_alone(self) -> None: """A running process means the job is fine.""" path = make_job(pid="5\n") with patch.object(_view, "_pid_alive", return_value=True): - _view._reconcile(path, "running") + _reconcile(path, "running") assert read_events(path)[-1]["state"] == "running" def test_dead_process_fails_job_with_result(self) -> None: """A dead pid gets a result then a failed state.""" path = make_job(pid="5\n") - _view._reconcile(path, "running") + _reconcile(path, "running") last_two = read_events(path)[-2:] assert last_two[0]["type"] == "result" assert last_two[0]["ok"] is False diff --git a/steam_backlog_enforcer/tests/test_ledger_earners.py b/steam_backlog_enforcer/tests/test_ledger_earners.py index fdb23e6..9cfe39d 100644 --- a/steam_backlog_enforcer/tests/test_ledger_earners.py +++ b/steam_backlog_enforcer/tests/test_ledger_earners.py @@ -18,39 +18,25 @@ from unittest.mock import call, patch import earned_time import pytest -from steam_backlog_enforcer import _ledger_earners, _leetcode_bonus, _workout_budget from steam_backlog_enforcer._budget_resolve import BudgetResolution, resolve_budget from steam_backlog_enforcer._ledger_earners import ledger_answer, reset_cache 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, + PIANO, + WORKOUT, + earner_caches_cleared, +) if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path _PKG = "steam_backlog_enforcer._ledger_earners" -_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, -) @pytest.fixture(autouse=True) @@ -60,11 +46,8 @@ 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 class TestLedgerAnswer: @@ -110,12 +93,14 @@ class TestLedgerAnswer: assert read.call_count == 1 def test_a_failure_is_not_cached(self) -> None: + """A failed ledger read is retried on the next call.""" with patch("earned_time.done_today", return_value=None) as read: assert ledger_answer(PIANO) is None assert ledger_answer(PIANO) is None assert read.call_count == 2 def test_reset_cache_forces_a_reread(self) -> None: + """Resetting the cache makes the next call read the ledger again.""" with patch("earned_time.done_today", return_value=True) as read: ledger_answer(PIANO) reset_cache() @@ -138,10 +123,10 @@ def _resolve( with ( pin_today(AFTER_CUT), patch("earned_time.EARNERS", (*earned_time.EARNERS, earner)), - patch(_WORKOUT, return_value=False), - patch(_LEETCODE, return_value=False), + patch(WORKOUT, return_value=False), + patch(LEETCODE, return_value=False), patch( - _ANSWER, side_effect=lambda asked: answer if asked is earner else False + ANSWER, side_effect=lambda asked: answer if asked is earner else False ) as asked, ): resolved = resolve_budget(Config()) @@ -153,20 +138,23 @@ class TestAGenericEarnerInTheBudget: """A registered earner joins the sum with no code of its own here.""" def test_its_credit_adds_its_term(self) -> None: + """A yes from its ledger adds the earner's term to the budget.""" resolved = _resolve(answer=True) - assert resolved.seconds == 4.5 * _HOUR - assert resolved.earned_seconds["piano"] == 0.5 * _HOUR + assert resolved.seconds == 4.5 * HOUR + assert resolved.earned_seconds["piano"] == 0.5 * HOUR assert resolved.reason.endswith(", Piano credited") def test_no_credit_adds_nothing(self) -> None: + """A no adds nothing and the reason says it was not credited.""" resolved = _resolve(answer=False) - assert resolved.seconds == 4 * _HOUR + assert resolved.seconds == 4 * HOUR assert resolved.earned_seconds["piano"] == 0.0 assert "no Piano credited" in resolved.reason def test_unknown_adds_nothing_and_reads_differently(self) -> None: + """An unknown answer adds nothing and reads as unknown.""" resolved = _resolve(answer=None) - assert resolved.seconds == 4 * _HOUR + assert resolved.seconds == 4 * HOUR assert "Piano unknown" in resolved.reason def test_its_penalty_lowers_the_base_by_what_it_pays_back(self) -> None: @@ -180,17 +168,17 @@ class TestAGenericEarnerInTheBudget: ) missed = _resolve(answer=False, earner=penalised) earned = _resolve(answer=True, earner=penalised) - assert missed.base_seconds == 3.5 * _HOUR - assert missed.seconds == 3.5 * _HOUR - assert earned.seconds == 4 * _HOUR + assert missed.base_seconds == 3.5 * HOUR + assert missed.seconds == 3.5 * HOUR + assert earned.seconds == 4 * HOUR def test_the_built_in_earners_are_not_read_twice(self) -> None: """Only an earner with no dedicated reader goes through the ledger path.""" with ( pin_today(AFTER_CUT), - patch(_WORKOUT, return_value=False), - patch(_LEETCODE, return_value=False), - patch(_ANSWER, return_value=False) as asked, + patch(WORKOUT, return_value=False), + patch(LEETCODE, return_value=False), + patch(ANSWER, return_value=False) as asked, ): resolved = resolve_budget(Config()) dedicated = (earned_time.WORKOUT, earned_time.LEETCODE, earned_time.READING) diff --git a/steam_backlog_enforcer/tests/test_ledger_units.py b/steam_backlog_enforcer/tests/test_ledger_units.py index 1ca4d57..3f97785 100644 --- a/steam_backlog_enforcer/tests/test_ledger_units.py +++ b/steam_backlog_enforcer/tests/test_ledger_units.py @@ -66,14 +66,18 @@ def _clear() -> Iterator[None]: class TestLedgerUnits: + """ledger_units counts signed ledger rows and caches the count.""" + def test_no_ledger_is_none_and_says_so( self, caplog: pytest.LogCaptureFixture ) -> None: + """An earner without a ledger yields None and logs a warning.""" with caplog.at_level(logging.WARNING): assert ledger_units(replace(BLOCKS, ledger=None)) is None assert "Earner Blocks has no ledger" in caplog.text def test_counts_the_signed_rows_under_home(self, tmp_path: Path) -> None: + """Each signed credit row under HOME counts as one unit.""" assert BLOCKS.ledger is not None ledger_dir = (tmp_path / BLOCKS.ledger).parent ledger_dir.mkdir(parents=True) @@ -85,6 +89,7 @@ class TestLedgerUnits: assert ledger_units(BLOCKS) == 2 def test_a_count_is_cached_until_its_ttl(self) -> None: + """A count is reused until its TTL runs out.""" with ( patch("earned_time.credit_units", return_value=3) as read, patch(f"{_PKG}.time.monotonic", side_effect=[0.0, 30.0, 61.0]), @@ -93,12 +98,14 @@ class TestLedgerUnits: assert read.call_count == 2 def test_a_failure_is_not_cached(self) -> None: + """A failed read is retried, not cached.""" with patch("earned_time.credit_units", return_value=None) as read: assert ledger_units(BLOCKS) is None assert ledger_units(BLOCKS) is None assert read.call_count == 2 def test_reset_cache_drops_counts(self) -> None: + """reset_cache forces the next call to reread.""" with patch("earned_time.credit_units", return_value=1) as read: ledger_units(BLOCKS) _ledger_earners.reset_cache() @@ -121,20 +128,26 @@ def _resolve(units: int | None) -> BudgetResolution: class TestACountedGateInTheBudget: + """Ledger units flow through the registry into the budget reason.""" + def test_units_earn_through_the_registry(self) -> None: + """Two units earn the registry minutes for two and say x2.""" resolved = _resolve(2) assert resolved.earned_seconds["blocks"] == BLOCKS.gaming_for(2) * 60.0 assert resolved.reason.endswith(", Blocks credited x2") def test_one_unit_reads_like_a_flat_credit(self) -> None: + """One unit reads like a flat credit, without a multiplier.""" assert _resolve(1).reason.endswith(", Blocks credited") def test_none_and_unknown(self) -> None: + """Zero units and an unknown count are both named in the reason.""" assert "no Blocks credited" in _resolve(0).reason assert "Blocks unknown" in _resolve(None).reason def test_view_lists_what_a_full_day_pays() -> None: + """The view shows the capped maximum, or the flat minutes without units.""" # Duck-typed stand-ins: ``plain`` is a pre-0.5 earner without units. capped = SimpleNamespace(gaming_minutes=15, max_gaming_minutes=60) plain = SimpleNamespace(gaming_minutes=30) diff --git a/steam_backlog_enforcer/tests/test_leetcode_bonus.py b/steam_backlog_enforcer/tests/test_leetcode_bonus.py index f81b323..86a7476 100644 --- a/steam_backlog_enforcer/tests/test_leetcode_bonus.py +++ b/steam_backlog_enforcer/tests/test_leetcode_bonus.py @@ -14,8 +14,11 @@ from unittest.mock import patch import pytest -from steam_backlog_enforcer import _leetcode_bonus -from steam_backlog_enforcer._leetcode_bonus import leetcode_solved_today, reset_cache +from steam_backlog_enforcer._leetcode_bonus import ( + _fetch_leetcode_today, + leetcode_solved_today, + reset_cache, +) from steam_backlog_enforcer.config import Config if TYPE_CHECKING: @@ -111,7 +114,7 @@ class TestFetchLeetcodeToday: resp = conn.return_value.getresponse.return_value resp.status = 200 resp.read.return_value = payload - _leetcode_bonus._fetch_leetcode_today("http://127.0.0.1:8771/api/status") + _fetch_leetcode_today("http://127.0.0.1:8771/api/status") def test_a_non_200_is_an_error(self) -> None: """An error page is not a payload.""" @@ -123,7 +126,7 @@ class TestFetchLeetcodeToday: resp.status = 500 resp.reason = "Internal Server Error" resp.read.return_value = b"" - _leetcode_bonus._fetch_leetcode_today("http://127.0.0.1:8771/api/status") + _fetch_leetcode_today("http://127.0.0.1:8771/api/status") def test_a_good_answer_is_read(self) -> None: """The happy path returns the flag.""" @@ -141,12 +144,7 @@ class TestFetchLeetcodeToday: resp = conn.return_value.getresponse.return_value resp.status = 200 resp.read.return_value = payload - assert ( - _leetcode_bonus._fetch_leetcode_today( - "http://127.0.0.1:8771/api/status" - ) - is True - ) + assert _fetch_leetcode_today("http://127.0.0.1:8771/api/status") is True @pytest.mark.parametrize( ("block", "expected"), @@ -167,4 +165,4 @@ class TestFetchLeetcodeToday: resp.status = 200 resp.read.return_value = payload url = "http://127.0.0.1:8771/api/status" - assert _leetcode_bonus._fetch_leetcode_today(url) is expected + assert _fetch_leetcode_today(url) is expected diff --git a/steam_backlog_enforcer/tests/test_leetcode_ledger.py b/steam_backlog_enforcer/tests/test_leetcode_ledger.py index 036cef9..a25bf78 100644 --- a/steam_backlog_enforcer/tests/test_leetcode_ledger.py +++ b/steam_backlog_enforcer/tests/test_leetcode_ledger.py @@ -23,7 +23,7 @@ from steam_backlog_enforcer._leetcode_ledger import read_ledger_solved_today from steam_backlog_enforcer.tests._ledger_fixtures import ( KEY, credit, - key_file, + fixture_key_file, sign, write_ledger, ) @@ -33,7 +33,7 @@ if TYPE_CHECKING: import pytest -__all__ = ["key_file"] +__all__ = ["fixture_key_file"] class TestSignatureCanonicalisation: diff --git a/steam_backlog_enforcer/tests/test_leetcode_ledger_errors.py b/steam_backlog_enforcer/tests/test_leetcode_ledger_errors.py index 04fff0e..776a9a1 100644 --- a/steam_backlog_enforcer/tests/test_leetcode_ledger_errors.py +++ b/steam_backlog_enforcer/tests/test_leetcode_ledger_errors.py @@ -19,14 +19,14 @@ from steam_backlog_enforcer._leetcode_ledger import ( from steam_backlog_enforcer.tests._ledger_fixtures import ( LEDGER_PKG, credit, - key_file, + fixture_key_file, write_ledger, ) if TYPE_CHECKING: from pathlib import Path -__all__ = ["key_file"] +__all__ = ["fixture_key_file"] class TestCannotCheck: diff --git a/steam_backlog_enforcer/tests/test_leetcode_paid_day.py b/steam_backlog_enforcer/tests/test_leetcode_paid_day.py index 7a2408a..6ebd4b3 100644 --- a/steam_backlog_enforcer/tests/test_leetcode_paid_day.py +++ b/steam_backlog_enforcer/tests/test_leetcode_paid_day.py @@ -17,7 +17,11 @@ from unittest.mock import patch import pytest from steam_backlog_enforcer import _leetcode_bonus, _leetcode_ledger -from steam_backlog_enforcer._leetcode_bonus import leetcode_solved_today, reset_cache +from steam_backlog_enforcer._leetcode_bonus import ( + _fetch_leetcode_today, + leetcode_solved_today, + reset_cache, +) from steam_backlog_enforcer.config import Config if TYPE_CHECKING: @@ -62,15 +66,17 @@ def _serve(block: dict[str, bool]) -> object: def test_the_fallback_pays_only_a_paid_day( block: dict[str, bool], *, expected: bool ) -> None: + """The endpoint fallback earns the hour only for a paid or stood-down day.""" conn = _serve(block) try: - assert _leetcode_bonus._fetch_leetcode_today(_URL) is expected + assert _fetch_leetcode_today(_URL) is expected finally: conn.stop() @pytest.mark.parametrize("off", [True, False]) def test_gate_off_is_read_from_the_endpoint(*, off: bool) -> None: + """gate_off_now returns the endpoint's live gate_off value.""" conn = _serve({"solved_today": False, "gate_off": off}) try: assert _leetcode_bonus.gate_off_now(_URL) is off @@ -79,11 +85,13 @@ def test_gate_off_is_read_from_the_endpoint(*, off: bool) -> None: def test_an_unreachable_endpoint_means_the_gate_is_on() -> None: + """An unreachable endpoint fails closed: the gate counts as on.""" with patch(f"{_PKG}.get_status", side_effect=OSError("refused")): assert _leetcode_bonus.gate_off_now(_URL) is False def test_an_old_endpoint_without_the_field_means_on() -> None: + """An endpoint without gate_off counts as gate on.""" conn = _serve({"solved_today": True}) try: assert _leetcode_bonus.gate_off_now(_URL) is False @@ -92,6 +100,7 @@ def test_an_old_endpoint_without_the_field_means_on() -> None: def test_the_ledger_read_is_told_the_live_answer() -> None: + """leetcode_solved_today hands the live gate_off answer to the ledger read.""" with ( patch(f"{_PKG}.gate_off_now", return_value=True), patch(f"{_PKG}.read_ledger_solved_today", return_value=True) as read, @@ -101,6 +110,7 @@ def test_the_ledger_read_is_told_the_live_answer() -> None: def test_the_ledger_reader_passes_it_on(tmp_path: Path) -> None: + """The ledger reader forwards gate_off to earned_time.done_today.""" with patch.object( _leetcode_ledger.earned_time, "done_today", return_value=True ) as done: diff --git a/steam_backlog_enforcer/tests/test_leisure_kill.py b/steam_backlog_enforcer/tests/test_leisure_kill.py index 119441e..7e2dea7 100644 --- a/steam_backlog_enforcer/tests/test_leisure_kill.py +++ b/steam_backlog_enforcer/tests/test_leisure_kill.py @@ -1,4 +1,4 @@ -"""Phase 2 at 0: which leisure pids die, how, and what the audit log says.""" +"""Phase 2 at 0: how leisure pids die and what the audit log says.""" from __future__ import annotations @@ -8,114 +8,56 @@ import signal from typing import TYPE_CHECKING from unittest.mock import patch -from leisure_watch.facts import Focus, Now, Reason +from leisure_watch.facts import Reason import pytest from steam_backlog_enforcer import _leisure_kill -from steam_backlog_enforcer._leisure_kill import ( - EVENT_LEISURE_KILL, - LeisureKiller, - fact_targets, -) +from steam_backlog_enforcer._leisure_kill import EVENT_LEISURE_KILL, LeisureKiller 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 +from steam_backlog_enforcer.tests._leisure_kill_world import ( + RULES as _RULES, +) +from steam_backlog_enforcer.tests._leisure_kill_world import ( + SPENT as _SPENT, +) +from steam_backlog_enforcer.tests._leisure_kill_world import ( + StubLog, + build_proc, +) +from steam_backlog_enforcer.tests._leisure_kill_world import ( + now_with as _now, +) +from steam_backlog_enforcer.tests._leisure_kill_world import ( + verdict_of as _verdict, +) if TYPE_CHECKING: from collections.abc import Iterator from pathlib import Path from unittest.mock import MagicMock -_HELIUM = "/opt/helium-browser-bin/helium" -_RULES = PlaytimeRules( - budget_seconds=100.0, - warn_at=(), - sigkill_after=30.0, - count_launchers=False, - enforcement=True, - demo=False, - leisure=True, -) -_SPENT = PlaytimeState(day_key="2026-11-02", seconds=100.0) + +@pytest.fixture(name="proc") +def fixture_proc(tmp_path: Path) -> Path: + """Isolated /proc with mpv, helium, chromium and a root-owned app.""" + return build_proc(tmp_path) -class StubLog: - def __init__(self) -> None: - self.records: list[tuple[str, dict[str, object]]] = [] - - def record(self, event: str, **fields: object) -> None: - self.records.append((event, fields)) - - -def _now(*reasons: Reason) -> Now: - return Now( - ts=0.0, - seq=1, - state="leisure", - paused_by=None, - reasons=reasons, - focus=Focus("Helium", "", None, idle=False), - ) - - -def _verdict(fact: Now | None, paused: str | None = None) -> ShadowVerdict: - return ShadowVerdict(paused_by=paused, keys=("k",), fact=fact) - - -@pytest.fixture -def proc(tmp_path: Path) -> Path: - 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 - - -@pytest.fixture -def kill() -> Iterator[MagicMock]: +@pytest.fixture(name="kill") +def fixture_kill() -> Iterator[MagicMock]: + """Replace ``os.kill`` so no real process is ever signalled.""" with patch.object(os, "kill") as mock: yield mock -class TestFactTargets: - def test_media_and_focus_apps_die_browsers_and_games_do_not( - self, proc: Path - ) -> None: - del proc - fact = _now( - Reason("media", "app:mpv", 10, "mpv"), - Reason("focus", "domain:youtube.com", 20, "YouTube"), - Reason("media", "domain:youtube.com", None, "url"), - Reason("game", "steam:440", 50, "tf2"), - Reason("mystery", "x", 10, "?"), - ) - assert fact_targets(fact) == {10: "app:mpv"} - - @pytest.mark.parametrize( - ("pid", "key"), - [ - (20, "failclosed:helium"), - (20, "failclosed:helium:unreported"), - (20, "failclosed:helium:audio"), - (30, "browser:chromium"), - ], - ) - def test_a_non_reporting_browser_dies(self, proc: Path, pid: int, key: str) -> None: - del proc - fact = _now(Reason("failclosed", key, pid, "x")) - assert fact_targets(fact) == {pid: key} - - def test_failclosed_without_a_pid_names_nothing(self) -> None: - fact = _now(Reason("failclosed", "failclosed:display", None, "X")) - assert fact_targets(fact) == {} - - class TestEnforce: + """Kill escalation, sparing rules and failure handling.""" + def test_mpv_is_terminated_then_killed_after_30_s( self, proc: Path, kill: MagicMock ) -> None: + """mpv gets SIGTERM, then SIGKILL after 30 s, each logged once.""" del proc log = StubLog() killer = LeisureKiller(log) @@ -145,6 +87,7 @@ class TestEnforce: def test_untrusted_watcher_sweeps_but_spares_root( self, proc: Path, kill: MagicMock ) -> None: + """Untrusted for 30 s: user leisure pids are swept, root ones spared.""" add_proc(proc, FakeProc(41, exe="/usr/bin/mpv", uid=0)) killer = LeisureKiller(StubLog()) for stamp in range(0, 31, 3): # untrusted for the whole 30 s window @@ -152,6 +95,7 @@ class TestEnforce: assert sorted(c.args[0] for c in kill.call_args_list) == [10, 20, 30] def test_paused_is_not_stale(self, proc: Path, kill: MagicMock) -> None: + """A paused watcher is not treated as stale, so nothing dies.""" del proc LeisureKiller(StubLog()).enforce( _SPENT, _RULES, verdict=_verdict(None, paused="i3lock"), stamp=1.0 @@ -159,6 +103,7 @@ class TestEnforce: kill.assert_not_called() def test_under_budget_forgets_the_timers(self, proc: Path, kill: MagicMock) -> None: + """Dropping under budget resets the SIGKILL timer.""" del proc killer = LeisureKiller(None) verdict = _verdict(_now(Reason("media", "app:mpv", 10, "mpv"))) @@ -171,6 +116,7 @@ class TestEnforce: def test_a_target_that_drops_out_restarts_its_timer( self, proc: Path, kill: MagicMock ) -> None: + """A target missing from one fact starts its timer over.""" del proc killer = LeisureKiller(None) listed = _verdict(_now(Reason("media", "app:mpv", 10, "mpv"))) @@ -182,6 +128,7 @@ class TestEnforce: def test_a_pid_reused_by_a_new_process_starts_over( self, proc: Path, kill: MagicMock ) -> None: + """A new start time on the same pid restarts the timer.""" killer = LeisureKiller(None) verdict = _verdict(_now(Reason("media", "app:mpv", 10, "mpv"))) killer.enforce(_SPENT, _RULES, verdict=verdict, stamp=0.0) @@ -194,6 +141,7 @@ class TestEnforce: def test_a_vanished_or_unsignallable_pid_is_not_logged( self, proc: Path, kill: MagicMock ) -> None: + """Pids that cannot be signalled leave no audit record.""" add_proc(proc, FakeProc(11, exe="/usr/bin/vlc")) (proc / "12").mkdir() (proc / "12" / "status").write_text("Uid:\t1000\t1000\n", encoding="utf-8") @@ -220,6 +168,7 @@ class TestEnforce: def test_never_with_enforcement_off_in_a_demo_or_dormant( self, proc: Path, kill: MagicMock, rules: PlaytimeRules ) -> None: + """Enforcement off, demo or dormant Phase 2 never kills.""" del proc LeisureKiller(None).enforce(_SPENT, rules, verdict=_verdict(None), stamp=0.0) kill.assert_not_called() @@ -227,6 +176,7 @@ class TestEnforce: def test_a_bug_never_escapes_into_the_loop( self, caplog: pytest.LogCaptureFixture ) -> None: + """An internal error is logged once and never raised.""" killer = LeisureKiller(None) with patch.object(_leisure_kill, "sweep_targets", side_effect=RecursionError): for stamp in range(0, 34, 3): # sweeps (and fails) at 30 and 33 @@ -236,6 +186,7 @@ class TestEnforce: def test_a_failed_sigkill_is_retried_and_logged_once_delivered( self, proc: Path, kill: MagicMock ) -> None: + """A failed SIGKILL is retried and logged only once it lands.""" del proc kill.side_effect = [None, ProcessLookupError, None, None] log = StubLog() diff --git a/steam_backlog_enforcer/tests/test_leisure_kill_debounce.py b/steam_backlog_enforcer/tests/test_leisure_kill_debounce.py index 60aa61c..3c50b60 100644 --- a/steam_backlog_enforcer/tests/test_leisure_kill_debounce.py +++ b/steam_backlog_enforcer/tests/test_leisure_kill_debounce.py @@ -10,26 +10,17 @@ from leisure_watch.facts import Focus, Now import pytest from steam_backlog_enforcer._leisure_kill import UNTRUSTED_SWEEP_AFTER, LeisureKiller -from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState +from steam_backlog_enforcer._playtime_state import 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 +from steam_backlog_enforcer.tests._leisure_kill_world import RULES, SPENT if TYPE_CHECKING: from collections.abc import Iterable, Iterator from pathlib import Path from unittest.mock import MagicMock -_RULES = PlaytimeRules( - budget_seconds=100.0, - warn_at=(), - sigkill_after=30.0, - count_launchers=False, - enforcement=True, - demo=False, - leisure=True, -) -_SPENT = PlaytimeState(day_key="2026-11-02", seconds=100.0) _FREE = PlaytimeState(day_key="2026-11-02", seconds=1.0) _TRUSTED = ShadowVerdict( paused_by=None, @@ -40,8 +31,9 @@ _STALE = ShadowVerdict(paused_by=None, keys=("k",), fact=None) _PAUSED = ShadowVerdict(paused_by="i3lock", keys=(), fact=None) -@pytest.fixture -def kill(tmp_path: Path) -> Iterator[MagicMock]: +@pytest.fixture(name="kill") +def fixture_kill(tmp_path: Path) -> Iterator[MagicMock]: + """Plant an untrusted mpv watcher and capture os.kill calls.""" add_proc(leisure_proc(tmp_path), FakeProc(10, exe="/usr/bin/mpv")) with patch.object(os, "kill") as mock: yield mock @@ -51,17 +43,19 @@ def _ticks( killer: LeisureKiller, stamps: Iterable[float], verdict: ShadowVerdict = _STALE, - state: PlaytimeState = _SPENT, + state: PlaytimeState = SPENT, ) -> None: for stamp in stamps: - killer.enforce(state, _RULES, verdict=verdict, stamp=float(stamp)) + killer.enforce(state, RULES, verdict=verdict, stamp=float(stamp)) def test_the_window_is_30_s() -> None: + """The untrusted sweep window is exactly 30 seconds.""" assert UNTRUSTED_SWEEP_AFTER == 30.0 def test_one_stale_moment_kills_nothing(kill: MagicMock) -> None: + """Untrust shorter than the window kills nothing; reaching it kills.""" killer = LeisureKiller(None) _ticks(killer, range(0, 28, 3)) kill.assert_not_called() @@ -70,6 +64,7 @@ def test_one_stale_moment_kills_nothing(kill: MagicMock) -> None: def test_a_trusted_tick_restarts_the_window(kill: MagicMock) -> None: + """A trusted tick resets the untrust window.""" killer = LeisureKiller(None) _ticks(killer, range(0, 25, 3)) _ticks(killer, [27], _TRUSTED) @@ -80,6 +75,7 @@ def test_a_trusted_tick_restarts_the_window(kill: MagicMock) -> None: def test_a_pause_restarts_the_window(kill: MagicMock) -> None: + """A pause verdict resets the untrust window.""" killer = LeisureKiller(None) _ticks(killer, range(0, 25, 3)) _ticks(killer, [27], _PAUSED) @@ -88,6 +84,7 @@ def test_a_pause_restarts_the_window(kill: MagicMock) -> None: def test_resume_from_sleep_restarts_the_window(kill: MagicMock) -> None: + """A gap from sleep resets the window instead of counting it.""" killer = LeisureKiller(None) _ticks(killer, range(0, 28, 3)) _ticks(killer, range(3600, 3628, 3)) @@ -97,6 +94,7 @@ def test_resume_from_sleep_restarts_the_window(kill: MagicMock) -> None: def test_untrust_counts_while_the_budget_lasts(kill: MagicMock) -> None: + """Untrust counts toward the sweep even before the budget is spent.""" killer = LeisureKiller(None) _ticks(killer, range(0, 31, 3), state=_FREE) _ticks(killer, [33]) @@ -104,6 +102,7 @@ def test_untrust_counts_while_the_budget_lasts(kill: MagicMock) -> None: def test_a_clock_step_back_restarts_the_window(kill: MagicMock) -> None: + """A clock stepping backwards resets the untrust window.""" killer = LeisureKiller(None) _ticks(killer, range(100, 128, 3)) _ticks(killer, range(0, 28, 3)) diff --git a/steam_backlog_enforcer/tests/test_leisure_kill_start.py b/steam_backlog_enforcer/tests/test_leisure_kill_start.py index 1f28780..a22459e 100644 --- a/steam_backlog_enforcer/tests/test_leisure_kill_start.py +++ b/steam_backlog_enforcer/tests/test_leisure_kill_start.py @@ -15,25 +15,14 @@ from leisure_watch.facts import Focus, Now, Reason import pytest from steam_backlog_enforcer._leisure_kill import LeisureKiller, fact_starts -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 +from steam_backlog_enforcer.tests._leisure_kill_world import RULES, SPENT if TYPE_CHECKING: from pathlib import Path -_RULES = PlaytimeRules( - budget_seconds=100.0, - warn_at=(), - sigkill_after=30.0, - count_launchers=False, - enforcement=True, - demo=False, - leisure=True, -) -_SPENT = PlaytimeState(day_key="2026-11-02", seconds=100.0) - @dataclass(frozen=True) class _R(Reason): @@ -46,15 +35,17 @@ def _killed(tmp_path: Path, *reasons: Reason) -> list[int]: fact = Now(0.0, 1, "leisure", None, reasons, Focus("mpv", "", None, idle=False)) verdict = ShadowVerdict(paused_by=None, keys=("k",), fact=fact) with patch.object(os, "kill") as kill: - LeisureKiller(None).enforce(_SPENT, _RULES, verdict=verdict, stamp=0.0) + LeisureKiller(None).enforce(SPENT, RULES, verdict=verdict, stamp=0.0) return [c.args[0] for c in kill.call_args_list] def test_a_matching_start_is_killed(tmp_path: Path) -> None: + """A pid whose start time matches the reason is killed.""" assert _killed(tmp_path, _R("media", "app:mpv", 10, "mpv", start=500)) == [10] def test_a_reused_pid_is_spared(tmp_path: Path) -> None: + """A pid whose start time differs (reused pid) is spared.""" assert _killed(tmp_path, _R("media", "app:mpv", 10, "mpv", start=499)) == [] @@ -69,10 +60,12 @@ def test_a_reused_pid_is_spared(tmp_path: Path) -> None: def test_no_usable_start_keeps_the_old_behaviour( tmp_path: Path, reason: Reason ) -> None: + """Without a usable start time the pid is killed as before.""" assert _killed(tmp_path, reason) == [10] def test_starts_come_from_the_reason_that_named_the_pid() -> None: + """A pid takes its start time from the reason that named it, not others.""" fact = Now( 0.0, 1, diff --git a/steam_backlog_enforcer/tests/test_leisure_kill_targets.py b/steam_backlog_enforcer/tests/test_leisure_kill_targets.py new file mode 100644 index 0000000..886a5f6 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_leisure_kill_targets.py @@ -0,0 +1,58 @@ +"""Phase 2 at 0: which pids a ``now.json`` fact names as kill targets.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from leisure_watch.facts import Reason +import pytest + +from steam_backlog_enforcer._leisure_kill import fact_targets +from steam_backlog_enforcer.tests._leisure_kill_world import build_proc, now_with + +if TYPE_CHECKING: + from pathlib import Path + + +@pytest.fixture(name="proc") +def fixture_proc(tmp_path: Path) -> Path: + """Isolated /proc with mpv, helium, chromium and a root-owned app.""" + return build_proc(tmp_path) + + +class TestFactTargets: + """Only media/focus apps and non-reporting browsers become targets.""" + + def test_media_and_focus_apps_die_browsers_and_games_do_not( + self, proc: Path + ) -> None: + """Media and focus pids are named; url-only, game and unknown are not.""" + del proc + fact = now_with( + Reason("media", "app:mpv", 10, "mpv"), + Reason("focus", "domain:youtube.com", 20, "YouTube"), + Reason("media", "domain:youtube.com", None, "url"), + Reason("game", "steam:440", 50, "tf2"), + Reason("mystery", "x", 10, "?"), + ) + assert fact_targets(fact) == {10: "app:mpv"} + + @pytest.mark.parametrize( + ("pid", "key"), + [ + (20, "failclosed:helium"), + (20, "failclosed:helium:unreported"), + (20, "failclosed:helium:audio"), + (30, "browser:chromium"), + ], + ) + def test_a_non_reporting_browser_dies(self, proc: Path, pid: int, key: str) -> None: + """A fail-closed or bare browser reason targets its pid.""" + del proc + fact = now_with(Reason("failclosed", key, pid, "x")) + assert fact_targets(fact) == {pid: key} + + def test_failclosed_without_a_pid_names_nothing(self) -> None: + """A fail-closed reason with no pid yields no target.""" + fact = now_with(Reason("failclosed", "failclosed:display", None, "X")) + assert fact_targets(fact) == {} diff --git a/steam_backlog_enforcer/tests/test_leisure_mode.py b/steam_backlog_enforcer/tests/test_leisure_mode.py index ebac3f9..a455e35 100644 --- a/steam_backlog_enforcer/tests/test_leisure_mode.py +++ b/steam_backlog_enforcer/tests/test_leisure_mode.py @@ -23,19 +23,13 @@ from steam_backlog_enforcer._shadow import ShadowVerdict, shadow_accumulate from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today from steam_backlog_enforcer.tests._isolate_leisure import switch_file +from steam_backlog_enforcer.tests._leisure_kill_world import BASE_RULES _LOCAL = timezone(timedelta(hours=2)) _NOW = datetime(2026, 11, 2, 20, 0, tzinfo=_LOCAL) _DAY = "2026-11-02" _REAL_SWITCH = Path("/etc/leisure-watch/leisure_from") -_RULES = PlaytimeRules( - budget_seconds=100.0, - warn_at=(), - sigkill_after=30.0, - count_launchers=False, - enforcement=True, - demo=False, -) +_RULES = BASE_RULES def _switch_on(tmp_path: Path, day: str) -> None: @@ -43,38 +37,49 @@ def _switch_on(tmp_path: Path, day: str) -> None: class TestHygiene: + """Tests never touch the real leisure switch file.""" + def test_no_test_reads_the_real_switch_file(self) -> None: + """Both switch-file readers point away from /etc.""" assert _leisure_mode.LEISURE_FROM_PATH != _REAL_SWITCH reader = importlib.import_module("earned_time._leisure") assert reader.LEISURE_FROM_PATH != _REAL_SWITCH def test_the_redirect_reaches_both_readers(self, tmp_path: Path) -> None: + """Writing the redirected switch adds leisure time in both readers.""" with pin_today(AFTER_CUT): dormant = resolve_budget(Config()).base_seconds _switch_on(tmp_path, AFTER_CUT.isoformat()) - live = resolve_budget(Config()).base_seconds + live_seconds = resolve_budget(Config()).base_seconds assert leisure_on(AFTER_CUT.isoformat()) - assert live - dormant == 2 * 3600.0 + assert live_seconds - dormant == 2 * 3600.0 class TestLeisureOn: + """leisure_on reads the switch day from earned_time.""" + def test_absent_switch_is_dormant(self) -> None: + """With no switch file leisure stays dormant.""" assert not leisure_on(_DAY) def test_the_switch_day_and_later_are_on(self, tmp_path: Path) -> None: + """The switch day and every later day are live.""" _switch_on(tmp_path, _DAY) assert leisure_on(_DAY) assert leisure_on("2026-11-03") def test_the_day_before_is_off(self, tmp_path: Path) -> None: + """The day before the switch day is not live.""" _switch_on(tmp_path, _DAY) assert not leisure_on("2026-11-01") def test_a_garbage_day_key_is_dormant(self, tmp_path: Path) -> None: + """An unparsable day key is treated as dormant.""" _switch_on(tmp_path, _DAY) assert not leisure_on("not-a-day") def test_the_gate_asks_earned_time(self) -> None: + """The gate delegates the decision to earned_time.leisure_active.""" with patch.object(earned_time, "leisure_active", return_value=True) as ask: assert leisure_on(_DAY) ask.assert_called_once_with(date(2026, 11, 2), _leisure_mode.LEISURE_FROM_PATH) @@ -95,34 +100,42 @@ def _bill( return state, rules.leisure -@pytest.fixture -def live(tmp_path: Path) -> None: +@pytest.fixture(name="live") +def fixture_live(tmp_path: Path) -> None: + """Turn leisure on from the test day onwards.""" _switch_on(tmp_path, _DAY) @pytest.mark.usefixtures("live") class TestLeisureBilled: + """Billing while leisure mode is live.""" + def test_leisure_bills_enforced_seconds_unattributed(self) -> None: + """Enforced leisure seconds are billed without a per-game credit.""" state, leisure = _bill(ShadowVerdict(paused_by=None, keys=("app:mpv",))) assert (state.seconds, state.per_game, leisure) == (3.0, {}, True) assert state.last_tick_at == _NOW.timestamp() def test_a_game_tick_credits_the_game(self) -> None: + """A tick owned by a game credits that game.""" verdict = ShadowVerdict(paused_by=None, keys=("app:440",)) state, _ = _bill(verdict, {7: "app:440"}) assert state.per_game == {"app:440": 3.0} def test_paused_bills_nothing_even_with_a_game(self) -> None: + """A paused tick bills nothing even when a game is running.""" verdict = ShadowVerdict(paused_by="i3lock", keys=()) state, leisure = _bill(verdict, {7: "app:440"}) assert (state.seconds, state.per_game, leisure) == (0.0, {}, True) assert state.last_tick_at == _NOW.timestamp() def test_free_bills_nothing(self) -> None: + """A free tick bills nothing.""" state, _ = _bill(ShadowVerdict(paused_by=None, keys=())) assert state.seconds == 0.0 def test_enforced_and_shadow_move_together(self) -> None: + """Enforced billing moves in lockstep with shadow accounting.""" verdicts = ( ShadowVerdict(paused_by=None, keys=("domain:youtube.com",)), ShadowVerdict(paused_by="i3lock", keys=()), @@ -143,15 +156,19 @@ class TestLeisureBilled: class TestDormantBilling: + """Billing while leisure mode is dormant.""" + def test_games_only_and_rules_untouched(self) -> None: + """Dormant billing counts only games and leaves rules untouched.""" verdict = ShadowVerdict(paused_by=None, keys=("app:mpv",)) state, leisure = _bill(verdict) assert (state.seconds, leisure) == (0.0, False) state, _ = _bill(ShadowVerdict(paused_by="i3lock", keys=()), {7: "app:440"}) assert state.per_game == {"app:440": 3.0} - def test_a_demo_is_never_live(self, live: None) -> None: - del live + @pytest.mark.usefixtures("live") + def test_a_demo_is_never_live(self) -> None: + """A demo run never enters live leisure billing.""" demo = PlaytimeRules( 1.0, (), 1.0, count_launchers=False, enforcement=True, demo=True ) diff --git a/steam_backlog_enforcer/tests/test_leisure_never_kill.py b/steam_backlog_enforcer/tests/test_leisure_never_kill.py index 797a4e1..e2fd83d 100644 --- a/steam_backlog_enforcer/tests/test_leisure_never_kill.py +++ b/steam_backlog_enforcer/tests/test_leisure_never_kill.py @@ -12,33 +12,26 @@ import pytest from steam_backlog_enforcer._leisure_kill import LeisureKiller from steam_backlog_enforcer._leisure_never_kill import never_kill -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 +from steam_backlog_enforcer.tests._leisure_kill_world import RULES, SPENT if TYPE_CHECKING: from pathlib import Path _PY = "/usr/bin/python3.14" -_RULES = PlaytimeRules( - budget_seconds=100.0, - warn_at=(), - sigkill_after=30.0, - count_launchers=False, - enforcement=True, - demo=False, - leisure=True, -) -_SPENT = PlaytimeState(day_key="2026-11-02", seconds=100.0) -@pytest.fixture -def proc(tmp_path: Path) -> Path: +@pytest.fixture(name="proc") +def fixture_proc(tmp_path: Path) -> Path: + """A fake ``/proc`` tree that the never-kill lookups read.""" return leisure_proc(tmp_path) class TestNeverKill: + """Which processes the never-kill set protects, and why.""" + @pytest.mark.parametrize( ("exe", "args", "why"), [ @@ -81,6 +74,7 @@ class TestNeverKill: def test_by_binary( self, proc: Path, exe: str, args: tuple[str, ...], why: str | None ) -> None: + """Desktop, shell and terminal binaries are protected; others are not.""" add_proc(proc, FakeProc(10, exe=exe, argv=args)) assert never_kill(10) == why @@ -102,6 +96,7 @@ class TestNeverKill: def test_by_python_script( self, proc: Path, args: tuple[str, ...], why: str | None ) -> None: + """A python process is protected by its script, not the interpreter.""" add_proc(proc, FakeProc(10, exe=_PY, argv=args)) assert never_kill(10) == why @@ -122,14 +117,18 @@ class TestNeverKill: def test_claude( self, proc: Path, exe: str, args: tuple[str, ...], why: str | None ) -> None: + """Claude Code is protected whether run natively or under node.""" add_proc(proc, FakeProc(10, exe=exe, argv=args)) assert never_kill(10) == why class TestKillerRefuses: + """The leisure killer skips protected pids even when a fact names them.""" + def test_a_forged_fact_cannot_kill_the_desktop( self, proc: Path, caplog: pytest.LogCaptureFixture ) -> None: + """Only the unprotected pid dies; each refusal is logged and warned once.""" add_proc(proc, FakeProc(10, exe="/usr/bin/i3", argv=("i3",))) add_proc(proc, FakeProc(11, exe="/usr/bin/alacritty", argv=("alacritty",))) add_proc(proc, FakeProc(12, exe="/usr/bin/mpv")) @@ -149,13 +148,16 @@ class TestKillerRefuses: records: list[tuple[str, dict[str, object]]] = [] class Log: + """Event log that keeps every record in ``records``.""" + def record(self, event: str, **fields: object) -> None: + """Keep one event and its fields.""" records.append((event, fields)) killer = LeisureKiller(Log()) with patch.object(os, "kill") as kill, caplog.at_level(logging.WARNING): for stamp in (0.0, 3.0, 40.0): - killer.enforce(_SPENT, _RULES, verdict=verdict, stamp=stamp) + killer.enforce(SPENT, RULES, verdict=verdict, stamp=stamp) assert {c.args[0] for c in kill.call_args_list} == {12} refused = [f for e, f in records if e == "leisure_kill_refused"] assert sorted((f["pid"], f["name"]) for f in refused) == [ @@ -167,11 +169,12 @@ class TestKillerRefuses: def test_a_refusal_without_a_log_only_warns( self, proc: Path, caplog: pytest.LogCaptureFixture ) -> None: + """With no event log, a refusal still kills nothing and only warns.""" add_proc(proc, FakeProc(10, exe="/usr/bin/i3", argv=("i3",))) reasons = (Reason("failclosed", "failclosed:x", 10, "x"),) fact = Now(0.0, 1, "leisure", None, reasons, Focus("i3", "", None, idle=False)) verdict = ShadowVerdict(paused_by=None, keys=("k",), fact=fact) with patch.object(os, "kill") as kill, caplog.at_level(logging.WARNING): - LeisureKiller(None).enforce(_SPENT, _RULES, verdict=verdict, stamp=0.0) + LeisureKiller(None).enforce(SPENT, RULES, verdict=verdict, stamp=0.0) kill.assert_not_called() assert "never killed" in caplog.text diff --git a/steam_backlog_enforcer/tests/test_leisure_procs.py b/steam_backlog_enforcer/tests/test_leisure_procs.py index 24920f2..9ff7a3d 100644 --- a/steam_backlog_enforcer/tests/test_leisure_procs.py +++ b/steam_backlog_enforcer/tests/test_leisure_procs.py @@ -15,12 +15,15 @@ if TYPE_CHECKING: from pathlib import Path -@pytest.fixture -def proc(tmp_path: Path) -> Path: +@pytest.fixture(name="proc") +def fixture_proc(tmp_path: Path) -> Path: + """Give each test an empty fake /proc tree.""" return leisure_proc(tmp_path) class TestNames: + """Process names come from exe, falling back to comm.""" + @pytest.mark.parametrize( "exe", [ @@ -38,6 +41,7 @@ class TestNames: ], ) def test_browser_families_by_exe(self, proc: Path, exe: str) -> None: + """Every supported browser family is recognised by its exe.""" add_proc(proc, FakeProc(10, exe=exe, comm="Isolated Web Co")) assert procs.is_browser(10) @@ -45,16 +49,19 @@ class TestNames: "exe", ["/usr/bin/mpv", "/usr/bin/operator", "/usr/bin/frozen", "/x/knowledge"] ) def test_non_browsers(self, proc: Path, exe: str) -> None: + """Names that merely contain a browser word are not browsers.""" add_proc(proc, FakeProc(10, exe=exe)) assert not procs.is_browser(10) def test_unreadable_exe_falls_back_to_comm(self, proc: Path) -> None: + """An unreadable exe link falls back to the comm name.""" add_proc(proc, FakeProc(10, comm="helium")) (proc / "10" / "exe").unlink() assert procs.exe_name(10) == "helium" assert procs.is_browser(10) def test_a_vanished_pid_has_no_name(self) -> None: + """A pid missing from /proc yields empty or None for every field.""" assert procs.exe_name(99) == "" assert procs.argv(99) == () assert procs.real_uid(99) is None @@ -62,6 +69,7 @@ class TestNames: assert procs.parent(99) is None def test_reads_uid_start_and_parent(self, proc: Path) -> None: + """Uid, start time and parent pid are parsed from status and stat.""" add_proc(proc, FakeProc(10, uid=1000, ppid=7, start=4242)) assert (procs.real_uid(10), procs.start_time(10), procs.parent(10)) == ( 1000, @@ -70,6 +78,7 @@ class TestNames: ) def test_malformed_status_and_stat(self, proc: Path) -> None: + """Malformed status and stat fields read as None.""" add_proc(proc, FakeProc(10)) (proc / "10" / "status").write_text("Name:\tmpv\nUid:\tx\n", encoding="utf-8") (proc / "10" / "stat").write_text("10 (mpv) S", encoding="utf-8") @@ -79,25 +88,32 @@ class TestNames: assert procs.parent(10) is None def test_status_without_a_uid_line(self, proc: Path) -> None: + """A status file with no Uid line gives no uid.""" add_proc(proc, FakeProc(10)) (proc / "10" / "status").write_text("Name:\tmpv\n", encoding="utf-8") assert procs.real_uid(10) is None class TestProtected: + """Lockers, other users and our own chain are never signalled.""" + def test_a_plain_user_player_is_fair_game(self, proc: Path) -> None: + """A desktop user's player outside any locker is killable.""" add_proc(proc, FakeProc(10)) assert not procs.is_protected(10, set()) @pytest.mark.parametrize("uid", [0, 999, 1001]) def test_anything_not_the_desktop_users(self, proc: Path, uid: int) -> None: + """Processes owned by any other uid are protected.""" add_proc(proc, FakeProc(10, uid=uid)) assert procs.is_protected(10, set()) def test_a_vanished_pid_is_never_signalled(self) -> None: + """A pid that no longer exists counts as protected.""" assert procs.is_protected(99, set()) def test_own_chain(self, proc: Path) -> None: + """A pid in our own process chain is protected.""" add_proc(proc, FakeProc(10)) assert procs.is_protected(10, {10}) @@ -113,17 +129,20 @@ class TestProtected: def test_lockers_and_their_children( self, proc: Path, argv: tuple[str, ...] ) -> None: + """Lockers and every child they spawn are protected.""" add_proc(proc, FakeProc(5, exe="/usr/bin/python3", argv=argv, comm="x")) add_proc(proc, FakeProc(10, ppid=5)) assert procs.is_protected(5, set()) assert procs.is_protected(10, set()) def test_a_parent_loop_terminates(self, proc: Path) -> None: + """A cyclic parent chain ends the walk instead of looping.""" add_proc(proc, FakeProc(10, ppid=11)) add_proc(proc, FakeProc(11, ppid=10)) assert not procs.is_protected(10, set()) def test_own_chain_walks_up(self, proc: Path) -> None: + """own_chain() collects every ancestor up to init.""" me = os.getpid() add_proc(proc, FakeProc(me, ppid=77)) add_proc(proc, FakeProc(77, ppid=1)) @@ -131,9 +150,12 @@ class TestProtected: class TestSweep: + """An untrusted watcher sweeps browsers, players and reviewers.""" + def test_untrusted_watcher_sweeps_browsers_players_and_the_reviewer( self, proc: Path ) -> None: + """The sweep tags browsers, players and wsg-grabber, but not its subcommands.""" add_proc(proc, FakeProc(10, exe="/opt/helium-browser-bin/helium")) add_proc(proc, FakeProc(11, exe="/usr/bin/mpv")) add_proc(proc, FakeProc(12, exe="/opt/FreeTube/freetube")) @@ -163,10 +185,12 @@ class TestSweep: } def test_an_unlistable_proc_sweeps_nothing(self, proc: Path) -> None: + """A missing /proc yields an empty sweep.""" proc.rmdir() assert procs.sweep_targets() == {} def test_a_deep_tree_walk_is_bounded(self, proc: Path) -> None: + """The ancestor walk stops after a fixed depth.""" for pid in range(100, 150): add_proc(proc, FakeProc(pid, ppid=pid + 1)) add_proc(proc, FakeProc(150, argv=("i3lock",))) diff --git a/steam_backlog_enforcer/tests/test_leisure_procs_identity.py b/steam_backlog_enforcer/tests/test_leisure_procs_identity.py index b211ec6..2b60632 100644 --- a/steam_backlog_enforcer/tests/test_leisure_procs_identity.py +++ b/steam_backlog_enforcer/tests/test_leisure_procs_identity.py @@ -17,12 +17,15 @@ _PY = "/usr/bin/python3.14" _WSG = "/home/kuhy/.local/bin/wsg-grabber" -@pytest.fixture -def proc(tmp_path: Path) -> Path: +@pytest.fixture(name="proc") +def fixture_proc(tmp_path: Path) -> Path: + """Give each test an empty fake /proc tree.""" return leisure_proc(tmp_path) class TestWsgIsTheProgram: + """Only a process running wsg-grabber itself counts as the reviewer.""" + @pytest.mark.parametrize( ("exe", "args"), [ @@ -39,6 +42,7 @@ class TestWsgIsTheProgram: ], ) def test_not_a_reviewer(self, proc: Path, exe: str, args: tuple[str, ...]) -> None: + """Tools that merely mention wsg-grabber, or run other code, are left alone.""" add_proc(proc, FakeProc(10, exe=exe, argv=args)) assert procs.sweep_targets() == {} @@ -53,22 +57,28 @@ class TestWsgIsTheProgram: ], ) def test_a_reviewer(self, proc: Path, exe: str, args: tuple[str, ...]) -> None: + """Interpreter or binary runs of wsg-grabber are swept.""" add_proc(proc, FakeProc(10, exe=exe, argv=args)) assert procs.sweep_targets() == {10: "sweep:wsg-grabber"} def test_script_of_a_non_interpreter_is_none(self, proc: Path) -> None: + """A non-interpreter has no script, even with a .py argument.""" add_proc(proc, FakeProc(10, exe="/usr/bin/git", argv=("git", "x.py"))) assert procs.script_of(10) is None class TestDeletedExe: + """A binary replaced on disk still matches by its original name.""" + def test_an_updated_browser_is_still_a_browser(self, proc: Path) -> None: + """A browser whose exe reads '(deleted)' is still a swept browser.""" add_proc(proc, FakeProc(10, exe="/opt/helium-browser-bin/helium (deleted)")) assert procs.exe_name(10) == "helium" assert procs.is_browser(10) assert procs.sweep_targets() == {10: "sweep:browser:helium"} def test_an_updated_player_is_still_swept(self, proc: Path) -> None: + """A player whose exe reads '(deleted)' is swept under its real path.""" add_proc(proc, FakeProc(10, exe="/usr/bin/mpv (deleted)")) assert procs.sweep_targets() == {10: "sweep:media:mpv"} assert procs.exe_path(10) == "/usr/bin/mpv" diff --git a/steam_backlog_enforcer/tests/test_leisure_tick.py b/steam_backlog_enforcer/tests/test_leisure_tick.py index 06cac43..136d8ff 100644 --- a/steam_backlog_enforcer/tests/test_leisure_tick.py +++ b/steam_backlog_enforcer/tests/test_leisure_tick.py @@ -40,7 +40,10 @@ _CUTOFF = "steam_backlog_enforcer._playtime_cutoff" class SpyLeisure: + """Leisure killer double that records each enforce call.""" + def __init__(self) -> None: + """Start with no calls.""" self.calls: list[tuple[PlaytimeState, PlaytimeRules]] = [] def enforce( @@ -51,12 +54,14 @@ class SpyLeisure: verdict: ShadowVerdict, stamp: float, ) -> None: + """Record the state and rules the tick passed.""" del verdict, stamp self.calls.append((state, rules)) -@pytest.fixture -def notify() -> Iterator[MagicMock]: +@pytest.fixture(name="notify") +def fixture_notify() -> Iterator[MagicMock]: + """Stub every cutoff side effect and yield the desktop-notify mock.""" with ExitStack() as stack: stack.enter_context(patch(f"{_CUTOFF}.reconcile")) stack.enter_context(patch(f"{_CUTOFF}.request_steam_shutdown")) @@ -93,7 +98,10 @@ def _go_live(tmp_path: Path) -> None: @pytest.mark.usefixtures("notify") class TestDormant: + """Phase 2 switched off: billing stays games-only.""" + def test_no_new_code_path_runs_without_the_switch(self) -> None: + """Leisure billing and the killer never run while dormant.""" with patch.object( _leisure_mode, "leisure_billed", side_effect=AssertionError("ran") ): @@ -103,10 +111,12 @@ class TestDormant: assert (state.seconds, state.shadow_seconds) == (0.0, 3.0) def test_games_still_bill_games_only(self) -> None: + """A running game is billed per game as before.""" state, spy = _tick({7: "app:440"}) assert (state.seconds, state.per_game, spy.calls) == (3.0, {"app:440": 3.0}, []) def test_a_demo_ignores_the_switch(self, tmp_path: Path) -> None: + """A demo tick stays dormant even with the switch set.""" _go_live(tmp_path) state, spy = _tick({}, demo=True) assert (state.seconds, spy.calls) == (0.0, []) @@ -114,15 +124,20 @@ class TestDormant: @pytest.mark.usefixtures("notify") class TestLive: + """Phase 2 switched on: leisure is billed and enforced.""" + def test_leisure_bills_the_enforced_count(self, tmp_path: Path) -> None: + """The fail-closed shadow count becomes the billed count.""" _go_live(tmp_path) state, spy = _tick({}) assert state.seconds == state.shadow_seconds == 3.0 assert state.shadow_per_source == {_shadow.FAILCLOSED_KEY: 3.0} - [(_seen, rules)] = spy.calls + assert len(spy.calls) == 1 + _seen, rules = spy.calls[0] assert rules.leisure def test_a_lock_pauses_the_enforced_count_too(self, tmp_path: Path) -> None: + """A screen lock pauses billing in live mode.""" _go_live(tmp_path) with patch.object(_shadow, "paused_by", return_value="i3lock"): state, _ = _tick({7: "app:440"}) @@ -131,6 +146,7 @@ class TestLive: def test_at_zero_the_cutoff_says_leisure_and_kills_run( self, tmp_path: Path, notify: MagicMock ) -> None: + """At zero the cutoff notice says leisure and the killer runs.""" _go_live(tmp_path) state, spy = _tick({}, budget=3.0) assert state.is_blocked() @@ -152,11 +168,13 @@ class TestToastGlyphs: """Textgate: toasts paint glyphs and numbers, no words.""" def test_warnings_show_gaming_glyph_while_dormant(self) -> None: + """Dormant warnings show the gaming glyph.""" with patch(f"{_CUTOFF}.notify_desktop_user") as notify: _warn(PlaytimeState(day_key=_TODAY, seconds=10.0), _RULES) notify.assert_called_once_with("⏳🎮", "1h") def test_warnings_show_leisure_glyph_when_live(self) -> None: + """Live warnings show the leisure glyph.""" with patch(f"{_CUTOFF}.notify_desktop_user") as notify: _warn( PlaytimeState(day_key=_TODAY, seconds=10.0), @@ -166,11 +184,13 @@ class TestToastGlyphs: @pytest.mark.usefixtures("notify") def test_the_dormant_cutoff_shows_unblock_time(self, notify: MagicMock) -> None: + """The dormant cutoff notice shows the unblock time.""" _begin_cutoff(PlaytimeState(day_key=_TODAY), _RULES, now=_NOW) notify.assert_called_once_with("⛔🎮", "🔓 06:00") def test_new_session_kills_through_the_budget_log() -> None: + """A new session wires a LeisureKiller with a budget log.""" session = new_session() assert isinstance(session.leisure, LeisureKiller) assert session.leisure.log is not None diff --git a/steam_backlog_enforcer/tests/test_library_hider.py b/steam_backlog_enforcer/tests/test_library_hider.py index 9ab8371..08a34e6 100644 --- a/steam_backlog_enforcer/tests/test_library_hider.py +++ b/steam_backlog_enforcer/tests/test_library_hider.py @@ -20,6 +20,7 @@ class TestGetSharedJsWsUrl: """Tests for _get_shared_js_ws_url.""" def test_finds_url(self) -> None: + """The SharedJSContext target's websocket URL is returned.""" targets = [ { "title": "SharedJSContext", @@ -37,6 +38,7 @@ class TestGetSharedJsWsUrl: assert result == "ws://127.0.0.1:8080/x" def test_no_shared_context(self) -> None: + """No SharedJSContext target yields None.""" targets = [{"title": "Other", "webSocketDebuggerUrl": "ws://other"}] mock_resp = MagicMock() mock_resp.json.return_value = targets @@ -47,6 +49,7 @@ class TestGetSharedJsWsUrl: assert _get_shared_js_ws_url() is None def test_connection_error(self) -> None: + """A failed target request yields None.""" with patch( "steam_backlog_enforcer._cdp.requests.get", side_effect=OSError, @@ -58,6 +61,7 @@ class TestEvaluateJsAsync: """Tests for _evaluate_js_async.""" def test_success(self) -> None: + """The websocket reply is decoded and returned.""" mock_ws = AsyncMock() mock_ws.send = AsyncMock() mock_ws.recv = AsyncMock( @@ -78,6 +82,7 @@ class TestEvaluateJs: """Tests for _evaluate_js.""" def test_success(self) -> None: + """The sync wrapper returns the async evaluation result.""" with ( patch( "steam_backlog_enforcer._cdp._get_shared_js_ws_url", @@ -92,6 +97,7 @@ class TestEvaluateJs: assert result["result"]["result"]["value"] == "ok" def test_no_ws_url(self) -> None: + """A missing SharedJSContext raises RuntimeError.""" with ( patch( "steam_backlog_enforcer._cdp._get_shared_js_ws_url", @@ -106,10 +112,12 @@ class TestCdpResultValue: """Tests for _cdp_result_value.""" def test_extracts_value(self) -> None: + """The nested result value is extracted.""" result = {"result": {"result": {"value": "hello"}}} assert _cdp_result_value(result) == "hello" def test_exception(self) -> None: + """An exceptionDetails reply raises RuntimeError.""" result = { "result": { "result": {"description": "Error!"}, @@ -120,4 +128,5 @@ class TestCdpResultValue: _cdp_result_value(result) def test_empty(self) -> None: + """An empty reply yields an empty string.""" assert _cdp_result_value({}) == "" diff --git a/steam_backlog_enforcer/tests/test_library_hider_ops.py b/steam_backlog_enforcer/tests/test_library_hider_ops.py index 24dbc27..a6c07f9 100644 --- a/steam_backlog_enforcer/tests/test_library_hider_ops.py +++ b/steam_backlog_enforcer/tests/test_library_hider_ops.py @@ -27,6 +27,7 @@ class TestRestartSteam: """Tests for restart_steam.""" def test_cdp_ready(self) -> None: + """restart_steam completes when the debug port comes up.""" with ( patch("steam_backlog_enforcer._steam_launch._shutdown_steam"), patch("steam_backlog_enforcer._steam_launch._launch_steam_with_debug"), @@ -38,6 +39,7 @@ class TestRestartSteam: restart_steam() def test_cdp_not_ready(self) -> None: + """restart_steam completes even when the debug port never comes up.""" with ( patch("steam_backlog_enforcer._steam_launch._shutdown_steam"), patch("steam_backlog_enforcer._steam_launch._launch_steam_with_debug"), @@ -53,6 +55,7 @@ class TestHideOtherGames: """Tests for hide_other_games.""" def test_hides(self) -> None: + """hide_other_games returns the hidden count from the page.""" with ( patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), patch( @@ -70,6 +73,7 @@ class TestHideOtherGames: assert count == 5 def test_empty_list(self) -> None: + """Nothing to hide returns zero.""" with ( patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), patch( @@ -87,6 +91,7 @@ class TestHideOtherGames: assert count == 0 def test_no_allowed(self) -> None: + """An empty allowed set still hides and returns the count.""" with ( patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), patch( @@ -113,10 +118,12 @@ class TestTryHideOtherGames: """ def test_success_returns_count_and_no_reason(self) -> None: + """Success returns the count and no reason.""" with patch(f"{PKG}.hide_other_games", return_value=7): assert try_hide_other_games([1, 2], {1}) == (7, None) def test_steam_unavailable_is_reported_not_raised(self) -> None: + """An unavailable Steam becomes a reason, not an exception.""" with patch( f"{PKG}.hide_other_games", side_effect=SteamUnavailableError("Steam is not installed"), @@ -126,6 +133,7 @@ class TestTryHideOtherGames: assert reason == "Steam is not installed" def test_update_in_progress_is_reported_not_raised(self) -> None: + """A running update becomes a reason, not an exception.""" with patch( f"{PKG}.hide_other_games", side_effect=SteamUpdateInProgressError("update in progress"), @@ -139,6 +147,7 @@ class TestUnhideAllGames: """Tests for unhide_all_games.""" def test_unhides(self) -> None: + """unhide_all_games returns the unhidden count.""" with ( patch("steam_backlog_enforcer.library_hider.ensure_steam_debug_port"), patch( diff --git a/steam_backlog_enforcer/tests/test_library_hider_part2.py b/steam_backlog_enforcer/tests/test_library_hider_part2.py index a936307..f36bacc 100644 --- a/steam_backlog_enforcer/tests/test_library_hider_part2.py +++ b/steam_backlog_enforcer/tests/test_library_hider_part2.py @@ -58,6 +58,7 @@ class TestResolveDesktopUser: assert _resolve_desktop_user() == "kuhy" def test_returns_none_when_nothing_set(self) -> None: + """No user variable at all resolves to None.""" env = os.environ.copy() env.pop("STEAM_ENFORCER_DESKTOP_USER", None) env.pop("SUDO_USER", None) @@ -70,11 +71,13 @@ class TestSteamIsInstalled: """Tests for steam_is_installed.""" def test_true_when_binary_exists(self) -> None: + """An existing /usr/bin/steam reads as installed.""" with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path: mock_path.return_value.exists.return_value = True assert steam_is_installed() is True def test_false_when_binary_missing(self) -> None: + """A missing /usr/bin/steam reads as not installed.""" with patch("steam_backlog_enforcer._steam_launch.Path") as mock_path: mock_path.return_value.exists.return_value = False assert steam_is_installed() is False @@ -138,6 +141,7 @@ class TestRunAsUser: _SPAWNED.clear() def test_non_root_runs_directly(self) -> None: + """A non-root caller runs the command unchanged.""" with ( patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=1000), patch(f"{STEAM_PROCESS_PKG}.subprocess.Popen") as mock_popen, @@ -148,6 +152,7 @@ class TestRunAsUser: assert cmd == ["steam", "-shutdown"] def test_root_drops_to_user(self) -> None: + """Root launches through sudo -u as the desktop user.""" mock_pw = MagicMock() mock_pw.pw_uid = 1001 with ( @@ -167,6 +172,7 @@ class TestRunAsUser: assert "alice" in cmd def test_root_user_key_error(self) -> None: + """An unknown user still drops privileges via sudo with the default uid.""" with ( patch(f"{STEAM_PROCESS_PKG}.os.geteuid", return_value=0), patch(f"{ENV_PKG}.pwd.getpwnam", side_effect=KeyError("no user")), diff --git a/steam_backlog_enforcer/tests/test_main_abandon.py b/steam_backlog_enforcer/tests/test_main_abandon.py index c9a5061..53b7b45 100644 --- a/steam_backlog_enforcer/tests/test_main_abandon.py +++ b/steam_backlog_enforcer/tests/test_main_abandon.py @@ -26,23 +26,29 @@ PKG = "steam_backlog_enforcer.main.abandon" class TestCmdAbandonPick: + """cmd_abandon_pick validates its argument and needs the typed phrase.""" + def test_no_args_exits(self) -> None: + """No argument prints usage and exits 1.""" with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit) as exc: cmd_abandon_pick(Config(), abandonable_state(), []) assert exc.value.code == 1 assert "Usage" in " ".join(str(c) for c in mock_echo.call_args_list) def test_non_numeric_app_id_exits(self) -> None: + """A non-numeric app id exits with a message.""" with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit): cmd_abandon_pick(Config(), abandonable_state(), ["abc"]) assert "must be a number" in " ".join(str(c) for c in mock_echo.call_args_list) def test_no_active_pick_exits(self) -> None: + """Without a manual pick the command exits.""" with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit): cmd_abandon_pick(Config(), State(), ["100"]) assert "No manual pick" in " ".join(str(c) for c in mock_echo.call_args_list) def test_wrong_app_id_exits(self) -> None: + """An app id that is not a manual pick exits.""" with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit): cmd_abandon_pick(Config(), abandonable_state(app_id=100), ["999"]) assert "not one of your manual picks" in " ".join( @@ -86,6 +92,7 @@ class TestCmdAbandonPick: assert [p["app_id"] for p in state.manual_picks] == [100] def test_aborted_when_not_yes(self) -> None: + """Any answer but the phrase aborts.""" state = abandonable_state() with ( patch(f"{PKG}._echo"), @@ -99,6 +106,7 @@ class TestCmdAbandonPick: assert [p["app_id"] for p in state.manual_picks] == [100] def test_success_clears_lock_and_uninstalls(self) -> None: + """The phrase clears the pick and uninstalls the game.""" state = abandonable_state() with ( patch(f"{PKG}._echo") as mock_echo, @@ -115,6 +123,7 @@ class TestCmdAbandonPick: assert "abandoned" in " ".join(str(c) for c in mock_echo.call_args_list) def test_skips_uninstall_when_not_installed(self) -> None: + """A game not installed is not uninstalled.""" state = abandonable_state() with ( patch(f"{PKG}._echo"), @@ -127,6 +136,7 @@ class TestCmdAbandonPick: mock_uninstall.assert_not_called() def test_warns_when_uninstall_fails(self) -> None: + """A failed uninstall is reported.""" state = abandonable_state() with ( patch(f"{PKG}._echo") as mock_echo, @@ -140,11 +150,15 @@ class TestCmdAbandonPick: class TestAbandonPickLockInteraction: + """abandon-pick stays reachable while the pick lock holds.""" + def test_abandon_pick_is_exempt_from_the_lock(self) -> None: + """The lock check lets abandon-pick through.""" # The escape hatch must survive the pre-dispatch lock check. _enforce_manual_pick_lock("abandon-pick", locked_state()) def test_main_dispatches_abandon_pick_while_locked(self) -> None: + """main runs abandon-pick under the lock.""" argv = ["prog", "abandon-pick", "100"] with ( patch.object(sys, "argv", argv), @@ -162,6 +176,7 @@ class TestAbandonPickLockInteraction: mock_cmd.assert_called_once() def test_lock_message_advertises_abandon(self) -> None: + """The lock message points at abandon-pick.""" state = abandonable_state(started_at=RECENT_PICK) with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo: _show_manual_pick_lock_message(state) @@ -170,6 +185,7 @@ class TestAbandonPickLockInteraction: assert "day(s) ago" in output def test_lock_message_advertises_abandon_for_old_picks_too(self) -> None: + """The lock message points at abandon-pick for old picks too.""" state = abandonable_state(started_at=OLD_PICK) with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo: _show_manual_pick_lock_message(state) @@ -183,7 +199,10 @@ class TestAbandonPickLockInteraction: class TestAbandonOneOfTwoPicks: + """Abandoning one of two picks leaves the other.""" + def test_other_pick_survives(self) -> None: + """Abandoning the second pick keeps the first.""" state = two_pick_state() with ( patch(f"{PKG}._echo") as mock_echo, @@ -199,6 +218,7 @@ class TestAbandonOneOfTwoPicks: assert state.current_app_id == 100 def test_wrong_id_lists_all_active_picks(self) -> None: + """A wrong id lists every active pick.""" state = two_pick_state() with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit): cmd_abandon_pick(Config(), state, ["999"]) @@ -207,6 +227,7 @@ class TestAbandonOneOfTwoPicks: assert "SecondGame (AppID=200)" in output def test_lock_message_lists_both_picks(self) -> None: + """The lock message lists both picks.""" with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo: _show_manual_pick_lock_message(two_pick_state()) output = " ".join(str(c) for c in mock_echo.call_args_list) diff --git a/steam_backlog_enforcer/tests/test_main_dispatch.py b/steam_backlog_enforcer/tests/test_main_dispatch.py index eee68f3..e684629 100644 --- a/steam_backlog_enforcer/tests/test_main_dispatch.py +++ b/steam_backlog_enforcer/tests/test_main_dispatch.py @@ -20,6 +20,7 @@ class TestMain: """Tests for main CLI entry point.""" def test_no_args_exits(self) -> None: + """No command prints usage and exits 1.""" with ( patch.object(sys, "argv", ["prog"]), patch(f"{PKG}._echo"), @@ -28,6 +29,7 @@ class TestMain: main() def test_unknown_command_exits(self) -> None: + """An unknown command exits 1.""" with ( patch.object(sys, "argv", ["prog", "bogus"]), patch(f"{PKG}._echo"), @@ -36,6 +38,7 @@ class TestMain: main() def test_valid_command_runs(self) -> None: + """A known command runs with the loaded config and state.""" mock_cmd = MagicMock() with ( patch.object(sys, "argv", ["prog", "status"]), @@ -47,6 +50,7 @@ class TestMain: mock_cmd.assert_called_once() def test_setup_no_key_required(self) -> None: + """setup runs without an API key.""" mock_cmd = MagicMock() with ( patch.object(sys, "argv", ["prog", "setup"]), @@ -58,6 +62,7 @@ class TestMain: mock_cmd.assert_called_once() def test_no_api_key_exits(self) -> None: + """Other commands exit when the API key is missing.""" with ( patch.object(sys, "argv", ["prog", "status"]), patch(f"{PKG}.Config.load", return_value=Config()), @@ -86,6 +91,7 @@ class TestMain: @pytest.mark.parametrize("raw", ["bogus", "--bogus"]) def test_unknown_command_names_the_input(self, raw: str) -> None: + """The unknown-command error names what was typed.""" with ( patch.object(sys, "argv", ["prog", raw]), patch("steam_backlog_enforcer.main._registry._echo") as mock_echo, @@ -115,21 +121,25 @@ class TestResolveCommand: ], ) def test_resolve(self, raw: str, expected: str | None) -> None: + """_resolve_command maps raw input to a command name or None.""" assert _resolve_command(raw) == expected def test_usage_suggests_close_match(self) -> None: + """Usage suggests a close command name.""" with patch("steam_backlog_enforcer.main._registry._echo") as mock_echo: _print_usage("statu") lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args] assert "Did you mean 'status'?" in lines def test_usage_omits_suggestion_when_far_off(self) -> None: + """Usage suggests nothing for a far-off name.""" with patch("steam_backlog_enforcer.main._registry._echo") as mock_echo: _print_usage("--zzzzzz") lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args] assert not any(line.startswith("Did you mean") for line in lines) def test_usage_without_unknown_has_no_error_line(self) -> None: + """Plain usage has no unknown-command line.""" with patch("steam_backlog_enforcer.main._registry._echo") as mock_echo: _print_usage() lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args] diff --git a/steam_backlog_enforcer/tests/test_main_dispatch_extra.py b/steam_backlog_enforcer/tests/test_main_dispatch_extra.py index fe26a41..9535b0f 100644 --- a/steam_backlog_enforcer/tests/test_main_dispatch_extra.py +++ b/steam_backlog_enforcer/tests/test_main_dispatch_extra.py @@ -11,18 +11,26 @@ from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.main import ( main, ) +from steam_backlog_enforcer.tests._main_dispatch_world import ( + PKG, + SHARED, + cli_run, + status_stubs, +) from steam_backlog_enforcer.tests._main_helpers import ( ACTIVE_STATUS, VALID_REASON, locked_state, ) -PKG = "steam_backlog_enforcer.main" CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done" class TestMainDispatchAddException: + """add-exception reaches its command handler.""" + def test_dispatches_add_exception(self) -> None: + """The raw tail argv is handed to cmd_add_exception.""" argv = ["prog", "add-exception", "440", "--reason", VALID_REASON] with ( patch.object(sys, "argv", argv), @@ -33,7 +41,10 @@ class TestMainDispatchAddException: class TestMainDispatchServe: + """serve reaches its command handler.""" + def test_dispatches_serve_with_raw_argv(self) -> None: + """serve gets its flags untouched.""" # serve parses its own flags, so the dispatcher must hand the argv # through untouched rather than pre-parsing it. argv = ["prog", "serve", "--port", "8123"] @@ -48,7 +59,10 @@ class TestMainDispatchServe: class TestMainDispatchUnblock: + """unblock reaches its command handler.""" + def test_dispatches_unblock_with_minutes(self) -> None: + """The minutes argument is passed through to cmd_unblock.""" argv = ["prog", "unblock", "10"] with ( patch.object(sys, "argv", argv), @@ -61,7 +75,10 @@ class TestMainDispatchUnblock: class TestMainDispatchPickManual: + """pick-manual reaches its command handler.""" + def test_dispatches_pick_manual(self) -> None: + """pick-manual calls cmd_pick_manual.""" argv = ["prog", "pick-manual", "489830"] with ( patch.object(sys, "argv", argv), @@ -73,6 +90,7 @@ class TestMainDispatchPickManual: mock_cmd.assert_called_once() def test_pick_manual_dispatches_while_locked(self) -> None: + """pick-manual is not stopped by an active lock.""" # Reaching cmd_pick_manual is required for a second concurrent pick. argv = ["prog", "pick-manual", "730"] with ( @@ -91,141 +109,41 @@ class TestMainDispatchPickManual: class TestMainDispatchBlockGaming: + """block-gaming dispatch and the total-block gate.""" + def test_dispatches_block_gaming(self) -> None: + """block-gaming passes its day count to the handler.""" argv = ["prog", "block-gaming", "14"] 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=False, - ), + cli_run(argv, total_block=False), patch(f"{PKG}.cmd_block_gaming") as mock_cmd, ): main() mock_cmd.assert_called_once_with(["14"]) def test_blocked_when_already_active(self) -> None: + """A gated command exits 1 during a total block.""" argv = ["prog", "scan"] 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=True, - ), - patch( - "steam_backlog_enforcer.main._shared.get_total_block_status", - return_value=ACTIVE_STATUS, - ), - patch("steam_backlog_enforcer.main._shared._show_total_block_lock_message"), + cli_run(argv, total_block=True), + patch(f"{SHARED}.get_total_block_status", return_value=ACTIVE_STATUS), + patch(f"{SHARED}._show_total_block_lock_message"), pytest.raises(SystemExit) as exc_info, ): main() assert exc_info.value.code == 1 def test_status_allowed_when_active(self) -> None: + """status still runs and prints during a total block.""" # "status" is dispatched via the COMMANDS dict, which captures the # cmd_status function reference at import time - patching # main.cmd_status would not intercept it. Verify real behavior # (no SystemExit, real status output) instead. argv = ["prog", "status"] 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=True, - ), - patch( - "steam_backlog_enforcer.main.status.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.main.status.get_installed_games", - return_value=[], - ), - # cmd_status resolves _echo from its own module, not the package. - patch("steam_backlog_enforcer.main.status._echo") as mock_echo, + cli_run(argv, total_block=True), + status_stubs(stub_completion=False) as mock_echo, ): main() # must not raise SystemExit output = " ".join(str(c) for c in mock_echo.call_args_list) assert "Steam Backlog Enforcer" in output - - -# ────────────────────────────────────────────────────────────── -# cmd_status shows total block info -# ────────────────────────────────────────────────────────────── - - -class TestMainDispatchGamingCommands: - def test_enforce_passes_its_tail_args_and_exits_with_the_code(self) -> None: - """`enforce` left COMMANDS because its signature cannot carry --demo.""" - argv = ["prog", "enforce", "--demo"] - 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=False, - ), - patch(f"{PKG}.cmd_enforce", return_value=0) as mock_cmd, - pytest.raises(SystemExit) as exc_info, - ): - main() - assert exc_info.value.code == 0 - assert mock_cmd.call_args.args[2] == ["--demo"] - - def test_enforce_propagates_a_nonzero_exit(self) -> None: - argv = ["prog", "enforce", "--bogus"] - 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=False, - ), - patch(f"{PKG}.cmd_enforce", return_value=2), - pytest.raises(SystemExit) as exc_info, - ): - main() - assert exc_info.value.code == 2 - - def test_gaming_unblock_dispatches(self) -> None: - argv = ["prog", "gaming-unblock"] - 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=False, - ), - patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd, - pytest.raises(SystemExit) as exc_info, - ): - main() - assert exc_info.value.code == 0 - mock_cmd.assert_called_once_with([]) - - def test_gaming_unblock_reachable_during_a_total_block(self) -> None: - """A playtime mount makes the total block's own pacman -R fail EBUSY.""" - argv = ["prog", "gaming-unblock"] - 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( - "steam_backlog_enforcer.main._shared.is_total_block_active", - return_value=True, - ), - patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd, - pytest.raises(SystemExit), - ): - main() - mock_cmd.assert_called_once_with([]) diff --git a/steam_backlog_enforcer/tests/test_main_dispatch_extra_gaming.py b/steam_backlog_enforcer/tests/test_main_dispatch_extra_gaming.py new file mode 100644 index 0000000..7091087 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_dispatch_extra_gaming.py @@ -0,0 +1,58 @@ +"""Tests for the main CLI: enforce and gaming-unblock dispatch.""" + +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer.main import main +from steam_backlog_enforcer.tests._main_dispatch_world import PKG, cli_run + + +class TestMainDispatchGamingCommands: + """enforce and gaming-unblock dispatch and exit codes.""" + + def test_enforce_passes_its_tail_args_and_exits_with_the_code(self) -> None: + """`enforce` left COMMANDS because its signature cannot carry --demo.""" + argv = ["prog", "enforce", "--demo"] + with ( + cli_run(argv, total_block=False), + patch(f"{PKG}.cmd_enforce", return_value=0) as mock_cmd, + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 0 + assert mock_cmd.call_args.args[2] == ["--demo"] + + def test_enforce_propagates_a_nonzero_exit(self) -> None: + """A nonzero enforce result becomes the exit code.""" + argv = ["prog", "enforce", "--bogus"] + with ( + cli_run(argv, total_block=False), + patch(f"{PKG}.cmd_enforce", return_value=2), + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 2 + + def test_gaming_unblock_dispatches(self) -> None: + """gaming-unblock calls its handler and exits 0.""" + argv = ["prog", "gaming-unblock"] + with ( + cli_run(argv, total_block=False), + patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd, + pytest.raises(SystemExit) as exc_info, + ): + main() + assert exc_info.value.code == 0 + mock_cmd.assert_called_once_with([]) + + def test_gaming_unblock_reachable_during_a_total_block(self) -> None: + """A playtime mount makes the total block's own pacman -R fail EBUSY.""" + argv = ["prog", "gaming-unblock"] + with ( + cli_run(argv, total_block=True), + patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd, + pytest.raises(SystemExit), + ): + main() + mock_cmd.assert_called_once_with([]) diff --git a/steam_backlog_enforcer/tests/test_main_done.py b/steam_backlog_enforcer/tests/test_main_done.py index d371f3f..aee3b2f 100644 --- a/steam_backlog_enforcer/tests/test_main_done.py +++ b/steam_backlog_enforcer/tests/test_main_done.py @@ -1,132 +1,19 @@ """Tests for the main CLI: done command dispatch.""" -from unittest.mock import MagicMock, patch +from typing import TYPE_CHECKING -from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.main import ( cmd_done, ) -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._cmd_done_cases import CmdDoneCases -PKG = "steam_backlog_enforcer.main" -CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done_finalize" +if TYPE_CHECKING: + from steam_backlog_enforcer.config import Config, State -class TestCmdDone: - """Tests for cmd_done.""" +class TestCmdDone(CmdDoneCases): + """Tests for cmd_done reached through ``steam_backlog_enforcer.main``.""" - def test_no_game_assigned(self) -> None: - with patch(f"{CMD_DONE_PKG}._echo") as mock_echo: - cmd_done(Config(), State()) - assert any("No game" in str(c) for c in mock_echo.call_args_list) - - def test_fetch_fails(self) -> None: - 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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - - def test_not_complete_enforces(self) -> None: - 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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - - def test_complete_finalizes(self) -> None: - 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, - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - mock_final.assert_called_once() - - def test_hltb_cache_miss_fetches(self) -> None: - 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"), - ): - cmd_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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - - def test_reassign_returns_true(self) -> None: - 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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) + def run_done(self, config: Config, state: State) -> None: + """Call the CLI's re-exported cmd_done.""" + cmd_done(config, state) diff --git a/steam_backlog_enforcer/tests/test_main_gate.py b/steam_backlog_enforcer/tests/test_main_gate.py index 2919b16..8b1187a 100644 --- a/steam_backlog_enforcer/tests/test_main_gate.py +++ b/steam_backlog_enforcer/tests/test_main_gate.py @@ -14,13 +14,17 @@ _CONFIGURED = Config(steam_api_key="k", steam_id="i") class TestEnforceGate: + """enforce_gate prints and exits for the CLI lock gate.""" + def test_unconfigured_prints_and_exits(self) -> None: + """An unconfigured CLI prints the setup hint and exits 1.""" with patch(f"{_PKG}._echo") as echo, pytest.raises(SystemExit) as exc: _gate.enforce_gate("status", Config()) assert exc.value.code == 1 echo.assert_called_once_with(_gate.NOT_CONFIGURED_MSG) def test_passes_through_the_loaded_state(self) -> None: + """A configured command runs both locks and returns the loaded state.""" state = State(current_app_id=5) with ( patch.object(State, "load", return_value=state), @@ -32,6 +36,7 @@ class TestEnforceGate: pick.assert_called_once_with("scan", state) def test_setup_runs_without_a_key(self) -> None: + """setup is allowed through before a Steam key exists.""" with ( patch.object(State, "load", return_value=State()), patch(f"{_PKG}._enforce_total_block_lock"), diff --git a/steam_backlog_enforcer/tests/test_main_install.py b/steam_backlog_enforcer/tests/test_main_install.py index 851562b..fe6be53 100644 --- a/steam_backlog_enforcer/tests/test_main_install.py +++ b/steam_backlog_enforcer/tests/test_main_install.py @@ -21,6 +21,7 @@ class TestCmdInstalled: """Tests for cmd_installed.""" def test_shows_games(self) -> None: + """Listing assigned and protected installed games runs cleanly.""" with ( patch( f"{PKG}.get_installed_games", @@ -36,11 +37,13 @@ class TestCmdUninstall: """Tests for cmd_uninstall.""" def test_no_game(self) -> None: + """With no assigned game uninstall says so.""" with patch(f"{PKG}._echo") as mock_echo: cmd_uninstall(Config(), State()) assert any("No game" in str(c) for c in mock_echo.call_args_list) def test_nothing_to_remove(self) -> None: + """With only the assigned game installed there is nothing to remove.""" state = State(current_app_id=440) with ( patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]), @@ -49,6 +52,7 @@ class TestCmdUninstall: cmd_uninstall(Config(), state) def test_confirms_yes(self) -> None: + """Typing the confirmation phrase uninstalls the other games.""" state = State(current_app_id=440) with ( patch( @@ -63,6 +67,7 @@ class TestCmdUninstall: mock_uninstall.assert_called_once() def test_aborts(self) -> None: + """Any other answer aborts and uninstalls nothing.""" state = State(current_app_id=440) with ( patch( @@ -82,11 +87,13 @@ class TestCmdInstall: """Tests for cmd_install.""" def test_no_game(self) -> None: + """With no assigned game install says so.""" with patch(f"{PKG}._echo") as mock_echo: cmd_install(Config(), State()) assert any("No game" in str(c) for c in mock_echo.call_args_list) def test_already_installed(self) -> None: + """An already installed game is not installed again.""" state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.is_game_fully_installed", return_value=True), @@ -95,6 +102,7 @@ class TestCmdInstall: cmd_install(Config(), state) def test_installs_ok(self) -> None: + """A successful install of the assigned game runs cleanly.""" state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.is_game_fully_installed", return_value=False), @@ -104,6 +112,7 @@ class TestCmdInstall: cmd_install(Config(steam_id="i"), state) def test_install_fails(self) -> None: + """A failed install of the assigned game is reported without raising.""" state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.is_game_fully_installed", return_value=False), @@ -130,10 +139,12 @@ class TestCmdHide: """Tests for cmd_hide.""" def test_no_game(self) -> None: + """Hiding with no assigned game runs cleanly.""" with patch(f"{PKG}._echo"): cmd_hide(Config(), State()) def test_no_owned(self) -> None: + """Hiding with no owned games runs cleanly.""" state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), @@ -142,6 +153,7 @@ class TestCmdHide: cmd_hide(Config(), state) def test_hides(self) -> None: + """Hiding the other owned games runs cleanly.""" state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), @@ -151,6 +163,7 @@ class TestCmdHide: cmd_hide(Config(), state) def test_hides_zero(self) -> None: + """Hiding zero games runs cleanly.""" state = State(current_app_id=1, current_game_name="G") with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), @@ -160,6 +173,7 @@ class TestCmdHide: cmd_hide(Config(), state) def test_unreachable_steam_reports_skip(self) -> None: + """An unreachable Steam degrades hiding to a skip message.""" # Regression guard: a deferred Steam restart used to escape as a # traceback instead of degrading to a skip message. state = State(current_app_id=1, current_game_name="G") @@ -181,6 +195,7 @@ class TestCmdUnhide: """Tests for cmd_unhide.""" def test_no_owned(self) -> None: + """Unhiding with no owned games runs cleanly.""" with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), patch(f"{PKG}._echo"), @@ -188,6 +203,7 @@ class TestCmdUnhide: cmd_unhide(Config(), State()) def test_unhides(self) -> None: + """Unhiding owned games runs cleanly.""" with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), patch(f"{PKG}.unhide_all_games", return_value=1), @@ -196,6 +212,7 @@ class TestCmdUnhide: cmd_unhide(Config(), State()) def test_skips_without_steam_like_hide(self) -> None: + """Unhiding without Steam reports a skip, like hide.""" with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), patch( @@ -208,6 +225,7 @@ class TestCmdUnhide: echo.assert_called_with("Library unhiding: skipped (Steam is not installed)") def test_unhides_zero(self) -> None: + """Unhiding zero games runs cleanly.""" with ( patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]), patch(f"{PKG}.unhide_all_games", return_value=0), diff --git a/steam_backlog_enforcer/tests/test_main_install_pick.py b/steam_backlog_enforcer/tests/test_main_install_pick.py index f18f667..4a7fcb4 100644 --- a/steam_backlog_enforcer/tests/test_main_install_pick.py +++ b/steam_backlog_enforcer/tests/test_main_install_pick.py @@ -28,6 +28,7 @@ class TestInstallPicksGame: """Every allowed game that is not fully installed is offered.""" def test_only_unfinished_game_installed_without_asking(self) -> None: + """A single unfinished game installs with no prompt.""" prompter = FakePrompter() with ( use_prompter(prompter), @@ -40,6 +41,7 @@ class TestInstallPicksGame: assert install.call_args.args[:2] == (2, "Picked") def test_several_unfinished_games_prompt(self) -> None: + """Several unfinished games make the user choose.""" prompter = FakePrompter(choices=["2"]) with ( use_prompter(prompter), @@ -52,6 +54,7 @@ class TestInstallPicksGame: assert install.call_args.args[:2] == (2, "Picked") def test_all_installed(self) -> None: + """With everything installed nothing is installed and the user is told.""" with ( patch(f"{PKG}.is_game_fully_installed", return_value=True), patch(f"{PKG}.install_game") as install, diff --git a/steam_backlog_enforcer/tests/test_main_misc.py b/steam_backlog_enforcer/tests/test_main_misc.py index 8cba360..b69bb00 100644 --- a/steam_backlog_enforcer/tests/test_main_misc.py +++ b/steam_backlog_enforcer/tests/test_main_misc.py @@ -23,6 +23,7 @@ class TestCmdUnblock: """Tests for cmd_unblock: it opens a timed store window.""" def test_default_window(self) -> None: + """No argument opens the default-length store window and says until when.""" with ( patch(f"{PKG}.open_store_window", return_value=_UNTIL) as mock_open, patch(f"{PKG}._echo") as mock_echo, @@ -32,6 +33,7 @@ class TestCmdUnblock: assert any("UNBLOCKED until" in str(c) for c in mock_echo.call_args_list) def test_explicit_minutes(self) -> None: + """A minutes argument sets the window length.""" with ( patch(f"{PKG}.open_store_window", return_value=_UNTIL) as mock_open, patch(f"{PKG}._echo"), @@ -41,6 +43,7 @@ class TestCmdUnblock: @pytest.mark.parametrize("raw", ["abc", "0", "31"]) def test_bad_minutes_exits(self, raw: str) -> None: + """Non-numeric or out-of-range minutes print usage and exit.""" with ( patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit), @@ -49,6 +52,7 @@ class TestCmdUnblock: assert any("Usage" in str(c) for c in mock_echo.call_args_list) def test_unblock_failure_exits(self) -> None: + """A failure to open the window is reported and exits.""" with ( patch(f"{PKG}.open_store_window", side_effect=RuntimeError("sudo?")), patch(f"{PKG}._echo") as mock_echo, @@ -62,6 +66,7 @@ class TestCmdBuyDlc: """cmd_buy_dlc is the default-length window, assigned game or not.""" def test_opens_default_window(self) -> None: + """buy-dlc opens a window of the default length.""" with ( patch(f"{PKG}.open_store_window", return_value=_UNTIL) as mock_open, patch(f"{PKG}._echo"), @@ -98,6 +103,7 @@ class TestCmdReset: """Tests for cmd_reset.""" def test_normal_reset(self) -> None: + """Reset clears the assignment and history and unhides games.""" state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1]) printed = _reset(state, [1, 2]) assert state.current_app_id is None @@ -110,24 +116,28 @@ class TestCmdReset: assert any("Store left blocked" in line for line in printed) def test_wrong_phrase_aborts_and_keeps_state(self) -> None: + """A wrong confirmation phrase aborts and keeps state.""" state = State(current_app_id=1) printed = _reset(state, [1], typed="yes") assert printed == ["Aborted."] assert state.current_app_id == 1 def test_reports_the_backup_it_took(self) -> None: + """Reset names the state backup it created.""" backup = MagicMock(id="20261010T000000Z-abcdef") with patch(f"{PKG}.create_backup", return_value=backup): printed = _reset(State(), []) assert "State backed up as 20261010T000000Z-abcdef." in printed def test_unhide_fails(self) -> None: + """An unhide error is reported but the reset still happens.""" state = State(current_app_id=1) printed = _reset(state, OSError("fail")) assert any("could not unhide games: fail" in line for line in printed) assert state.current_app_id is None def test_unhide_returns_zero(self) -> None: + """Zero unhidden games prints no unhidden count.""" state = State(current_app_id=1) printed = _reset(state, [1, 2], unhidden=0) assert not any("Unhidden" in line for line in printed) @@ -137,6 +147,7 @@ class TestCmdSetup: """Tests for cmd_setup.""" def test_calls_interactive(self) -> None: + """setup runs the interactive setup.""" with patch(f"{PKG}.interactive_setup") as mock_setup: cmd_setup(Config(), State()) mock_setup.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_main_misc_gating.py b/steam_backlog_enforcer/tests/test_main_misc_gating.py index 38ba8cf..4835471 100644 --- a/steam_backlog_enforcer/tests/test_main_misc_gating.py +++ b/steam_backlog_enforcer/tests/test_main_misc_gating.py @@ -19,7 +19,10 @@ _BLOCK_PHRASE = "block all gaming for 14 days" class TestCmdAddException: + """add-exception validates arguments and the typed phrase.""" + def test_no_args_prints_usage_and_exits(self) -> None: + """No arguments prints usage and exits 1.""" with ( patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), @@ -27,6 +30,7 @@ class TestCmdAddException: cmd_add_exception([]) def test_missing_reason_flag_exits(self) -> None: + """Arguments without a --reason flag exit 1.""" with ( patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), @@ -34,6 +38,7 @@ class TestCmdAddException: cmd_add_exception(["440", "no", "flag"]) def test_non_numeric_app_id_exits(self) -> None: + """A non-numeric app id exits 1.""" with ( patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), @@ -41,6 +46,7 @@ class TestCmdAddException: cmd_add_exception(["notanumber", "--reason", VALID_REASON]) def test_reason_flag_with_no_value_exits(self) -> None: + """A --reason flag with no value exits 1.""" with ( patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), @@ -48,6 +54,7 @@ class TestCmdAddException: cmd_add_exception(["440", "--reason"]) def test_reason_flag_last_position_with_no_value_exits(self) -> None: + """A trailing --reason with no words after it exits 1.""" # 3 args passes the len/flag guard but --reason is last so reason_parts=[] with ( patch(f"{EXC_PKG}._echo"), @@ -56,6 +63,7 @@ class TestCmdAddException: cmd_add_exception(["440", "extra", "--reason"]) def test_invalid_reason_exits(self) -> None: + """A reason that fails validation exits 1.""" with ( patch(f"{EXC_PKG}._echo"), pytest.raises(SystemExit, match="1"), @@ -63,6 +71,7 @@ class TestCmdAddException: cmd_add_exception(["440", "--reason", "too short"]) def test_add_pending_exception_raises_value_error(self) -> None: + """A ValueError from adding the exception exits 1.""" with ( patch(f"{EXC_PKG}._echo"), patch("builtins.input", return_value=_PHRASE), @@ -75,6 +84,7 @@ class TestCmdAddException: cmd_add_exception(["440", "--reason", VALID_REASON]) def test_wrong_phrase_aborts_before_adding(self) -> None: + """A wrong confirmation phrase aborts before anything is added.""" with ( patch(f"{EXC_PKG}._echo") as mock_echo, patch("builtins.input", return_value="please"), @@ -86,6 +96,7 @@ class TestCmdAddException: assert mock_echo.call_args.args == ("Aborted.",) def test_phrase_names_the_game_from_the_snapshot(self) -> None: + """The confirmation phrase uses the game name from the snapshot.""" with ( patch(f"{EXC_PKG}._echo"), patch(f"{EXC_PKG}.snapshot_game_name", return_value="Team Fortress 2"), @@ -98,6 +109,7 @@ class TestCmdAddException: add.assert_called_once_with(440, VALID_REASON) def test_happy_path(self) -> None: + """The right phrase and reason add the exception and report it.""" with ( patch(f"{EXC_PKG}._echo") as mock_echo, patch("builtins.input", return_value=_PHRASE), @@ -116,7 +128,10 @@ class TestCmdAddException: class TestCmdBlockGaming: + """block-gaming validates the day count and the typed phrase.""" + def test_no_args_shows_usage(self) -> None: + """No arguments prints usage and exits 1.""" with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit) as exc_info: cmd_block_gaming([]) assert exc_info.value.code == 1 @@ -124,18 +139,22 @@ class TestCmdBlockGaming: assert "Usage" in output def test_non_numeric_days(self) -> None: + """A non-numeric day count exits.""" with patch(f"{PKG}._echo"), pytest.raises(SystemExit): cmd_block_gaming(["abc"]) def test_zero_days_rejected(self) -> None: + """Zero days is rejected.""" with patch(f"{PKG}._echo"), pytest.raises(SystemExit): cmd_block_gaming(["0"]) def test_negative_days_rejected(self) -> None: + """A negative day count is rejected.""" with patch(f"{PKG}._echo"), pytest.raises(SystemExit): cmd_block_gaming(["-1"]) def test_aborted_when_not_yes(self) -> None: + """A wrong confirmation phrase starts no block.""" with ( patch(f"{PKG}._echo"), patch("builtins.input", return_value="no"), @@ -145,6 +164,7 @@ class TestCmdBlockGaming: mock_start.assert_not_called() def test_confirmed_starts_block(self) -> None: + """The right phrase starts a block for the given days.""" with ( patch(f"{PKG}._echo"), patch("builtins.input", return_value=_BLOCK_PHRASE), @@ -154,6 +174,7 @@ class TestCmdBlockGaming: mock_start.assert_called_once_with(14) def test_start_failure_exits_nonzero(self) -> None: + """A block that fails to start exits 1.""" with ( patch(f"{PKG}._echo"), patch("builtins.input", return_value=_BLOCK_PHRASE), diff --git a/steam_backlog_enforcer/tests/test_main_part2.py b/steam_backlog_enforcer/tests/test_main_part2.py index 0e16e42..81d0616 100644 --- a/steam_backlog_enforcer/tests/test_main_part2.py +++ b/steam_backlog_enforcer/tests/test_main_part2.py @@ -1,110 +1,67 @@ """Tests for main CLI module — part 2 (missing coverage).""" -from typing import Any from unittest.mock import ( patch, ) from steam_backlog_enforcer._cmd_done_finalize import _finalize_completion from steam_backlog_enforcer.config import Config, State -from steam_backlog_enforcer.tests._main_helpers import done_game +from steam_backlog_enforcer.tests._main_helpers import done_game, snap +from steam_backlog_enforcer.tests._main_patches import ( + HIDER_ECHO, + assign_next, + library_hider, +) CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done_finalize" PKG = "steam_backlog_enforcer.main" -def _snap( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> dict[str, Any]: - return { - "app_id": app_id, - "name": name, - "total_achievements": total, - "unlocked_achievements": unlocked, - "playtime_minutes": 60, - "completionist_hours": hours, - } - - class TestFinalizeCompletion: + """_finalize_completion records the finish and moves on to the next game.""" + def test_with_snapshot_and_hiding(self) -> None: + """With a snapshot the finished game is recorded and the next one picked.""" config = Config(steam_api_key="k", steam_id="i") state = State(current_app_id=1, current_game_name="G") - snap = [_snap(2, "NewGame", 10, 0, 5.0)] + snapshot = [snap(2, "NewGame", 10, 0, 5.0)] with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2, 3], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(2, None), - ), + library_hider([1, 2, 3], (2, None)), patch(f"{CMD_DONE_PKG}.send_notification"), patch.object(State, "save"), ): - - def set_next( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "NewGame" - - mock_pick.side_effect = set_next + mock_pick.side_effect = assign_next(2, "NewGame") _finalize_completion(config, state, done_game(1, "G")) assert 1 in state.finished_app_ids def test_hide_skipped_when_steam_unreachable(self) -> None: + """An undrivable Steam skips hiding with a note instead of aborting.""" # Reconciliation is best-effort: an undrivable Steam must not abort # the completion flow. config = Config(steam_api_key="k", steam_id="i") state = State(current_app_id=1, current_game_name="G") - snap = [_snap(2, "NewGame", 10, 0, 5.0)] + snapshot = [snap(2, "NewGame", 10, 0, 5.0)] with ( patch(f"{CMD_DONE_PKG}._echo") as mock_echo, - # The skip line is printed by the shared hide helper. - patch("steam_backlog_enforcer.library_hider._echo", mock_echo), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(HIDER_ECHO, mock_echo), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2, 3], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, "update in progress"), - ), + library_hider([1, 2, 3], (0, "update in progress")), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True), patch(f"{CMD_DONE_PKG}.send_notification"), patch.object(State, "save"), ): - - def set_next( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "NewGame" - - mock_pick.side_effect = set_next + mock_pick.side_effect = assign_next(2, "NewGame") _finalize_completion(config, state, done_game(1, "G")) assert "skipped (update in progress)" in " ".join( str(c) for c in mock_echo.call_args_list ) def test_no_snapshot(self) -> None: + """Without a snapshot the current game is kept.""" config = Config() state = State(current_app_id=1, current_game_name="G") with ( @@ -117,83 +74,47 @@ class TestFinalizeCompletion: assert state.current_app_id == 1 def test_no_next_game(self) -> None: + """Completion survives when no next game is picked.""" config = Config() state = State(current_app_id=1, current_game_name="G") - snap = [_snap(1, "G", 10, 10)] + snapshot = [snap(1, "G", 10, 10)] with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick, patch.object(State, "save"), ): - - def set_none( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = None - - mock_pick.side_effect = set_none + mock_pick.side_effect = assign_next(None) _finalize_completion(config, state, done_game(1, "G")) def test_no_owned_ids(self) -> None: + """Completion survives an empty owned-games list.""" config = Config() state = State(current_app_id=1, current_game_name="G") - snap = [_snap(2, "Next", 10, 0)] + snapshot = [snap(2, "Next", 10, 0)] with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), + library_hider([]), patch(f"{CMD_DONE_PKG}.send_notification"), patch.object(State, "save"), ): - - def set_2( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "Next" - - mock_pick.side_effect = set_2 + mock_pick.side_effect = assign_next(2, "Next") _finalize_completion(config, state, done_game(1, "G")) def test_hide_returns_zero(self) -> None: + """Completion survives when nothing new needs hiding.""" config = Config() state = State(current_app_id=1, current_game_name="G") - snap = [_snap(2, "Next", 10, 0)] + snapshot = [snap(2, "Next", 10, 0)] with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, None), - ), + library_hider([1, 2], (0, None)), patch(f"{CMD_DONE_PKG}.send_notification"), patch.object(State, "save"), ): - - def set_2( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "Next" - - mock_pick.side_effect = set_2 + mock_pick.side_effect = assign_next(2, "Next") _finalize_completion(config, state, done_game(1, "G")) diff --git a/steam_backlog_enforcer/tests/test_main_part2b.py b/steam_backlog_enforcer/tests/test_main_part2b.py index 92b1d44..1f41d36 100644 --- a/steam_backlog_enforcer/tests/test_main_part2b.py +++ b/steam_backlog_enforcer/tests/test_main_part2b.py @@ -1,47 +1,27 @@ """Tests for the main CLI: enforce-on-done behaviour.""" -from typing import Any -from unittest.mock import MagicMock, patch +from unittest.mock import patch from steam_backlog_enforcer._cmd_done_finalize import ( _enforce_on_done, - cmd_done, ) from steam_backlog_enforcer.config import Config, State -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._main_patches import HIDER_ECHO, library_hider CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done_finalize" PKG = "steam_backlog_enforcer.main" -def _snap( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> dict[str, Any]: - return { - "app_id": app_id, - "name": name, - "total_achievements": total, - "unlocked_achievements": unlocked, - "playtime_minutes": 60, - "completionist_hours": hours, - } - - class TestEnforceOnDone: """Tests for _enforce_on_done.""" def test_no_current_game(self) -> None: + """With no game assigned there is nothing to enforce.""" _enforce_on_done(Config(), State()) def test_kills_and_uninstalls(self) -> None: - config = Config( - kill_unauthorized_games=True, - uninstall_other_games=True, - ) + """Violations are killed and other games uninstalled.""" + config = Config(kill_unauthorized_games=True, uninstall_other_games=True) state = State(current_app_id=1, current_game_name="G") with ( patch(f"{CMD_DONE_PKG}._echo"), @@ -51,55 +31,32 @@ class TestEnforceOnDone: ), patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(1, None), - ), + library_hider([1, 2], (1, None)), ): _enforce_on_done(config, state) def test_no_violations_no_uninstalls(self) -> None: - config = Config( - kill_unauthorized_games=True, - uninstall_other_games=True, - ) + """No violations and nothing to uninstall still completes.""" + config = Config(kill_unauthorized_games=True, uninstall_other_games=True) state = State(current_app_id=1, current_game_name="G") with ( patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]), patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, None), - ), + library_hider([], (0, None)), ): _enforce_on_done(config, state) def test_hide_skipped_when_steam_unreachable(self) -> None: + """Hiding is skipped with the reason when Steam is unreachable.""" config = Config(kill_unauthorized_games=False, uninstall_other_games=False) state = State(current_app_id=1, current_game_name="G") with ( patch(f"{CMD_DONE_PKG}._echo") as mock_echo, - # The skip line is printed by the shared hide helper. - patch("steam_backlog_enforcer.library_hider._echo", mock_echo), + patch(HIDER_ECHO, mock_echo), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, "Steam is not installed"), - ), + library_hider([1, 2], (0, "Steam is not installed")), ): _enforce_on_done(config, state) assert "skipped (Steam is not installed)" in " ".join( @@ -107,6 +64,7 @@ class TestEnforceOnDone: ) def test_reinstall_when_not_installed(self) -> None: + """A missing assigned game is reinstalled through the Steam protocol.""" config = Config( kill_unauthorized_games=False, uninstall_other_games=False, @@ -117,132 +75,7 @@ class TestEnforceOnDone: patch(f"{CMD_DONE_PKG}._echo"), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=False), patch(f"{CMD_DONE_PKG}.install_game") as mock_install, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, None), - ), + library_hider([1, 2], (0, None)), ): _enforce_on_done(config, state) mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True) - - """Tests for cmd_done.""" - - def test_no_game_assigned(self) -> None: - with patch(f"{CMD_DONE_PKG}._echo") as mock_echo: - cmd_done(Config(), State()) - assert any("No game" in str(c) for c in mock_echo.call_args_list) - - def test_fetch_fails(self) -> None: - 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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - - def test_not_complete_enforces(self) -> None: - 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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - - def test_complete_finalizes(self) -> None: - 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, - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - mock_final.assert_called_once() - - def test_hltb_cache_miss_fetches(self) -> None: - 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"), - ): - cmd_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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) - - def test_reassign_returns_true(self) -> None: - 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"), - ): - cmd_done(Config(steam_api_key="k", steam_id="i"), state) diff --git a/steam_backlog_enforcer/tests/test_main_part2c.py b/steam_backlog_enforcer/tests/test_main_part2c.py index 203f116..03eaa5b 100644 --- a/steam_backlog_enforcer/tests/test_main_part2c.py +++ b/steam_backlog_enforcer/tests/test_main_part2c.py @@ -1,13 +1,14 @@ """Tests for finalize_completion: snapshot refresh and install retry.""" -from typing import TYPE_CHECKING, Any +from typing import TYPE_CHECKING from unittest.mock import ( patch, ) from steam_backlog_enforcer._cmd_done_finalize import _finalize_completion from steam_backlog_enforcer.config import Config, State -from steam_backlog_enforcer.tests._main_helpers import done_game +from steam_backlog_enforcer.tests._main_helpers import done_game, snap +from steam_backlog_enforcer.tests._main_patches import assign_next, library_hider if TYPE_CHECKING: from steam_backlog_enforcer.steam_api import GameInfo @@ -19,31 +20,16 @@ _CMD_DONE = "steam_backlog_enforcer._cmd_done" PKG = "steam_backlog_enforcer.main" -def _snap( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> dict[str, Any]: - return { - "app_id": app_id, - "name": name, - "total_achievements": total, - "unlocked_achievements": unlocked, - "playtime_minutes": 60, - "completionist_hours": hours, - } - - class TestFinalizeCompletionRetries: + """Finalizing a completion refreshes hours and retries a dropped install.""" + def test_refreshes_snapshot_hours_before_pick(self) -> None: """Ensure stale snapshot hours are replaced before picking next game.""" config = Config() state = State(current_app_id=1, current_game_name="G") - snap = [ - _snap(2, "A Space for the Unbound", 10, 0, 0.56), - _snap(3, "Lacuna", 10, 0, 1.2), + snapshot = [ + snap(2, "A Space for the Unbound", 10, 0, 0.56), + snap(3, "Lacuna", 10, 0, 1.2), ] seen: dict[int, float] = {} @@ -60,7 +46,7 @@ class TestFinalizeCompletionRetries: with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={2: 20.05}), patch( f"{_CMD_DONE}.fetch_hltb_times_cached", @@ -79,29 +65,13 @@ class TestFinalizeCompletionRetries: """Re-trigger install after hide step in case Steam restart drops it.""" config = Config(steam_id="sid") state = State(current_app_id=1, current_game_name="DoneGame") - snap = [_snap(2, "Next", 10, 0, 5.0)] - - def set_next( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "Next" + snapshot = [snap(2, "Next", 10, 0, 5.0)] with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), - patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(1, None), - ), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), + patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=assign_next(2, "Next")), + library_hider([1, 2], (1, None)), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=False), patch(f"{CMD_DONE_PKG}.install_game") as mock_install, patch(f"{CMD_DONE_PKG}.send_notification"), @@ -115,29 +85,13 @@ class TestFinalizeCompletionRetries: """Do not re-trigger install when assigned game is already present.""" config = Config(steam_id="sid") state = State(current_app_id=1, current_game_name="DoneGame") - snap = [_snap(2, "Next", 10, 0, 5.0)] - - def set_next( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "Next" + snapshot = [snap(2, "Next", 10, 0, 5.0)] with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap), - patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(1, None), - ), + patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snapshot), + patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=assign_next(2, "Next")), + library_hider([1, 2], (1, None)), patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True), patch(f"{CMD_DONE_PKG}.install_game") as mock_install, patch(f"{CMD_DONE_PKG}.send_notification"), diff --git a/steam_backlog_enforcer/tests/test_main_pick_cap.py b/steam_backlog_enforcer/tests/test_main_pick_cap.py index 0bcf620..a40a4b4 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_cap.py +++ b/steam_backlog_enforcer/tests/test_main_pick_cap.py @@ -5,7 +5,7 @@ from unittest.mock import patch import pytest from steam_backlog_enforcer._pick_completion import PickProgress -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.main import ( cmd_pick_manual, ) @@ -14,30 +14,26 @@ from steam_backlog_enforcer.tests._main_helpers import ( locked_state, two_pick_state, ) +from steam_backlog_enforcer.tests._main_patches import HIDE_OTHERS, confirmed_pick PKG = "steam_backlog_enforcer.main.picks" class TestPickManualCap: + """pick-manual adds picks up to max_manual_picks and keeps every allowed game.""" + def test_second_pick_is_added_not_replacing(self) -> None: + """A second pick joins the first instead of replacing it.""" state = locked_state(app_id=100) with ( patch(f"{PKG}.report_completion", return_value=[]), - patch(f"{PKG}._echo"), - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), + confirmed_pick(), ): cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"]) assert [p["app_id"] for p in state.manual_picks] == [100, 489830] def test_third_pick_refused_at_cap(self) -> None: + """A pick beyond the cap exits 1 and lists the locked-in games.""" state = two_pick_state() with ( patch(f"{PKG}.report_completion", return_value=[]), @@ -53,67 +49,40 @@ class TestPickManualCap: assert len(state.manual_picks) == 2 def test_duplicate_pick_refused_by_core(self) -> None: + """Picking a game that is already a pick is refused.""" # The cap has room, so this exercises the core's own refusal path. state = locked_state(app_id=489830) with ( patch(f"{PKG}.report_completion", return_value=[]), - patch(f"{PKG}._echo") as mock_echo, - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), + confirmed_pick() as mocks, pytest.raises(SystemExit), ): cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"]) assert "already one of your manual picks" in " ".join( - str(c) for c in mock_echo.call_args_list + str(c) for c in mocks["echo"].call_args_list ) def test_cascade_keeps_every_allowed_game(self) -> None: + """Uninstall and hide spare every allowed game, old and new.""" # The regression this whole feature turns on: adding a second pick must # not uninstall or hide the first. state = locked_state(app_id=100) with ( patch(f"{PKG}.report_completion", return_value=[]), - patch(f"{PKG}._echo"), - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0) as mock_uninstall, - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 100, 489830], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(1, None), - ) as mock_hide, + confirmed_pick(owned=[1, 100, 489830]) as mocks, + patch(HIDE_OTHERS, return_value=(1, None)) as mock_hide, ): cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"]) - mock_uninstall.assert_called_once_with({100, 489830}) + mocks["uninstall"].assert_called_once_with({100, 489830}) mock_hide.assert_called_once_with([1, 100, 489830], {100, 489830}) def test_installs_every_missing_allowed_game(self) -> None: + """Every allowed game that is not installed gets installed.""" state = locked_state(app_id=100) with ( patch(f"{PKG}.report_completion", return_value=[]), - patch(f"{PKG}._echo"), - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=False), + confirmed_pick(installed=False), patch(f"{PKG}.install_game") as mock_install, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), ): cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"]) installed = {call.args[0] for call in mock_install.call_args_list} @@ -124,6 +93,7 @@ class TestPickManualRetirementNotices: """A pick retired by the sweep is announced before the typed-phrase prompt.""" def test_lists_kept_games_and_stale_assignment(self) -> None: + """Retired picks are listed as kept and a stale assignment is noted.""" state = locked_state(app_id=100) state.current_app_id = 200 state.current_game_name = "Finished" diff --git a/steam_backlog_enforcer/tests/test_main_pick_keep.py b/steam_backlog_enforcer/tests/test_main_pick_keep.py index 264b911..065dac7 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_keep.py +++ b/steam_backlog_enforcer/tests/test_main_pick_keep.py @@ -60,7 +60,10 @@ def _output(mock_echo: MagicMock) -> str: class TestCmdPickKeep: + """pick keeps the current games unless --replace is given.""" + def test_keeps_by_default(self) -> None: + """A plain pick keeps the current games.""" with ( patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]), patch(f"{PKG}.load_hltb_cache", return_value={}), @@ -71,6 +74,7 @@ class TestCmdPickKeep: assert mock_pick.call_args.kwargs["keep"] is True def test_replace_flag_replaces(self) -> None: + """--replace replaces the current games.""" with ( patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]), patch(f"{PKG}.load_hltb_cache", return_value={}), @@ -81,6 +85,7 @@ class TestCmdPickKeep: assert mock_pick.call_args.kwargs["keep"] is False def test_unknown_argument_exits(self) -> None: + """An unknown flag exits without picking.""" with ( patch(f"{PKG}._echo") as mock_echo, patch(f"{PKG}.pick_next_game") as mock_pick, @@ -92,6 +97,7 @@ class TestCmdPickKeep: mock_pick.assert_not_called() def test_full_allowed_set_is_refused_before_the_list(self) -> None: + """A full allowed set is refused before any list or question.""" # No scripted answers: being asked anything raises IndexError. with ( use_prompter(FakePrompter()), @@ -111,6 +117,7 @@ class TestCmdPickKeep: assert "--replace" in output def test_one_below_the_cap_is_allowed(self) -> None: + """One game below the cap may still pick.""" state = State( current_app_id=1, current_game_name="Current", manual_picks=[_pick(2, "A")] ) @@ -125,7 +132,10 @@ class TestCmdPickKeep: class TestDispatch: + """main hands the pick flags through.""" + def test_main_passes_the_argv_tail_to_pick(self) -> None: + """main passes the argv tail to cmd_pick.""" config, state = _config(), State() with ( patch("sys.argv", ["sbe", "pick", "--replace"]), @@ -136,19 +146,25 @@ class TestDispatch: class TestReplaceParam: + """The web job replace parameter is a boolean checkbox.""" + def test_defaults_to_keep(self) -> None: + """A missing replace parameter means keep.""" assert validate_params("pick", {}) == {"replace": 0} @pytest.mark.parametrize(("raw", "want"), [(True, 1), (False, 0), (1, 1)]) def test_accepts_booleans(self, raw: object, want: int) -> None: + """Booleans and 1 are accepted as 0 or 1.""" assert validate_params("pick", {"replace": raw}) == {"replace": want} @pytest.mark.parametrize("raw", [2, "yes", None]) def test_rejects_non_booleans(self, raw: object) -> None: + """Non-boolean values are rejected.""" with pytest.raises(InvalidParamsError): validate_params("pick", {"replace": raw}) def test_is_an_unchecked_checkbox_in_the_catalog(self) -> None: + """The catalog shows replace as an unchecked checkbox.""" [spec] = PARAMS["pick"] assert spec.to_json() == { "name": "replace", @@ -161,17 +177,23 @@ class TestReplaceParam: class TestPickJob: + """The pick job turns its parameter into CLI flags.""" + @pytest.mark.parametrize(("replace", "argv"), [(1, ["--replace"]), (0, [])]) def test_replace_param_becomes_the_flag( self, replace: int, argv: list[str] ) -> None: + """replace=1 becomes --replace, replace=0 no flag.""" with patch("steam_backlog_enforcer.jobs._registry.cmd_pick") as command: _registry.HANDLERS["pick"]({"replace": replace}, MagicMock(), MagicMock()) assert command.call_args.args[2] == argv class TestAbandonKeptGame: + """A game kept as a manual pick can be abandoned.""" + def test_a_kept_game_can_be_abandoned(self) -> None: + """abandon-pick drops a kept pick.""" state = State( current_app_id=1, current_game_name="Current", diff --git a/steam_backlog_enforcer/tests/test_main_pick_manual.py b/steam_backlog_enforcer/tests/test_main_pick_manual.py index d4922ed..8cd5fd2 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_manual.py +++ b/steam_backlog_enforcer/tests/test_main_pick_manual.py @@ -6,18 +6,23 @@ from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.main import ( cmd_pick_manual, ) +from steam_backlog_enforcer.tests._main_patches import HIDE_OTHERS, confirmed_pick PKG = "steam_backlog_enforcer.main.picks" class TestCmdPickManual: + """cmd_pick_manual validates the id, confirms, then locks the pick in.""" + def test_invalid_app_id(self) -> None: + """A non-numeric app id prints an error.""" with patch(f"{PKG}._echo") as mock_echo: cmd_pick_manual(Config(), State(), ["abc"]) output = " ".join(str(c) for c in mock_echo.call_args_list) assert "Error" in output def test_game_not_found(self) -> None: + """An app id with no resolvable name reports not found.""" with ( patch(f"{PKG}._resolve_game_name", return_value=None), patch(f"{PKG}._echo") as mock_echo, @@ -27,6 +32,7 @@ class TestCmdPickManual: assert "not found" in output def test_aborted_when_not_yes(self) -> None: + """Any answer other than the lock-in phrase aborts without saving.""" with ( patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), patch(f"{PKG}._echo"), @@ -37,41 +43,20 @@ class TestCmdPickManual: mock_save.assert_not_called() def test_prompts_for_id_when_no_args(self) -> None: + """With no args the app id is asked for interactively.""" state = State() - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", side_effect=["489830", "lock in Skyrim SE"]), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - ): + with confirmed_pick(answers=["489830", "lock in Skyrim SE"]): cmd_pick_manual(Config(), state, []) assert state.current_app_id == 489830 def test_success_sets_state_and_runs_post_steps(self) -> None: + """A confirmed pick is recorded and uninstall, install and hide all run.""" state = State() config = Config(uninstall_other_games=True) with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save") as mock_save, - patch(f"{PKG}.uninstall_other_games", return_value=2) as mock_uninstall, - patch(f"{PKG}.is_game_fully_installed", return_value=False), + confirmed_pick(owned=[1, 489830], installed=False, uninstall=2) as mocks, patch(f"{PKG}.install_game") as mock_install, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 489830], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(1, None), - ) as mock_hide, + patch(HIDE_OTHERS, return_value=(1, None)) as mock_hide, ): cmd_pick_manual(config, state, ["489830"]) @@ -79,42 +64,22 @@ class TestCmdPickManual: assert state.manual_picks[0]["game_name"] == "Skyrim SE" assert state.manual_picks[0]["started_at"] != "" assert state.current_app_id == 489830 - mock_save.assert_called_once() - mock_uninstall.assert_called_once_with({489830}) + mocks["save"].assert_called_once() + mocks["uninstall"].assert_called_once_with({489830}) mock_install.assert_called_once() mock_hide.assert_called_once() def test_enforcement_started_at_set_when_empty(self) -> None: + """A confirmed pick stamps enforcement_started_at when it is empty.""" state = State(enforcement_started_at="") - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - ): + with confirmed_pick(): cmd_pick_manual(Config(), state, ["489830"]) assert state.enforcement_started_at != "" def test_enforcement_started_at_not_overwritten(self) -> None: + """A confirmed pick keeps an existing enforcement_started_at.""" existing_ts = "2026-01-01T00:00:00+00:00" state = State(enforcement_started_at=existing_ts) - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - ): + with confirmed_pick(): cmd_pick_manual(Config(), state, ["489830"]) assert state.enforcement_started_at == existing_ts diff --git a/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py b/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py index 13da5aa..8491ef9 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py +++ b/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py @@ -10,133 +10,73 @@ from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.main import ( cmd_pick_manual, ) +from steam_backlog_enforcer.tests._main_patches import ( + HIDE_OTHERS, + HIDER_ECHO, + confirmed_pick, +) PKG = "steam_backlog_enforcer.main.picks" class TestPickManualCascade: + """After a confirmed pick, uninstall, install and hide run only when needed.""" + def test_no_uninstall_when_config_off(self) -> None: + """With uninstall_other_games off nothing is uninstalled.""" state = State() config = Config(uninstall_other_games=False) - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games") as mock_uninstall, - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - ): + with confirmed_pick(uninstall=None) as mocks: cmd_pick_manual(config, state, ["489830"]) - mock_uninstall.assert_not_called() + mocks["uninstall"].assert_not_called() def test_game_already_installed_skips_install(self) -> None: + """An already installed pick is not installed again.""" state = State() - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch(f"{PKG}.install_game") as mock_install, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - ): + with confirmed_pick(), patch(f"{PKG}.install_game") as mock_install: cmd_pick_manual(Config(), state, ["489830"]) mock_install.assert_not_called() def test_no_hide_when_no_owned_ids(self) -> None: + """With no owned app ids the library is not hidden.""" state = State() - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo"), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games" - ) as mock_hide, - ): + with confirmed_pick(), patch(HIDE_OTHERS) as mock_hide: cmd_pick_manual(Config(), state, ["489830"]) mock_hide.assert_not_called() def test_uninstall_returns_zero_no_echo(self) -> None: + """Uninstalling zero games prints no uninstall line.""" state = State() config = Config(uninstall_other_games=True) - with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - ): + with confirmed_pick() as mocks: cmd_pick_manual(config, state, ["489830"]) - 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 "Uninstalled 0" not in output def test_hide_returns_zero_no_echo(self) -> None: + """Hiding zero games prints no library line.""" state = State() with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo") as mock_echo, - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, None), - ), + confirmed_pick(owned=[1, 2]) as mocks, + patch(HIDE_OTHERS, return_value=(0, None)), ): cmd_pick_manual(Config(), state, ["489830"]) - 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 "Library: hid" not in output def test_unreachable_steam_reports_skip(self) -> None: + """A Steam that cannot be driven reports a skip and the pick is kept.""" # The pick itself must survive a Steam that cannot be driven: this # used to abort cmd_pick_manual with a traceback after the pick had # already been saved. state = State() with ( - patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"), - patch(f"{PKG}._echo") as mock_echo, - # The skip line is printed by the shared hide helper. - patch("steam_backlog_enforcer.library_hider._echo", mock_echo), - patch("builtins.input", return_value="lock in Skyrim SE"), - patch.object(State, "save"), - patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_fully_installed", return_value=True), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, "update in progress"), - ), + confirmed_pick(owned=[1, 2]) as mocks, + patch(HIDER_ECHO, mocks["echo"]), + patch(HIDE_OTHERS, return_value=(0, "update in progress")), ): cmd_pick_manual(Config(), state, ["489830"]) - 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 "skipped (update in progress)" in output assert [p["app_id"] for p in state.manual_picks] == [489830] diff --git a/steam_backlog_enforcer/tests/test_main_picks.py b/steam_backlog_enforcer/tests/test_main_picks.py index 07d1bde..c13f201 100644 --- a/steam_backlog_enforcer/tests/test_main_picks.py +++ b/steam_backlog_enforcer/tests/test_main_picks.py @@ -13,6 +13,12 @@ from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError from steam_backlog_enforcer.tests._main_helpers import ( snap, ) +from steam_backlog_enforcer.tests._main_patches import ( + HIDE_OTHERS, + HIDER_ECHO, + OWNED_IDS, + library_hider, +) PKG = "steam_backlog_enforcer.main.picks" @@ -21,6 +27,7 @@ class TestCmdPick: """Tests for cmd_pick.""" def test_no_snapshot_prints_message(self) -> None: + """Without a snapshot, pick tells the user to scan first.""" with ( patch(f"{PKG}.load_snapshot", return_value=[]), patch(f"{PKG}._echo") as mock_echo, @@ -29,15 +36,13 @@ class TestCmdPick: mock_echo.assert_called_once_with("No snapshot found. Run 'scan' first.") def test_calls_pick_next_game(self) -> None: + """With a snapshot, pick hands the games to pick_next_game.""" snapshot = [snap(2, "NewGame", 10, 0, 5.0)] with ( patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}), patch(f"{PKG}.pick_next_game") as mock_pick, - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), + library_hider([]), ): config = Config(steam_api_key="k", steam_id="i") state = State() @@ -45,100 +50,77 @@ class TestCmdPick: mock_pick.assert_called_once() def test_hides_games_after_pick(self) -> None: + """After a pick every game except the assigned one is hidden.""" snapshot = [snap(2, "NewGame", 10, 0, 5.0)] state = State(current_app_id=2, current_game_name="NewGame") with ( patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}), patch(f"{PKG}.pick_next_game"), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2, 3], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(2, None), - ) as mock_hide, + library_hider([1, 2, 3], (2, None)) as mock_hide, patch(f"{PKG}._echo"), ): cmd_pick(Config(steam_api_key="k", steam_id="i"), state) mock_hide.assert_called_once_with([1, 2, 3], {2}) def test_no_hide_message_when_none_hidden(self) -> None: + """Nothing hidden means nothing is printed.""" snapshot = [snap(2, "NewGame", 10, 0, 5.0)] state = State(current_app_id=2, current_game_name="NewGame") with ( patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={}), patch(f"{PKG}.pick_next_game"), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2, 3], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, None), - ), + library_hider([1, 2, 3], (0, None)), patch(f"{PKG}._echo") as mock_echo, ): cmd_pick(Config(steam_api_key="k", steam_id="i"), state) mock_echo.assert_not_called() def test_unreachable_steam_reports_skip(self) -> None: + """A hide skipped for an unreachable Steam is reported with its reason.""" snapshot = [snap(2, "NewGame", 10, 0, 5.0)] state = State(current_app_id=2, current_game_name="NewGame") with ( patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={}), patch(f"{PKG}.pick_next_game"), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[1, 2, 3], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games", - return_value=(0, "Steam is not installed"), - ), + library_hider([1, 2, 3], (0, "Steam is not installed")), patch(f"{PKG}._echo") as mock_echo, - # The skip line is printed by the shared hide helper. - patch("steam_backlog_enforcer.library_hider._echo", mock_echo), + patch(HIDER_ECHO, mock_echo), ): cmd_pick(Config(steam_api_key="k", steam_id="i"), state) output = " ".join(str(c) for c in mock_echo.call_args_list) assert "skipped (Steam is not installed)" in output def test_no_hide_when_no_current_app(self) -> None: + """No assignment after the pick means no hide attempt.""" snapshot = [snap(2, "NewGame", 10, 0, 5.0)] with ( patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={}), patch(f"{PKG}.pick_next_game"), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids" - ) as mock_owned, + patch(OWNED_IDS) as mock_owned, ): cmd_pick(Config(steam_api_key="k", steam_id="i"), State()) mock_owned.assert_not_called() def test_no_hide_when_owned_ids_empty(self) -> None: + """An empty owned-games list skips hiding.""" snapshot = [snap(2, "NewGame", 10, 0, 5.0)] state = State(current_app_id=2, current_game_name="NewGame") with ( patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={}), patch(f"{PKG}.pick_next_game"), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), - patch( - "steam_backlog_enforcer.library_hider.try_hide_other_games" - ) as mock_hide, + library_hider([]), + patch(HIDE_OTHERS) as mock_hide, ): cmd_pick(Config(steam_api_key="k", steam_id="i"), state) mock_hide.assert_not_called() def test_hltb_cache_applied_to_games(self) -> None: + """Cached HLTB hours replace unknown hours before picking.""" snapshot = [snap(2, "NewGame", 10, 0, -1.0)] captured_games: list[list[GameInfo]] = [] config = Config(steam_api_key="k", steam_id="i") @@ -153,10 +135,7 @@ class TestCmdPick: patch(f"{PKG}.load_snapshot", return_value=snapshot), patch(f"{PKG}.load_hltb_cache", return_value={2: 7.5}), patch(f"{PKG}.pick_next_game", side_effect=capture_pick), - patch( - "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", - return_value=[], - ), + library_hider([]), ): cmd_pick(config, state) @@ -165,7 +144,10 @@ class TestCmdPick: class TestResolveGameName: + """Game names come from the snapshot first, then the Steam API.""" + def test_found_in_snapshot(self) -> None: + """A snapshot entry supplies the name.""" snapshot = [ { "app_id": 440, @@ -182,6 +164,7 @@ class TestResolveGameName: assert result == "TF2" def test_not_in_snapshot_found_via_api(self) -> None: + """A game missing from the snapshot is named via the API.""" with ( patch(f"{PKG}.load_snapshot", return_value=[]), patch(f"{PKG}.SteamAPIClient") as mock_cls, @@ -193,6 +176,7 @@ class TestResolveGameName: assert result == "Counter-Strike 2" def test_api_raises_returns_none(self) -> None: + """An API error resolves to None.""" with ( patch(f"{PKG}.load_snapshot", return_value=[]), patch(f"{PKG}.SteamAPIClient") as mock_cls, @@ -202,6 +186,7 @@ class TestResolveGameName: assert result is None def test_not_found_anywhere_returns_none(self) -> None: + """A game unknown to both sources resolves to None.""" with ( patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 1, "name": "X"}]), patch(f"{PKG}.SteamAPIClient") as mock_cls, @@ -211,6 +196,7 @@ class TestResolveGameName: assert result is None def test_no_snapshot_falls_through_to_api(self) -> None: + """No snapshot at all falls back to the API.""" with ( patch(f"{PKG}.load_snapshot", return_value=None), patch(f"{PKG}.SteamAPIClient") as mock_cls, @@ -220,8 +206,3 @@ class TestResolveGameName: ] result = _resolve_game_name(Config(), 440) assert result == "TF2" - - -# ────────────────────────────────────────────────────────────── -# cmd_pick_manual -# ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/test_main_shared.py b/steam_backlog_enforcer/tests/test_main_shared.py index 7cd47b0..1774f30 100644 --- a/steam_backlog_enforcer/tests/test_main_shared.py +++ b/steam_backlog_enforcer/tests/test_main_shared.py @@ -27,28 +27,36 @@ PKG = "steam_backlog_enforcer.main._shared" class TestIsManualPickLocked: + """_is_manual_pick_locked holds until the game is done or the deadline passes.""" + def test_no_manual_pick_not_locked(self) -> None: + """No manual pick means no lock.""" assert _is_manual_pick_locked(State()) is False def test_game_finished_not_locked(self) -> None: + """A finished pick releases the lock.""" state = locked_state(app_id=100) state.finished_app_ids = [100] assert _is_manual_pick_locked(state) is False def test_deadline_passed_not_locked(self) -> None: + """A passed deadline releases the lock.""" state = locked_state(started_at=EXPIRED_AT) assert _is_manual_pick_locked(state) is False def test_active_lock_returns_true(self) -> None: + """A recent unfinished pick is locked.""" state = locked_state(started_at=STARTED_AT) assert _is_manual_pick_locked(state) is True def test_no_started_at_stays_locked(self) -> None: + """A missing start time keeps the lock.""" # Missing timestamp → cannot determine deadline → stays locked. state = locked_state(started_at="") assert _is_manual_pick_locked(state) is True def test_invalid_started_at_stays_locked(self) -> None: + """An unparsable start time keeps the lock.""" state = locked_state(started_at="not-a-date") assert _is_manual_pick_locked(state) is True @@ -59,7 +67,10 @@ class TestIsManualPickLocked: class TestShowManualPickLockMessage: + """The manual-pick lock message names the game and deadline.""" + def test_shows_game_info(self) -> None: + """The message names the game and its id.""" state = locked_state(app_id=42, name="MyGame", started_at=STARTED_AT) with patch(f"{PKG}._echo") as mock_echo: _show_manual_pick_lock_message(state) @@ -68,6 +79,7 @@ class TestShowManualPickLockMessage: assert "42" in output def test_shows_deadline_when_started_at_valid(self) -> None: + """A valid start time adds the deadline.""" state = locked_state(started_at=STARTED_AT) with patch(f"{PKG}._echo") as mock_echo: _show_manual_pick_lock_message(state) @@ -75,11 +87,13 @@ class TestShowManualPickLockMessage: assert "Deadline" in output def test_no_crash_on_invalid_started_at(self) -> None: + """An invalid start time does not crash the message.""" state = locked_state(started_at="bad-date") with patch(f"{PKG}._echo"): _show_manual_pick_lock_message(state) # must not raise def test_no_crash_on_empty_started_at(self) -> None: + """An empty start time does not crash the message.""" state = locked_state(started_at="") with patch(f"{PKG}._echo"): _show_manual_pick_lock_message(state) # must not raise @@ -91,15 +105,20 @@ class TestShowManualPickLockMessage: class TestEnforceManualPickLock: + """_enforce_manual_pick_lock exits on blocked commands only.""" + def test_no_lock_passes(self) -> None: + """Without a lock every command passes.""" _enforce_manual_pick_lock("scan", State()) # no exit def test_exempt_command_passes_while_locked(self) -> None: + """Exempt commands pass while locked.""" state = locked_state() _enforce_manual_pick_lock("done", state) # no exit _enforce_manual_pick_lock("status", state) # no exit def test_blocked_command_exits(self) -> None: + """A blocked command exits while locked.""" state = locked_state() with ( patch(f"{PKG}._show_manual_pick_lock_message"), @@ -109,6 +128,7 @@ class TestEnforceManualPickLock: assert exc_info.value.code == 1 def test_add_exception_blocked_when_locked(self) -> None: + """add-exception is blocked while locked.""" state = locked_state() with ( patch(f"{PKG}._show_manual_pick_lock_message"), @@ -117,6 +137,7 @@ class TestEnforceManualPickLock: _enforce_manual_pick_lock("add-exception", state) def test_pick_manual_allowed_when_already_locked(self) -> None: + """pick-manual stays reachable while locked.""" # A second pick must be reachable while the first holds the lock; the # cap inside cmd_pick_manual is what limits it, not the lock check. _enforce_manual_pick_lock("pick-manual", locked_state()) @@ -128,7 +149,10 @@ class TestEnforceManualPickLock: class TestShowTotalBlockLockMessage: + """The total-block message shows the time left and exempt commands.""" + def test_shows_remaining_time(self) -> None: + """The message shows the banner and the time left.""" with patch(f"{PKG}._echo") as mock_echo: _show_total_block_lock_message(ACTIVE_STATUS) output = " ".join(str(c) for c in mock_echo.call_args_list) @@ -136,6 +160,7 @@ class TestShowTotalBlockLockMessage: assert "Time remaining: 23h 2m" in output def test_lists_exempt_commands(self) -> None: + """The message lists the exempt commands.""" with patch(f"{PKG}._echo") as mock_echo: _show_total_block_lock_message(ACTIVE_STATUS) output = " ".join(str(c) for c in mock_echo.call_args_list) @@ -143,6 +168,7 @@ class TestShowTotalBlockLockMessage: assert "enforce" in output def test_no_crash_without_until(self) -> None: + """A block without an end time does not crash the message.""" status = TotalBlockStatus( active=True, started_at=None, until=None, days=1, days_remaining=0.5 ) @@ -156,16 +182,21 @@ class TestShowTotalBlockLockMessage: class TestEnforceTotalBlockLock: + """_enforce_total_block_lock exits on blocked commands while active.""" + def test_not_active_passes(self) -> None: + """Without an active block every command passes.""" with patch(f"{PKG}.is_total_block_active", return_value=False): _enforce_total_block_lock("scan") # no exit def test_exempt_command_passes_while_active(self) -> None: + """Exempt commands pass during a block.""" with patch(f"{PKG}.is_total_block_active", return_value=True): _enforce_total_block_lock("status") # no exit _enforce_total_block_lock("enforce") # no exit def test_blocked_command_exits(self) -> None: + """A blocked command exits during a block.""" with ( patch(f"{PKG}.is_total_block_active", return_value=True), patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS), @@ -186,6 +217,7 @@ class TestEnforceTotalBlockLock: _enforce_total_block_lock("done") def test_add_exception_blocked_while_active(self) -> None: + """add-exception is blocked during a block.""" with ( patch(f"{PKG}.is_total_block_active", return_value=True), patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS), diff --git a/steam_backlog_enforcer/tests/test_main_status.py b/steam_backlog_enforcer/tests/test_main_status.py index 9ec9e99..43d7c62 100644 --- a/steam_backlog_enforcer/tests/test_main_status.py +++ b/steam_backlog_enforcer/tests/test_main_status.py @@ -25,6 +25,7 @@ class TestCmdStatus: """Tests for cmd_status.""" def test_with_game(self) -> None: + """Status renders with an assigned, installed game.""" state = State(current_app_id=440, current_game_name="TF2") with ( patch(f"{PKG}.is_store_blocked", return_value=True), @@ -35,6 +36,7 @@ class TestCmdStatus: cmd_status(Config(), state) def test_no_game(self) -> None: + """Status renders with no game assigned and nothing installed.""" with ( patch(f"{PKG}.is_store_blocked", return_value=False), patch(f"{PKG}.get_installed_games", return_value=[]), @@ -48,6 +50,7 @@ class TestCmdList: """Tests for cmd_list.""" def test_no_snapshot(self) -> None: + """Without a snapshot the list says so and hints at a scan.""" with ( patch("steam_backlog_enforcer._snapshot.load_snapshot", return_value=None), patch(f"{PKG}.report_completion", return_value=[]), @@ -58,6 +61,7 @@ class TestCmdList: assert any("No snapshot" in str(c) for c in mock_echo.call_args_list) def test_with_games(self) -> None: + """The list renders complete, partial and no-estimate games.""" snapshot = [ snap(1, "A", 10, 5, 20.0), snap(2, "B", 10, 10, 10.0), @@ -74,6 +78,7 @@ class TestCmdList: cmd_list(Config(), state) def test_many_games(self) -> None: + """A long backlog is cut off with a "more" line.""" snapshot = [snap(i, f"Game{i}") for i in range(60)] with ( patch( @@ -87,7 +92,10 @@ class TestCmdList: class TestCmdStatusLockHint: + """Status shows the manual pick lock only while it holds.""" + def test_shows_lock_hint_when_locked(self) -> None: + """A locked manual pick is shown in the status.""" state = locked_state() with ( patch(f"{PKG}.is_store_blocked", return_value=False), @@ -100,6 +108,7 @@ class TestCmdStatusLockHint: assert "MANUAL PICK LOCK" in output def test_no_lock_hint_when_not_locked(self) -> None: + """Without a lock the status shows no lock hint.""" with ( patch(f"{PKG}.is_store_blocked", return_value=False), patch(f"{PKG}.get_installed_games", return_value=[]), @@ -127,7 +136,10 @@ def _abandonable_state(app_id: int = 100, started_at: str = _RECENT_PICK) -> Sta class TestCmdStatusTotalBlock: + """Status shows the total gaming block while it is active.""" + def test_shows_total_block_when_active(self) -> None: + """An active block is shown with its time remaining.""" with ( patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS), patch(f"{PKG}.is_store_blocked", return_value=False), @@ -141,6 +153,7 @@ class TestCmdStatusTotalBlock: assert "Time remaining: 23h 2m" in output def test_no_total_block_section_when_inactive(self) -> None: + """An inactive block adds no block section.""" with ( patch(f"{PKG}.get_total_block_status", return_value=INACTIVE_STATUS), patch(f"{PKG}.is_store_blocked", return_value=False), @@ -153,6 +166,7 @@ class TestCmdStatusTotalBlock: assert "TOTAL GAMING BLOCK" not in output def test_active_without_until_skips_remaining_time(self) -> None: + """An active block with no end time omits the remaining time.""" status = TotalBlockStatus( active=True, started_at=None, until=None, days=1, days_remaining=0.5 ) @@ -178,6 +192,7 @@ class TestCmdStatusCompletionSweepIsNonFatal: """status stays usable when the completion sweep cannot run.""" def test_reports_failure_and_still_renders(self) -> None: + """A failed sweep is reported and the rest of the status still renders.""" with ( patch(f"{PKG}.report_completion", side_effect=OSError("state read-only")), patch(f"{PKG}.is_store_blocked", return_value=True), diff --git a/steam_backlog_enforcer/tests/test_main_status_two_picks.py b/steam_backlog_enforcer/tests/test_main_status_two_picks.py index 596a2fc..12fff4d 100644 --- a/steam_backlog_enforcer/tests/test_main_status_two_picks.py +++ b/steam_backlog_enforcer/tests/test_main_status_two_picks.py @@ -5,30 +5,18 @@ Split out of test_main_abandon.py to keep every test file under 250 lines. from __future__ import annotations -from unittest.mock import patch - from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.main import cmd_status +from steam_backlog_enforcer.tests._main_dispatch_world import status_stubs from steam_backlog_enforcer.tests._main_helpers import OLD_PICK, two_pick_state class TestStatusWithTwoPicks: + """status shows every concurrent manual pick with its age.""" + def test_status_lists_both_picks(self) -> None: - with ( - patch( - "steam_backlog_enforcer.main.status.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.main.status.get_installed_games", - return_value=[], - ), - patch( - "steam_backlog_enforcer.main.status.report_completion", - return_value=[], - ), - patch("steam_backlog_enforcer.main.status._echo") as mock_echo, - ): + """Two manual picks are both listed with how long ago they were picked.""" + with status_stubs(stub_completion=True) as mock_echo: cmd_status(Config(), two_pick_state()) output = " ".join(str(c) for c in mock_echo.call_args_list) assert "Manual picks (2)" in output @@ -36,24 +24,11 @@ class TestStatusWithTwoPicks: assert "day(s) ago" in output def test_status_shows_age_for_old_picks(self) -> None: + """Old picks show their age in days.""" state = two_pick_state() state.manual_picks[0]["started_at"] = OLD_PICK state.manual_picks[1]["started_at"] = OLD_PICK - with ( - patch( - "steam_backlog_enforcer.main.status.is_store_blocked", - return_value=False, - ), - patch( - "steam_backlog_enforcer.main.status.get_installed_games", - return_value=[], - ), - patch( - "steam_backlog_enforcer.main.status.report_completion", - return_value=[], - ), - patch("steam_backlog_enforcer.main.status._echo") as mock_echo, - ): + with status_stubs(stub_completion=True) as mock_echo: cmd_status(Config(), state) assert "picked 8.0 day(s) ago" in " ".join( str(c) for c in mock_echo.call_args_list diff --git a/steam_backlog_enforcer/tests/test_mcp.py b/steam_backlog_enforcer/tests/test_mcp.py index d59e57c..49fdf86 100644 --- a/steam_backlog_enforcer/tests/test_mcp.py +++ b/steam_backlog_enforcer/tests/test_mcp.py @@ -4,8 +4,9 @@ from __future__ import annotations from unittest.mock import patch -from steam_backlog_enforcer import _mcp_actions as mcp_actions from steam_backlog_enforcer import _mcp_query as mcp_query +from steam_backlog_enforcer._mcp_actions import _resolve_game_name +from steam_backlog_enforcer._mcp_query import _backlog_sort_key from steam_backlog_enforcer.steam_api import GameInfo @@ -21,7 +22,10 @@ def _game(app_id: int, hours: float) -> GameInfo: class TestReadTools: + """The read tools return the payload built from the loaded state.""" + def test_get_dataset(self) -> None: + """get_dataset builds the dataset from saved state and returns its payload.""" with ( patch.object(mcp_query, "State") as state, patch.object(mcp_query, "build_web_dataset", return_value="DS") as bwd, @@ -32,6 +36,7 @@ class TestReadTools: dtp.assert_called_once_with("DS") def test_get_status(self) -> None: + """get_status returns the status payload for the saved state.""" with ( patch.object(mcp_query, "State") as state, patch.object(mcp_query, "status_payload", return_value={"ok": 1}) as sp, @@ -40,6 +45,7 @@ class TestReadTools: sp.assert_called_once_with(state.load.return_value) def test_get_stats_subsets_dataset(self) -> None: + """get_stats keeps only the summary keys and drops the game list.""" payload = { "default_summary": {"qualifying": 3}, "pace_vs_hltb": None, @@ -55,7 +61,10 @@ class TestReadTools: class TestListBacklog: + """list_backlog ranks the unfinished snapshot games.""" + def test_no_snapshot_returns_note(self) -> None: + """A missing snapshot yields an empty list with an explanatory note.""" with patch.object(mcp_query, "load_snapshot", return_value=None): out = mcp_query.list_backlog() assert out["total"] == 0 @@ -63,6 +72,7 @@ class TestListBacklog: assert "note" in out def test_sorts_shortest_first_excludes_complete_and_caps(self) -> None: + """Complete games drop out, shortest go first, and the limit caps the list.""" snap = [ { "app_id": 440, @@ -103,6 +113,7 @@ class TestListBacklog: assert out["games"][0]["completion_pct"] == 10.0 def test_negative_limit_returns_none(self) -> None: + """A negative limit returns no games.""" snap = [ { "app_id": 70, @@ -118,24 +129,30 @@ class TestListBacklog: class TestResolveAndSort: + """Name lookup by app id and the backlog sort key.""" + def test_resolve_found(self) -> None: + """A snapshot game resolves to its name.""" with patch( "steam_backlog_enforcer._snapshot.load_snapshot", return_value=[{"app_id": 440, "name": "TF2"}], ): - assert mcp_actions._resolve_game_name(440) == "TF2" + assert _resolve_game_name(440) == "TF2" def test_resolve_missing(self) -> None: + """An app id absent from the snapshot resolves to None.""" with patch( "steam_backlog_enforcer._snapshot.load_snapshot", return_value=[{"app_id": 1, "name": "X"}], ): - assert mcp_actions._resolve_game_name(440) is None + assert _resolve_game_name(440) is None def test_resolve_no_snapshot(self) -> None: + """No snapshot at all resolves to None.""" with patch.object(mcp_query, "load_snapshot", return_value=None): - assert mcp_actions._resolve_game_name(440) is None + assert _resolve_game_name(440) is None def test_sort_key_branches(self) -> None: - assert mcp_query._backlog_sort_key(_game(1, 5.0)) == (0, 5.0) - assert mcp_query._backlog_sort_key(_game(2, -1)) == (1, 0.0) + """Known hours sort by value; unknown hours sort after them.""" + assert _backlog_sort_key(_game(1, 5.0)) == (0, 5.0) + assert _backlog_sort_key(_game(2, -1)) == (1, 0.0) diff --git a/steam_backlog_enforcer/tests/test_mcp_actions.py b/steam_backlog_enforcer/tests/test_mcp_actions.py index d4c8dc6..a42c428 100644 --- a/steam_backlog_enforcer/tests/test_mcp_actions.py +++ b/steam_backlog_enforcer/tests/test_mcp_actions.py @@ -11,13 +11,17 @@ from steam_backlog_enforcer.config import State class TestPickManualGate: + """The pick_manual tool previews first and applies only on confirm.""" + def test_not_found(self) -> None: + """A game missing from the snapshot is refused as not found.""" with patch.object(mcp_query, "load_snapshot", return_value=[]): out = mcp_actions.pick_manual(440) assert out["ok"] is False assert "not found" in out["reason"] def test_preview_does_not_mutate(self) -> None: + """Without confirm the tool only previews the pick.""" with ( patch( "steam_backlog_enforcer._snapshot.load_snapshot", @@ -31,6 +35,7 @@ class TestPickManualGate: amp.assert_not_called() def test_confirm_applies(self) -> None: + """With confirm the pick is applied with the configured pick cap.""" with ( patch( "steam_backlog_enforcer._snapshot.load_snapshot", @@ -67,12 +72,14 @@ class TestAbandonPickGate: ) def test_no_active_pick(self) -> None: + """With no manual pick there is nothing to abandon.""" with patch.object(mcp_query.State, "load", return_value=State()): out = mcp_actions.abandon_pick(440) assert out["ok"] is False assert "No manual pick" in out["reason"] def test_wrong_app_id(self) -> None: + """An app id that is not an active pick is refused.""" with patch.object(mcp_query.State, "load", return_value=self._state()): out = mcp_actions.abandon_pick(999) assert out["ok"] is False @@ -80,6 +87,7 @@ class TestAbandonPickGate: def test_old_pick_is_still_abandonable(self) -> None: # Past the deleted 4-day grace window, inside the 14-day lock. + """An 8-day-old pick can still be abandoned.""" state = self._state(days_ago=8) with ( patch.object(mcp_query.State, "load", return_value=state), @@ -89,6 +97,7 @@ class TestAbandonPickGate: assert out["ok"] is True def test_preview_does_not_mutate(self) -> None: + """Without confirm the tool previews the pick and its age.""" with ( patch.object(mcp_query.State, "load", return_value=self._state()), patch.object(mcp_actions, "abandon_manual_pick") as amp, @@ -100,6 +109,7 @@ class TestAbandonPickGate: amp.assert_not_called() def test_confirm_applies(self) -> None: + """With confirm the pick is abandoned in the loaded state.""" state = self._state() with ( patch.object(mcp_query.State, "load", return_value=state), @@ -111,6 +121,7 @@ class TestAbandonPickGate: amp.assert_called_once_with(state, 440) def test_refused_at_cap(self) -> None: + """A pick refused at the cap reports the refusal reason.""" with ( patch( "steam_backlog_enforcer._snapshot.load_snapshot", @@ -129,28 +140,35 @@ class TestAbandonPickGate: class TestBlockGamingGate: + """The block_gaming tool validates days and needs root to apply.""" + def test_invalid_days(self) -> None: + """Zero days is refused.""" out = mcp_actions.block_gaming(0) assert out["ok"] is False def test_preview(self) -> None: + """Without confirm the tool previews and says root is needed.""" out = mcp_actions.block_gaming(3) assert out["preview"] is True assert out["requires_root"] is True def test_confirm_success(self) -> None: + """A started block reports it was applied for the given days.""" with patch.object(mcp_actions, "start_total_block", return_value=True): out = mcp_actions.block_gaming(3, confirm=True) assert out["applied"] is True assert out["days"] == 3 def test_confirm_unprivileged_returns_gracefully(self) -> None: + """A block refused for privileges is reported, not raised.""" with patch.object(mcp_actions, "start_total_block", return_value=False): out = mcp_actions.block_gaming(3, confirm=True) assert out["ok"] is False assert "privileges" in out["reason"] def test_confirm_oserror_returns_gracefully(self) -> None: + """An OSError starting the block is reported as missing privileges.""" with patch.object( mcp_actions, "start_total_block", side_effect=OSError("boom") ): diff --git a/steam_backlog_enforcer/tests/test_mcp_gaming_time.py b/steam_backlog_enforcer/tests/test_mcp_gaming_time.py index 6aea096..b666a0d 100644 --- a/steam_backlog_enforcer/tests/test_mcp_gaming_time.py +++ b/steam_backlog_enforcer/tests/test_mcp_gaming_time.py @@ -12,7 +12,10 @@ from steam_backlog_enforcer._playtime_state import PlaytimeState class TestGetGamingTime: + """get_gaming_time reports the day's usage, budget and masked launchers.""" + def test_reports_unrecorded_state(self) -> None: + """With no recorded state it reports unrecorded and the default 8h budget.""" with ( patch.object(budget_view, "load_state", return_value=None), patch.object(budget_view, "mounted_targets", return_value=set()), @@ -22,6 +25,7 @@ class TestGetGamingTime: assert out["budget_seconds"] == 8 * 3600 def test_reports_usage(self) -> None: + """A recorded day reports its key, seconds used and seconds remaining.""" stored = PlaytimeState(day_key="2026-07-27", seconds=100.0) with ( patch.object(budget_view, "load_state", return_value=stored), @@ -35,6 +39,7 @@ class TestGetGamingTime: assert out["blocked"] is False def test_remaining_never_goes_negative(self) -> None: + """Usage beyond the budget reports zero remaining, never a negative.""" stored = PlaytimeState(day_key="d", seconds=10**9) with ( patch.object(budget_view, "load_state", return_value=stored), @@ -44,6 +49,7 @@ class TestGetGamingTime: assert out["seconds_remaining"] == 0.0 def test_lists_masked_launchers(self) -> None: + """A blocked day lists the launchers that are currently masked.""" stored = PlaytimeState(day_key="d", seconds=1.0, blocked_at=2.0) with ( patch.object(budget_view, "load_state", return_value=stored), @@ -67,7 +73,10 @@ class TestGetGamingTime: class TestResetGamingTime: + """reset_gaming_time previews by default and applies only as root.""" + def test_preview_by_default(self) -> None: + """Without confirm it only previews and releases nothing.""" with patch.object(mcp_gaming_time, "release_block") as mock_release: out = mcp_gaming_time.reset_gaming_time() mock_release.assert_not_called() @@ -76,6 +85,7 @@ class TestResetGamingTime: assert out["requires_root"] is True def test_confirm_unprivileged_returns_gracefully(self) -> None: + """Confirming without root returns a privileges failure and releases nothing.""" with ( patch.object(mcp_gaming_time.os, "geteuid", return_value=1000), patch.object(mcp_gaming_time, "release_block") as mock_release, @@ -86,6 +96,7 @@ class TestResetGamingTime: assert "privileges" in out["reason"] def test_confirm_applies_as_root(self) -> None: + """Confirming as root releases the block and saves fresh state.""" with ( patch.object(mcp_gaming_time.os, "geteuid", return_value=0), patch.object( @@ -99,6 +110,7 @@ class TestResetGamingTime: assert out["released"] == ["/usr/bin/steam"] def test_confirm_oserror_returns_gracefully(self) -> None: + """An OSError while releasing returns a privileges failure, not a raise.""" with ( patch.object(mcp_gaming_time.os, "geteuid", return_value=0), patch.object(mcp_gaming_time, "release_block", side_effect=OSError("boom")), @@ -109,6 +121,7 @@ class TestResetGamingTime: def test_main_runs_stdio_server() -> None: + """The MCP entry point runs the stdio server.""" with patch.object(mcp_server.mcp, "run") as run: _mcp.main() run.assert_called_once_with() diff --git a/steam_backlog_enforcer/tests/test_no_real_effects.py b/steam_backlog_enforcer/tests/test_no_real_effects.py index ffeab64..1c354dd 100644 --- a/steam_backlog_enforcer/tests/test_no_real_effects.py +++ b/steam_backlog_enforcer/tests/test_no_real_effects.py @@ -22,13 +22,13 @@ from steam_backlog_enforcer._enforce_demo import run_demo from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.tests import _no_real_effects as guard from steam_backlog_enforcer.tests._no_real_effects import ( - _REAL_POPEN, RealEffectError, _argv, _drives_steam, _fail_if_blocked, _is_ours, _parent_of, + _RealPopen, ) # Never a live pid: if the guard ever let it through, the kernel would only @@ -36,23 +36,25 @@ from steam_backlog_enforcer.tests._no_real_effects import ( _NO_SUCH_PID = int(Path("/proc/sys/kernel/pid_max").read_text(encoding="utf-8")) -@pytest.fixture -def blocked(request: pytest.FixtureRequest) -> list[str]: +@pytest.fixture(name="blocked") +def fixture_blocked(request: pytest.FixtureRequest) -> list[str]: """The autouse guard's record of blocked calls, for inspection.""" record: list[str] = request.getfixturevalue("_no_real_effects") return record -@pytest.fixture -def child() -> object: +@pytest.fixture(name="child") +def fixture_child() -> object: """A real, short-lived child of pytest in its own session.""" - proc = _REAL_POPEN(["sleep", "30"], start_new_session=True) - yield proc - proc.kill() - proc.wait() + with _RealPopen(["sleep", "30"], start_new_session=True) as proc: + yield proc + proc.kill() + proc.wait() class TestDrivesSteam: + """_drives_steam flags Steam, URL openers and kill tools.""" + @pytest.mark.parametrize( "argv", [ @@ -69,57 +71,76 @@ class TestDrivesSteam: ], ) def test_steam_and_kill_tools_are_denied(self, argv: list[str]) -> None: + """Steam, xdg-open, kill tools and steam:// argv are all flagged.""" assert _drives_steam(argv) def test_ordinary_commands_pass(self) -> None: + """An ordinary command mentioning steam in a flag is not flagged.""" assert not _drives_steam(["git", "status", "--steam"]) class TestArgv: + """_argv normalises every Popen args form to a list of strings.""" + def test_string_without_shell_is_one_program(self) -> None: + """A string without shell=True is one program name.""" assert _argv("steam -shutdown", via_shell=False) == ["steam -shutdown"] def test_string_with_shell_is_split(self) -> None: + """A string with shell=True is split like a shell would.""" assert _argv("steam -shutdown", via_shell=True) == ["steam", "-shutdown"] def test_bytes_and_paths_are_decoded(self) -> None: + """Bytes and Path arguments are decoded to strings.""" assert _argv([b"a", Path("/b"), "c"], via_shell=False) == ["a", "/b", "c"] assert _argv(Path("/usr/bin/steam"), via_shell=False) == ["/usr/bin/steam"] class TestIsOurs: + """_is_ours accepts only pytest and its descendants.""" + def test_pytest_itself(self) -> None: + """The pytest process itself is ours.""" assert _is_ours(os.getpid(), set()) @pytest.mark.parametrize("pid", [0, -1]) def test_group_and_broadcast_pids_never(self, pid: int) -> None: + """Group and broadcast pids are never ours.""" assert not _is_ours(pid, set()) def test_init_and_missing_pids_never(self) -> None: + """init and nonexistent pids are never ours.""" assert not _is_ours(1, set()) assert not _is_ours(_NO_SUCH_PID, set()) def test_descendants(self, child: subprocess.Popen[bytes]) -> None: + """A child of pytest is ours.""" assert _is_ours(child.pid, set()) def test_reparented_spawns_still_count(self) -> None: + """A spawned pid reparented to init still counts if recorded.""" with patch.object(guard, "_parent_of", return_value=1): assert _is_ours(4242, {4242}) assert not _is_ours(4242, set()) def test_a_cyclic_table_gives_up(self) -> None: + """A cyclic parent table ends the walk as not ours.""" with patch.object(guard, "_parent_of", return_value=4243): assert not _is_ours(4242, set()) class TestParentOf: + """_parent_of parses the ppid from /proc//stat.""" + def test_reads_ppid_after_last_paren(self, tmp_path: Path) -> None: + """The ppid is read after the last paren of the comm field.""" (tmp_path / "7").mkdir() (tmp_path / "7" / "stat").write_text("7 (a) b) S 3 7 7 0", encoding="utf-8") with patch.object(guard, "_REAL_PROC", tmp_path): assert _parent_of(7) == 3 def test_truncated_stat(self, tmp_path: Path) -> None: + """A truncated stat line yields None.""" (tmp_path / "7").mkdir() (tmp_path / "7" / "stat").write_text("7 (a) S", encoding="utf-8") with patch.object(guard, "_REAL_PROC", tmp_path): @@ -127,13 +148,17 @@ class TestParentOf: class TestGuardedKill: + """os.kill and os.killpg refuse foreign targets.""" + def test_foreign_signal_is_refused_and_recorded(self, blocked: list[str]) -> None: + """Signalling a foreign pid is refused and recorded.""" with pytest.raises(RealEffectError): os.kill(_NO_SUCH_PID, signal.SIGTERM) assert blocked == [f"os.kill(pid={_NO_SUCH_PID}, sig={signal.SIGTERM})"] blocked.clear() def test_broadcast_is_refused(self, blocked: list[str]) -> None: + """Broadcast kill and group-0 killpg are refused.""" with pytest.raises(RealEffectError): os.kill(-1, signal.SIGTERM) with pytest.raises(RealEffectError): @@ -142,6 +167,7 @@ class TestGuardedKill: blocked.clear() def test_probe_passes_through(self) -> None: + """Signal 0 probes reach the kernel untouched.""" with pytest.raises(ProcessLookupError): os.kill(_NO_SUCH_PID, 0) with pytest.raises(ProcessLookupError): @@ -150,27 +176,38 @@ class TestGuardedKill: def test_own_children_can_be_signalled( self, child: subprocess.Popen[bytes] ) -> None: + """Our own children can be signalled.""" os.kill(child.pid, signal.SIGSTOP) os.killpg(child.pid, signal.SIGKILL) assert child.wait(timeout=5) == -signal.SIGKILL class TestGuardedPopen: + """Popen refuses Steam spawns and runs everything else.""" + def test_steam_spawn_is_refused(self, blocked: list[str]) -> None: - with pytest.raises(RealEffectError): - _REAL_POPEN(["steam", "-shutdown"]) - with pytest.raises(RealEffectError): - _REAL_POPEN(["anything"], executable="/usr/bin/steam") + """A Steam argv or executable is refused and recorded.""" + with pytest.raises(RealEffectError), _RealPopen(["steam", "-shutdown"]): + pass + with ( + pytest.raises(RealEffectError), + _RealPopen(["anything"], executable="/usr/bin/steam"), + ): + pass assert len(blocked) == 2 blocked.clear() def test_ordinary_spawns_run(self) -> None: - proc = _REAL_POPEN([sys.executable, "-c", "print(1)"], stdout=subprocess.PIPE) - out, _ = proc.communicate(timeout=10) + """An ordinary program really runs.""" + argv = [sys.executable, "-c", "print(1)"] + with _RealPopen(argv, stdout=subprocess.PIPE) as proc: + out, _ = proc.communicate(timeout=10) assert out.strip() == b"1" class TestDemoTripwire: + """The unpatched demo loop trips the guard.""" + def test_unpatched_demo_loop_fails_at_once(self, blocked: list[str]) -> None: """The real loop never returns on its own: it must not even tick.""" with ( @@ -183,9 +220,13 @@ class TestDemoTripwire: class TestFailIfBlocked: + """_fail_if_blocked fails the test when anything was blocked.""" + def test_fails_naming_each_call(self) -> None: + """Each blocked call is named in the failure.""" with pytest.raises(pytest.fail.Exception, match="a; b"): _fail_if_blocked(["a", "b"]) def test_silent_when_clean(self) -> None: + """Nothing blocked means no failure.""" _fail_if_blocked([]) diff --git a/steam_backlog_enforcer/tests/test_own_pick.py b/steam_backlog_enforcer/tests/test_own_pick.py index 149b73a..884f48f 100644 --- a/steam_backlog_enforcer/tests/test_own_pick.py +++ b/steam_backlog_enforcer/tests/test_own_pick.py @@ -8,6 +8,7 @@ from unittest.mock import patch import pytest from steam_backlog_enforcer import _own_pick +from steam_backlog_enforcer._own_pick import _eligible, _label, _matches from steam_backlog_enforcer._prompter import use_prompter from steam_backlog_enforcer.config import State from steam_backlog_enforcer.steam_api import GameInfo @@ -20,6 +21,7 @@ _UNTIL = datetime(2099, 1, 2, 3, 4, tzinfo=UTC).isoformat() def _game( app_id: int, name: str = "G", *, unlocked: int = 5, hours: float = -1 ) -> GameInfo: + """Build an owned game with 10 achievements.""" return GameInfo( app_id=app_id, name=name, @@ -31,65 +33,87 @@ def _game( def _skipping(app_id: int) -> State: + """Build a state that skips ``app_id`` until a far-future date.""" return State(skipped_until={str(app_id): _UNTIL}) class TestIneligibleReason: + """Why a game cannot be picked as your own next game.""" + def _reason(self, game: GameInfo | None, state: State | None = None) -> str | None: + """Return the ineligible reason for app 7 under ``state``.""" state = state or State() return _own_pick.ineligible_reason(7, game, state, state.active_skipped_ids()) def test_no_scan_data(self) -> None: + """A game with no scan data is refused for missing achievements.""" assert self._reason(None) == "No achievement data" def test_complete_game(self) -> None: + """A 100% game is refused as already complete.""" assert self._reason(_game(7, unlocked=10)) == "Already 100% complete" def test_game_marked_finished_below_100(self) -> None: + """A game marked finished is refused even below 100%.""" state = State(finished_app_ids=[7]) assert self._reason(_game(7), state) == "Marked finished" def test_skipped_game_names_the_date(self) -> None: + """A skipped game names the day its skip ends.""" assert self._reason(_game(7), _skipping(7)) == "Skipped until 2099-01-02" def test_skipped_game_without_a_date(self) -> None: + """A skip with no stored end date reads as skipped for now.""" reason = _own_pick.ineligible_reason(7, _game(7), State(), {7}) assert reason == "Skipped for now" def test_unfinished_game_is_eligible(self) -> None: + """An unfinished, unskipped game has no reason against it.""" assert self._reason(_game(7)) is None class TestMatching: + """Finding owned games by a typed query.""" + def test_digits_match_the_exact_app_id(self) -> None: + """A numeric query matches only the exact app id.""" games = [_game(10, "Ten"), _game(100, "Hundred")] - assert [g.app_id for g in _own_pick._matches("10", games)] == [10] + assert [g.app_id for g in _matches("10", games)] == [10] def test_names_match_by_substring_prefix_first(self) -> None: + """Name matches are case-insensitive substrings, prefix hits first.""" games = [_game(1, "The Doom"), _game(2, "Doom Eternal"), _game(3, "Quake")] - hits = _own_pick._matches("doom", games) + hits = _matches("doom", games) assert [g.name for g in hits] == ["Doom Eternal", "The Doom"] def test_eligible_drops_complete_and_cooling_down_games(self) -> None: + """Complete and skipped games are filtered out.""" games = [_game(1), _game(2, unlocked=10), _game(3)] - assert [g.app_id for g in _own_pick._eligible(games, _skipping(3))] == [1] + assert [g.app_id for g in _eligible(games, _skipping(3))] == [1] class TestLabel: + """How a game is shown as a choice.""" + def test_with_hours(self) -> None: - option = _own_pick._label(_game(5, "Five", hours=2.5)) + """The label shows name and app id; the detail adds hours when known.""" + option = _label(_game(5, "Five", hours=2.5)) assert option.value == "5" assert option.label == "Five (AppID=5)" assert option.detail == "50% achievements, ~2.5h" def test_without_hours(self) -> None: - assert _own_pick._label(_game(5)).detail == "50% achievements" + """Without an hours estimate the detail shows progress only.""" + assert _label(_game(5)).detail == "50% achievements" class TestPromptOwnPickWithTextSearch: """The CLI path: no library browser, a typed search.""" + said: list[str] + def _run(self, prompter: FakePrompter, games: list[GameInfo]) -> GameInfo | None: + """Run the prompt, record what it echoed and return the pick.""" with use_prompter(prompter), patch(f"{_PKG}._echo") as echo: self.said = [c.args[0] for c in echo.call_args_list] result = _own_pick.prompt_own_pick(games, State()) @@ -97,36 +121,43 @@ class TestPromptOwnPickWithTextSearch: return result def test_empty_search_goes_back(self) -> None: + """An empty search returns no pick.""" assert self._run(FakePrompter(texts=[""]), [_game(1)]) is None def test_a_single_hit_is_taken_without_asking(self) -> None: + """One match is returned after just the search prompt.""" prompter = FakePrompter(texts=["quake"]) games = [_game(1, "Quake"), _game(2, "Doom")] assert self._run(prompter, games) is games[0] assert len(prompter.asked) == 1 def test_no_hits_searches_again(self) -> None: + """No match says so and searches again.""" prompter = FakePrompter(texts=["zzz", ""]) assert self._run(prompter, [_game(1, "Quake")]) is None assert self.said == ["No unfinished owned game matches 'zzz'."] def test_too_many_hits_asks_to_narrow(self) -> None: + """More than 15 matches asks for a longer query.""" games = [_game(i, f"Game {i}") for i in range(1, 17)] assert self._run(FakePrompter(texts=["game", ""]), games) is None assert self.said == ["16 games match 'game'; type more of the name."] def test_several_hits_ask_which_one(self) -> None: + """Several matches are offered with a search-again option.""" games = [_game(1, "Doom"), _game(2, "Doom 2")] prompter = FakePrompter(texts=["doom"], choices=["2"]) assert self._run(prompter, games) is games[1] assert prompter.asked[1][1] == ["1", "2", "search-again"] def test_search_again_restarts_the_search(self) -> None: + """Choosing search-again goes back to the search prompt.""" games = [_game(1, "Doom"), _game(2, "Doom 2")] prompter = FakePrompter(texts=["doom", ""], choices=["search-again"]) assert self._run(prompter, games) is None def test_finished_games_are_not_offered(self) -> None: + """A query matching only finished games finds nothing.""" prompter = FakePrompter(texts=["done", ""]) assert self._run(prompter, [_game(1, "Done", unlocked=10)]) is None assert self.said @@ -136,6 +167,7 @@ class TestPromptOwnPickWithLibraryPicker: """The web path: the whole eligible library is shown at once.""" def test_offers_only_eligible_games_and_returns_the_pick(self) -> None: + """The picker sees only eligible games and its pick is returned.""" games = [_game(1), _game(2, unlocked=10), _game(3)] picker = FakePicker(picks=[3]) with use_prompter(picker): @@ -144,12 +176,14 @@ class TestPromptOwnPickWithLibraryPicker: @pytest.mark.parametrize("pick", [None, 2]) def test_no_pick_or_an_ineligible_one_goes_back(self, pick: int | None) -> None: + """No pick, or a pick of an ineligible game, returns nothing.""" games = [_game(1), _game(2, unlocked=10)] with use_prompter(FakePicker(picks=[pick])): assert _own_pick.prompt_own_pick(games, State()) is None def test_skip_expiry_is_judged_now() -> None: + """An expired skip no longer makes the game ineligible.""" expired = (datetime.now(UTC) - timedelta(days=1)).isoformat() state = State(skipped_until={"1": expired}) - assert _own_pick._eligible([_game(1)], state)[0].app_id == 1 + assert _eligible([_game(1)], state)[0].app_id == 1 diff --git a/steam_backlog_enforcer/tests/test_owned_apps_cache.py b/steam_backlog_enforcer/tests/test_owned_apps_cache.py index b710df0..e5df16a 100644 --- a/steam_backlog_enforcer/tests/test_owned_apps_cache.py +++ b/steam_backlog_enforcer/tests/test_owned_apps_cache.py @@ -3,15 +3,22 @@ from __future__ import annotations import json -from typing import Any +from typing import TYPE_CHECKING, Any from unittest.mock import MagicMock, patch import pytest from steam_backlog_enforcer import _owned_apps_cache as cache_mod +from steam_backlog_enforcer._owned_apps_cache import ( + _owned_record, + _save_owned_app_ids_cache, +) from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.steam_api import SteamAPIError +if TYPE_CHECKING: + from pathlib import Path + _PKG = "steam_backlog_enforcer._owned_apps_cache" _FULL = { "appid": 10, @@ -22,8 +29,16 @@ _FULL = { } -def _write(payload: object) -> None: - cache_mod._OWNED_IDS_CACHE_FILE.write_text(json.dumps(payload), encoding="utf-8") +@pytest.fixture(name="cache_file") +def fixture_cache_file(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path: + """Point the module's cache path at a file this test can see and write.""" + path = tmp_path / "owned_app_ids_cache.json" + monkeypatch.setattr(cache_mod, "_OWNED_IDS_CACHE_FILE", path) + return path + + +def _write(cache_file: Path, payload: object) -> None: + cache_file.write_text(json.dumps(payload), encoding="utf-8") def _configured() -> Config: @@ -34,7 +49,8 @@ class TestOwnedRecord: """The slice of a GetOwnedGames entry the library shows.""" def test_maps_every_field(self) -> None: - assert cache_mod._owned_record(_FULL) == { + """Every GetOwnedGames field maps onto its record key.""" + assert _owned_record(_FULL) == { "app_id": 10, "name": "Half-Life", "playtime_minutes": 120, @@ -43,7 +59,8 @@ class TestOwnedRecord: } def test_missing_fields_default_to_empty(self) -> None: - assert cache_mod._owned_record({"appid": "7"}) == { + """Missing fields become empty defaults and the id becomes an int.""" + assert _owned_record({"appid": "7"}) == { "app_id": 7, "name": "", "playtime_minutes": 0, @@ -55,17 +72,17 @@ class TestOwnedRecord: class TestSaveRecords: """_save_owned_app_ids_cache stores records only when given games.""" - def test_games_become_records(self) -> None: - cache_mod._save_owned_app_ids_cache("sid", [10], [_FULL, {"name": "no id"}]) - data: dict[str, Any] = json.loads( - cache_mod._OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8") - ) + def test_games_become_records(self, cache_file: Path) -> None: + """Games with an id are stored as records next to the ids.""" + _save_owned_app_ids_cache("sid", [10], [_FULL, {"name": "no id"}]) + data: dict[str, Any] = json.loads(cache_file.read_text(encoding="utf-8")) assert data["app_ids"] == [10] assert [g["app_id"] for g in data["games"]] == [10] - def test_without_games_there_are_no_records(self) -> None: - cache_mod._save_owned_app_ids_cache("sid", [10]) - data = json.loads(cache_mod._OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")) + def test_without_games_there_are_no_records(self, cache_file: Path) -> None: + """Saving ids only writes no games key.""" + _save_owned_app_ids_cache("sid", [10]) + data = json.loads(cache_file.read_text(encoding="utf-8")) assert "games" not in data @@ -73,10 +90,12 @@ class TestOwnedCacheStamp: """The stamp changes whenever the file is rewritten.""" def test_absent_file_is_zero(self) -> None: + """No cache file stamps as (0, 0).""" assert cache_mod.owned_cache_stamp() == (0, 0) - def test_present_file_reports_mtime_and_size(self) -> None: - _write({"steam_id": "sid"}) + def test_present_file_reports_mtime_and_size(self, cache_file: Path) -> None: + """An existing file stamps as its mtime and byte size.""" + _write(cache_file, {"steam_id": "sid"}) mtime_ns, size = cache_mod.owned_cache_stamp() assert mtime_ns > 0 assert size == len(json.dumps({"steam_id": "sid"})) @@ -85,9 +104,10 @@ class TestOwnedCacheStamp: class TestLoadOwnedRecords: """load_owned_records ignores the TTL but not the account.""" - def test_returns_stored_records(self) -> None: + def test_returns_stored_records(self, cache_file: Path) -> None: + """Records for the same account come back regardless of age.""" records = [{"app_id": 10}] - _write({"steam_id": "sid", "fetched_at": 0, "games": records}) + _write(cache_file, {"steam_id": "sid", "fetched_at": 0, "games": records}) assert cache_mod.load_owned_records("sid") == records @pytest.mark.parametrize( @@ -99,15 +119,18 @@ class TestLoadOwnedRecords: ["not", "a", "dict"], ], ) - def test_unusable_cache_is_none(self, payload: object) -> None: - _write(payload) + def test_unusable_cache_is_none(self, cache_file: Path, payload: object) -> None: + """Another account, missing or malformed games, or a non-dict give None.""" + _write(cache_file, payload) assert cache_mod.load_owned_records("sid") is None def test_missing_file_is_none(self) -> None: + """No cache file gives None.""" assert cache_mod.load_owned_records("sid") is None - def test_corrupt_file_is_none(self) -> None: - cache_mod._OWNED_IDS_CACHE_FILE.write_text("{nope", encoding="utf-8") + def test_corrupt_file_is_none(self, cache_file: Path) -> None: + """Invalid JSON gives None.""" + cache_file.write_text("{nope", encoding="utf-8") assert cache_mod.load_owned_records("sid") is None @@ -118,11 +141,13 @@ class TestRefreshOwnedRecords: "config", [Config(steam_id="sid"), Config(steam_api_key="k")] ) def test_unconfigured_never_asks_steam(self, config: Config) -> None: + """Without a key and an id, Steam is never called.""" with patch(f"{_PKG}.SteamAPIClient") as client: assert cache_mod.refresh_owned_records(config) is None client.assert_not_called() def test_fetches_caches_and_returns_records(self) -> None: + """Fetched games are cached and returned as records.""" client = MagicMock() client.get_owned_games.return_value = [_FULL, {"name": "no id"}] with patch(f"{_PKG}.SteamAPIClient", return_value=client) as ctor: @@ -134,6 +159,7 @@ class TestRefreshOwnedRecords: @pytest.mark.parametrize("error", [OSError("down"), SteamAPIError("bad key")]) def test_steam_failure_is_none(self, error: Exception) -> None: + """A network or API error gives None and writes nothing.""" with patch(f"{_PKG}.SteamAPIClient", side_effect=error): assert cache_mod.refresh_owned_records(_configured()) is None assert cache_mod.owned_cache_stamp() == (0, 0) @@ -143,6 +169,7 @@ class TestGetAllOwnedAppIdsCached: """The cached branch merges cached ids with the snapshot, de-duplicated.""" def test_cache_hit_skips_steam(self) -> None: + """Cached ids are merged with the snapshot without calling Steam.""" with ( patch(f"{_PKG}._load_owned_app_ids_cache", return_value=[1, 2]), patch(f"{_PKG}.load_snapshot", return_value=[{"app_id": 2}, {"app_id": 3}]), @@ -152,6 +179,7 @@ class TestGetAllOwnedAppIdsCached: client.assert_not_called() def test_api_ids_are_merged_without_duplicates(self) -> None: + """On a cache miss, API ids and snapshot ids merge without duplicates.""" client = MagicMock() client.get_owned_games.return_value = [{"appid": 1}, {"appid": 2}] with ( diff --git a/steam_backlog_enforcer/tests/test_owned_ids_cache_helpers.py b/steam_backlog_enforcer/tests/test_owned_ids_cache_helpers.py new file mode 100644 index 0000000..69256c5 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_owned_ids_cache_helpers.py @@ -0,0 +1,118 @@ +"""Tests for the owned app IDs cache load/save helpers. + +Split out of test_enforce_loop.py to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +import json +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer._owned_apps_cache import ( + _load_owned_app_ids_cache, + _save_owned_app_ids_cache, +) + +if TYPE_CHECKING: + from pathlib import Path + +# The owned-ids cache reads its own clock. +_CLOCK = "steam_backlog_enforcer._owned_apps_cache.time.time" +OWNED_APPS_CACHE_PKG = "steam_backlog_enforcer._owned_apps_cache" + + +class TestOwnedIdsCacheHelpers: + """Tests for owned app IDs cache helper functions.""" + + def test_load_cache_no_steam_id(self, tmp_path: Path) -> None: + """No Steam id means no cache hit.""" + 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: + """A missing cache file means no cache hit.""" + 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: + """A corrupt cache file means no cache hit.""" + 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: + """A cache for another account is ignored.""" + 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: + """A cache older than the TTL is ignored.""" + 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: + """A cache whose app_ids is not a list is ignored.""" + 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: + """A fresh matching cache returns its ids as ints.""" + 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: + """Saving writes the steam id and ids atomically as indented JSON.""" + 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 diff --git a/steam_backlog_enforcer/tests/test_pick_completion.py b/steam_backlog_enforcer/tests/test_pick_completion.py index 143f4ec..192d44a 100644 --- a/steam_backlog_enforcer/tests/test_pick_completion.py +++ b/steam_backlog_enforcer/tests/test_pick_completion.py @@ -1,99 +1,64 @@ """Tests for achievement-based release of manual picks.""" -from datetime import UTC, datetime -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from steam_backlog_enforcer._actions import allowed_app_ids from steam_backlog_enforcer._pick_completion import ( - MANUAL_PICK_RECHECK_TTL_SECONDS, PickProgress, - _DaemonSweep, - daemon_sweep, - report_completion, retire_completed_manual_picks, - retire_completed_manual_picks_throttled, - warn_stale_assignment, ) from steam_backlog_enforcer.config import Config, State -from steam_backlog_enforcer.steam_api import AchievementInfo, GameInfo from steam_backlog_enforcer.tests._main_helpers import locked_state, two_pick_state +from steam_backlog_enforcer.tests._pick_completion_helpers import ( + client_returning, + game, +) PKG = "steam_backlog_enforcer._pick_completion" -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 - - class TestPickProgressDescribe: + """describe() renders each pick state as one status line.""" + def test_undeterminable(self) -> None: + """A pick with unreadable achievements says progress is unavailable.""" line = PickProgress(1, "G", 0, 0, retired=False, determinable=False).describe() assert "progress unavailable" in line def test_retired_at_100(self) -> None: + """A pick retired at 100% says complete and still playable.""" line = PickProgress(1, "G", 5, 5, retired=True, determinable=True).describe() assert "COMPLETE, slot freed, stays playable" in line def test_released_by_new_achievement(self) -> None: + """A pick released by a new unlock names that achievement.""" line = PickProgress( 1, "G", 2, 5, retired=True, determinable=True, new_achievement="Win" ).describe() assert "2/5 (40%) - earned 'Win', slot freed, stays playable" in line def test_in_progress(self) -> None: + """An unreleased pick shows its ratio and waits for an unlock.""" line = PickProgress(1, "G", 1, 4, retired=False, determinable=True).describe() assert "1/4 (25%) - no new achievement since it was picked yet" in line def test_in_progress_with_zero_total(self) -> None: + """A determinable pick with zero achievements shows 0% safely.""" # Defensive: determinable but no achievements must not divide by zero. line = PickProgress(1, "G", 0, 0, retired=False, determinable=True).describe() assert "(0%)" in line class TestRetireCompletedManualPicks: + """Manual picks are released on completion or a new unlock only.""" + def test_no_picks_makes_no_api_call(self) -> None: + """No manual picks means no Steam query.""" fake = client_returning() assert retire_completed_manual_picks(Config(), State(), client=fake) == [] fake.refresh_single_game.assert_not_called() def test_complete_pick_is_retired_and_saved(self) -> None: + """A 100% pick is finished, retired and saved.""" state = locked_state(app_id=100) with patch.object(State, "save") as mock_save: results = retire_completed_manual_picks( @@ -104,6 +69,7 @@ class TestRetireCompletedManualPicks: mock_save.assert_called_once() def test_new_achievement_releases_without_finishing(self) -> None: + """A new unlock releases the slot but does not finish the game.""" state = locked_state(app_id=100) with patch.object(State, "save") as mock_save: results = retire_completed_manual_picks( @@ -116,6 +82,7 @@ class TestRetireCompletedManualPicks: mock_save.assert_called_once() def test_incomplete_pick_is_left_alone(self) -> None: + """A pick without a new unlock stays locked and unsaved.""" state = locked_state(app_id=100) with patch.object(State, "save") as mock_save: results = retire_completed_manual_picks( @@ -126,6 +93,7 @@ class TestRetireCompletedManualPicks: mock_save.assert_not_called() def test_unreadable_pick_stays_locked(self) -> None: + """A pick whose progress cannot be read stays locked.""" # No achievements at all, or Steam unreachable: both surface as None. state = locked_state(app_id=100) with patch.object(State, "save") as mock_save: @@ -139,6 +107,7 @@ class TestRetireCompletedManualPicks: mock_save.assert_not_called() def test_reports_every_pick_not_only_retirements(self) -> None: + """Every pick gets a result, retired or not.""" state = two_pick_state() with patch.object(State, "save"): results = retire_completed_manual_picks( @@ -149,12 +118,14 @@ class TestRetireCompletedManualPicks: assert [(r.app_id, r.retired) for r in results] == [(100, False), (200, True)] def test_pick_without_app_id_is_skipped(self) -> None: + """A malformed pick with no app id is skipped without a query.""" state = State(manual_picks=[{"game_name": "Broken", "started_at": ""}]) fake = client_returning() assert retire_completed_manual_picks(Config(), state, client=fake) == [] fake.refresh_single_game.assert_not_called() def test_unnamed_pick_falls_back_to_app_id(self) -> None: + """A pick with no name is labelled by its app id.""" state = State(manual_picks=[{"app_id": 100, "game_name": "", "started_at": ""}]) results = retire_completed_manual_picks( Config(), state, client=client_returning(game(100, 1, 5)) @@ -162,6 +133,7 @@ class TestRetireCompletedManualPicks: assert results[0].game_name == "AppID=100" def test_builds_its_own_client_when_none_given(self) -> None: + """Without a client one is built from the config credentials.""" state = locked_state(app_id=100) fake = client_returning(game(100, 1, 5)) with patch(f"{PKG}.SteamAPIClient", return_value=fake) as mock_cls: @@ -169,74 +141,3 @@ class TestRetireCompletedManualPicks: Config(steam_api_key="k", steam_id="i"), state ) mock_cls.assert_called_once_with("k", "i") - - -class TestReportCompletion: - def test_prints_nothing_without_picks(self) -> None: - with patch(f"{PKG}._echo") as mock_echo: - assert report_completion(Config(), State()) == [] - mock_echo.assert_not_called() - - def test_prints_each_pick_and_returns_only_retirements(self) -> None: - state = two_pick_state() - with ( - patch.object(State, "save"), - patch( - f"{PKG}.SteamAPIClient", - return_value=client_returning(game(100, 1, 5), game(200, 5, 5)), - ), - patch(f"{PKG}._echo") as mock_echo, - ): - retired = report_completion(Config(), state) - assert [r.app_id for r in retired] == [200] - output = " ".join(str(c) for c in mock_echo.call_args_list) - assert "no new achievement" in output - assert "COMPLETE, slot freed" in output - - -class TestWarnStaleAssignment: - def test_warns_when_retired_pick_is_current(self) -> None: - state = State(current_app_id=200, current_game_name="SecondGame") - retired = [ - PickProgress(200, "SecondGame", 5, 5, retired=True, determinable=True) - ] - with patch(f"{PKG}._echo") as mock_echo: - warn_stale_assignment(state, retired) - assert "still" in " ".join(str(c) for c in mock_echo.call_args_list) - - def test_silent_when_current_is_something_else(self) -> None: - state = State(current_app_id=999) - retired = [PickProgress(200, "G", 5, 5, retired=True, determinable=True)] - with patch(f"{PKG}._echo") as mock_echo: - warn_stale_assignment(state, retired) - mock_echo.assert_not_called() - - -class TestDaemonSweep: - def test_due_then_throttled(self) -> None: - sweep = _DaemonSweep() - assert sweep.due(1000.0, 900.0) is True - assert sweep.due(1100.0, 900.0) is False - assert sweep.due(2000.0, 900.0) is True - - def test_throttled_call_releases_without_evicting(self) -> None: - state = locked_state(app_id=100) - daemon_sweep.last = None - try: - with ( - patch.object(State, "save"), - patch( - f"{PKG}.SteamAPIClient", - return_value=client_returning(game(100, 5, 5)), - ), - ): - results = retire_completed_manual_picks_throttled(Config(), state) - assert [r.retired for r in results] == [True] - assert 100 in allowed_app_ids(state) - # Second call inside the TTL must not re-query Steam. - assert retire_completed_manual_picks_throttled(Config(), state) == [] - finally: - daemon_sweep.last = None - - def test_ttl_is_well_clear_of_the_three_second_loop(self) -> None: - assert MANUAL_PICK_RECHECK_TTL_SECONDS >= 300 diff --git a/steam_backlog_enforcer/tests/test_pick_completion_report.py b/steam_backlog_enforcer/tests/test_pick_completion_report.py new file mode 100644 index 0000000..c1554fb --- /dev/null +++ b/steam_backlog_enforcer/tests/test_pick_completion_report.py @@ -0,0 +1,109 @@ +"""Tests for reporting, stale-assignment warnings and the daemon pick sweep. + +Split out of test_pick_completion.py to keep both files under the 250-line cap. +""" + +from unittest.mock import patch + +from steam_backlog_enforcer._actions import allowed_app_ids +from steam_backlog_enforcer._pick_completion import ( + MANUAL_PICK_RECHECK_TTL_SECONDS, + PickProgress, + _DaemonSweep, + daemon_sweep, + report_completion, + retire_completed_manual_picks_throttled, + warn_stale_assignment, +) +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.tests._main_helpers import locked_state, two_pick_state +from steam_backlog_enforcer.tests._pick_completion_helpers import ( + client_returning, + game, +) + +PKG = "steam_backlog_enforcer._pick_completion" + + +class TestReportCompletion: + """report_completion prints every pick and returns retirements.""" + + def test_prints_nothing_without_picks(self) -> None: + """No picks means no output.""" + with patch(f"{PKG}._echo") as mock_echo: + assert report_completion(Config(), State()) == [] + mock_echo.assert_not_called() + + def test_prints_each_pick_and_returns_only_retirements(self) -> None: + """Each pick is printed; only retired ones are returned.""" + state = two_pick_state() + with ( + patch.object(State, "save"), + patch( + f"{PKG}.SteamAPIClient", + return_value=client_returning(game(100, 1, 5), game(200, 5, 5)), + ), + patch(f"{PKG}._echo") as mock_echo, + ): + retired = report_completion(Config(), state) + assert [r.app_id for r in retired] == [200] + output = " ".join(str(c) for c in mock_echo.call_args_list) + assert "no new achievement" in output + assert "COMPLETE, slot freed" in output + + +class TestWarnStaleAssignment: + """A retired pick still set as the current game is flagged.""" + + def test_warns_when_retired_pick_is_current(self) -> None: + """Retiring the current game prints a warning.""" + state = State(current_app_id=200, current_game_name="SecondGame") + retired = [ + PickProgress(200, "SecondGame", 5, 5, retired=True, determinable=True) + ] + with patch(f"{PKG}._echo") as mock_echo: + warn_stale_assignment(state, retired) + assert "still" in " ".join(str(c) for c in mock_echo.call_args_list) + + def test_silent_when_current_is_something_else(self) -> None: + """Retiring another game prints nothing.""" + state = State(current_app_id=999) + retired = [PickProgress(200, "G", 5, 5, retired=True, determinable=True)] + with patch(f"{PKG}._echo") as mock_echo: + warn_stale_assignment(state, retired) + mock_echo.assert_not_called() + + +class TestDaemonSweep: + """The daemon's pick sweep runs at most once per TTL.""" + + def test_due_then_throttled(self) -> None: + """The sweep is due, then throttled, then due again after the TTL.""" + sweep = _DaemonSweep() + assert sweep.due(1000.0, 900.0) is True + assert sweep.due(1100.0, 900.0) is False + assert sweep.due(2000.0, 900.0) is True + + def test_throttled_call_releases_without_evicting(self) -> None: + """A throttled sweep releases once and skips Steam within the TTL.""" + state = locked_state(app_id=100) + daemon_sweep.last = None + try: + with ( + patch.object(State, "save"), + patch( + f"{PKG}.SteamAPIClient", + return_value=client_returning(game(100, 5, 5)), + ), + ): + results = retire_completed_manual_picks_throttled(Config(), state) + assert [r.retired for r in results] == [True] + assert 100 in allowed_app_ids(state) + # Second call inside the TTL must not re-query Steam. + assert retire_completed_manual_picks_throttled(Config(), state) == [] + finally: + daemon_sweep.last = None + + def test_ttl_is_well_clear_of_the_three_second_loop(self) -> None: + """The recheck TTL is at least five minutes.""" + assert MANUAL_PICK_RECHECK_TTL_SECONDS >= 300 diff --git a/steam_backlog_enforcer/tests/test_pick_keep.py b/steam_backlog_enforcer/tests/test_pick_keep.py index 2a520f6..b70ebc5 100644 --- a/steam_backlog_enforcer/tests/test_pick_keep.py +++ b/steam_backlog_enforcer/tests/test_pick_keep.py @@ -101,7 +101,10 @@ def _pick( class TestKeep: + """pick --keep adds the new game and keeps the old one allowed.""" + def test_old_game_stays_allowed_and_is_not_uninstalled(self) -> None: + """The old game stays allowed and installed; the new one is installed.""" state = _state_on() effects = _pick(state, FakePrompter(choices=["0"]), keep=True) assert state.current_app_id == 2 @@ -110,6 +113,7 @@ class TestKeep: effects.install.assert_called_once_with(2, "New", "i", use_steam_protocol=True) def test_kept_game_is_a_released_pick_holding_no_lock(self) -> None: + """The kept game becomes a released pick that holds no lock.""" state = _state_on() _pick(state, FakePrompter(choices=["0"]), keep=True) [kept] = state.manual_picks @@ -122,18 +126,21 @@ class TestKeep: assert state.released_at == {} def test_allowed_games_are_not_offered(self) -> None: + """Games already allowed are not offered as candidates.""" state = _state_on() prompter = FakePrompter(choices=["0"]) _pick(state, prompter, keep=True) assert prompter.asked[0][1] == ["0", "own"] def test_own_pick_search_excludes_allowed_games(self) -> None: + """The own-pick search leaves out allowed games.""" state = _state_on() with patch(f"{_ASSIGN}.prompt_own_pick", return_value=NEW) as own: _pick(state, FakePrompter(choices=["own"]), keep=True) assert [g.app_id for g in own.call_args.args[0]] == [2] def test_pick_manual_then_pick_does_not_duplicate_the_active_pick(self) -> None: + """An active manual pick is not copied into a second entry.""" state = _state_on( manual_picks=[{"app_id": 1, "game_name": "Old", "started_at": STARTED_AT}] ) @@ -145,12 +152,14 @@ class TestKeep: assert allowed_app_ids(state) == {1, 2} def test_released_entry_for_the_current_game_is_not_duplicated(self) -> None: + """A released entry for the current game is not added again.""" entry = {"app_id": 1, "game_name": "Old", "started_at": STARTED_AT} state = _state_on(manual_picks=[entry | _RELEASED]) _pick(state, FakePrompter(choices=["0"]), keep=True) assert [p["app_id"] for p in state.manual_picks] == [1] def test_earlier_kept_games_survive_another_keep(self) -> None: + """Games kept earlier stay allowed through another keep.""" entry = {"app_id": 9, "game_name": "Kept", "started_at": STARTED_AT} state = _state_on(manual_picks=[entry | _RELEASED]) effects = _pick(state, FakePrompter(choices=["0"]), keep=True) @@ -158,6 +167,7 @@ class TestKeep: effects.uninstall.assert_called_once_with({1, 2, 9}) def test_choosing_an_allowed_game_changes_nothing(self) -> None: + """Choosing a game that is already allowed is a no-op.""" state = _state_on() with _effects() as effects: _assign_chosen_game(OLD, [OLD, NEW], state, _config(), keep=True) @@ -167,7 +177,10 @@ class TestKeep: class TestReplace: + """Without --keep, a pick replaces every allowed game.""" + def test_replace_drops_old_and_released_games(self) -> None: + """Replacing leaves only the new game allowed.""" entry = {"app_id": 9, "game_name": "Kept", "started_at": STARTED_AT} state = _state_on(manual_picks=[entry | _RELEASED]) effects = _pick(state, FakePrompter(choices=["0"])) diff --git a/steam_backlog_enforcer/tests/test_pick_manual_keep.py b/steam_backlog_enforcer/tests/test_pick_manual_keep.py index 7ec76d3..7fcd135 100644 --- a/steam_backlog_enforcer/tests/test_pick_manual_keep.py +++ b/steam_backlog_enforcer/tests/test_pick_manual_keep.py @@ -46,7 +46,10 @@ def _entries(state: State, app_id: int) -> list[dict[str, object]]: class TestApplyManualPickKeeps: + """apply_manual_pick keeps earlier picks allowed within the cap.""" + def test_kept_games_and_outgoing_assignment_stay_allowed(self) -> None: + """Kept picks and the outgoing game stay allowed; the new pick is active.""" state = _state((2, "KeptA")) with patch.object(State, "save"): assert apply_manual_pick(state, 5, "New", max_picks=2) is None @@ -55,6 +58,7 @@ class TestApplyManualPickKeeps: assert state.current_app_id == 5 def test_over_cap_is_refused_naming_the_games(self) -> None: + """A pick over the cap is refused naming every allowed game.""" state = _state((2, "KeptA"), (3, "KeptB")) with patch.object(State, "save") as save: refused = apply_manual_pick(state, 5, "New", max_picks=2) @@ -68,6 +72,7 @@ class TestApplyManualPickKeeps: assert [p["app_id"] for p in state.manual_picks] == [2, 3] def test_repicking_a_kept_game_activates_it_without_duplicating(self) -> None: + """Re-picking a kept game at the cap activates it without a duplicate.""" # At the cap, but re-picking a game already allowed adds nothing. state = _state((2, "KeptA"), (3, "KeptB")) with patch.object(State, "save"): @@ -78,6 +83,7 @@ class TestApplyManualPickKeeps: assert allowed_app_ids(state) == {1, 2, 3} def test_repicking_the_kept_current_game_does_not_duplicate(self) -> None: + """Re-picking the current kept game adds no duplicate.""" state = _state((2, "KeptA"), current=(2, "KeptA")) with patch.object(State, "save"): assert apply_manual_pick(state, 2, "KeptA", max_picks=2) is None @@ -85,6 +91,7 @@ class TestApplyManualPickKeeps: assert active_manual_picks(state) == state.manual_picks def test_finished_pick_is_kept_too(self) -> None: + """A finished pick stays allowed after a new pick.""" state = _state((2, "Done")) state.finished_app_ids = [2] with patch.object(State, "save"): @@ -120,7 +127,10 @@ def _output(mock_echo: MagicMock) -> str: class TestCmdPickManualKeeps: + """cmd_pick_manual spares kept games and checks the cap first.""" + def test_kept_games_are_not_uninstalled(self) -> None: + """Only games outside the kept set are uninstalled.""" state = _state((2, "KeptA")) with ExitStack() as stack: mocks = _cli_effects(stack) @@ -132,6 +142,7 @@ class TestCmdPickManualKeeps: assert "KeptA" in output def test_over_cap_is_refused_before_the_confirmation(self) -> None: + """An over-cap pick exits before asking to confirm.""" state = _state((2, "KeptA"), (3, "KeptB")) with ExitStack() as stack: mocks = _cli_effects(stack) @@ -147,6 +158,8 @@ class TestCmdPickManualKeeps: class TestMcpPickManualKeeps: + """The MCP pick-manual tool keeps earlier picks and honours the cap.""" + def _run(self, state: State) -> dict[str, object]: with ( patch( @@ -164,11 +177,13 @@ class TestMcpPickManualKeeps: return out def test_kept_game_survives(self) -> None: + """An MCP pick keeps the earlier pick allowed.""" state = _state((2, "KeptA")) assert self._run(state)["ok"] is True assert allowed_app_ids(state) == {1, 2, 5} def test_over_cap_is_refused(self) -> None: + """An over-cap MCP pick is refused naming the games.""" state = _state((2, "KeptA"), (3, "KeptB")) out = self._run(state) assert out["ok"] is False diff --git a/steam_backlog_enforcer/tests/test_playtime.py b/steam_backlog_enforcer/tests/test_playtime.py index 21d4302..c78892b 100644 --- a/steam_backlog_enforcer/tests/test_playtime.py +++ b/steam_backlog_enforcer/tests/test_playtime.py @@ -2,23 +2,17 @@ from __future__ import annotations -from dataclasses import FrozenInstanceError from datetime import datetime, timedelta, timezone import json from unittest.mock import patch -import pytest - from steam_backlog_enforcer._gaming_days import gaming_day_key from steam_backlog_enforcer._playtime_state import ( PlaytimeState, load_state, - rules_for, save_state, state_path, ) -from steam_backlog_enforcer.config import Config -from steam_backlog_enforcer.tests._no_workout_http import fixed_budget PKG = "steam_backlog_enforcer._playtime" LOCAL = timezone(timedelta(hours=2)) @@ -29,22 +23,30 @@ def _at(year: int, month: int, day: int, hour: int, minute: int = 0) -> datetime class TestGamingDayKey: + """The gaming day runs from 06:00 to 06:00 local time.""" + def test_just_before_the_boundary_is_the_previous_day(self) -> None: + """05:59 still belongs to yesterday.""" assert gaming_day_key(_at(2026, 7, 27, 5, 59)) == "2026-07-26" def test_exactly_at_the_boundary_starts_the_new_day(self) -> None: + """06:00 starts the new day.""" assert gaming_day_key(_at(2026, 7, 27, 6, 0)) == "2026-07-27" def test_evening_is_the_same_day(self) -> None: + """A late evening keeps today's key.""" assert gaming_day_key(_at(2026, 7, 27, 23, 30)) == "2026-07-27" def test_after_midnight_belongs_to_the_day_it_started(self) -> None: + """01:15 belongs to the day before.""" assert gaming_day_key(_at(2026, 7, 28, 1, 15)) == "2026-07-27" def test_exact_midnight(self) -> None: + """Midnight belongs to the day before.""" assert gaming_day_key(_at(2026, 7, 28, 0, 0)) == "2026-07-27" def test_month_boundary(self) -> None: + """Early hours on the 1st belong to the last day of the previous month.""" assert gaming_day_key(_at(2026, 8, 1, 3, 0)) == "2026-07-31" def test_dst_transition_day_still_yields_a_key(self) -> None: @@ -56,18 +58,26 @@ class TestGamingDayKey: class TestStatePath: + """Production and demo state live in separate files.""" + def test_production_and_demo_differ(self) -> None: + """The two modes never share a path.""" assert state_path(demo=False) != state_path(demo=True) def test_demo_path_is_named_demo(self) -> None: + """The demo file is recognisable by name.""" assert "demo" in state_path(demo=True).name class TestPlaytimeState: + """Blocked status derives from blocked_at.""" + def test_not_blocked_by_default(self) -> None: + """A fresh state is not blocked.""" assert PlaytimeState().is_blocked() is False def test_blocked_when_stamped(self) -> None: + """A blocked_at stamp makes the state blocked.""" assert PlaytimeState(blocked_at=1.0).is_blocked() is True def test_is_blocked_is_not_serialised(self) -> None: @@ -76,7 +86,10 @@ class TestPlaytimeState: class TestSaveLoadRoundTrip: + """Saving and loading state, and every unreadable case.""" + def test_round_trip(self) -> None: + """A saved state loads back equal.""" original = PlaytimeState( day_key="2026-07-27", seconds=123.5, @@ -89,40 +102,48 @@ class TestSaveLoadRoundTrip: assert loaded == original def test_written_world_readable(self) -> None: + """The state file is written 0644 so unprivileged readers see it.""" # The daemon runs as root and mkstemp would leave this 0600, which made # every unprivileged reader see "no state recorded yet". save_state(PlaytimeState(day_key="d", seconds=1.0), demo=False) assert state_path(demo=False).stat().st_mode & 0o777 == 0o644 def test_demo_and_production_are_independent(self) -> None: + """Demo and production saves do not overwrite each other.""" save_state(PlaytimeState(day_key="prod", seconds=10.0), demo=False) save_state(PlaytimeState(day_key="demo", seconds=20.0), demo=True) assert load_state(demo=False).day_key == "prod" assert load_state(demo=True).day_key == "demo" def test_missing_file_is_none(self) -> None: + """No file loads as None.""" assert load_state(demo=False) is None def test_corrupt_json_is_none(self) -> None: + """Invalid JSON loads as None.""" state_path(demo=False).write_text("{not json", encoding="utf-8") assert load_state(demo=False) is None def test_unreadable_file_is_none(self) -> None: + """A read error loads as None.""" save_state(PlaytimeState(day_key="x"), demo=False) with patch("pathlib.Path.read_text", side_effect=OSError("nope")): assert load_state(demo=False) is None def test_wrong_schema_version_is_none(self) -> None: + """An unknown schema version loads as None.""" state_path(demo=False).write_text( json.dumps({"schema_version": 99, "day_key": "x"}), encoding="utf-8" ) assert load_state(demo=False) is None def test_non_dict_json_is_none(self) -> None: + """A JSON list loads as None.""" state_path(demo=False).write_text("[1, 2, 3]", encoding="utf-8") assert load_state(demo=False) is None def test_unknown_keys_are_dropped(self) -> None: + """Unknown keys are ignored and known ones kept.""" state_path(demo=False).write_text( json.dumps({"schema_version": 1, "day_key": "d", "bogus": 1}), encoding="utf-8", @@ -133,6 +154,8 @@ class TestSaveLoadRoundTrip: class TestSaveStateImmutability: + """Production saves toggle the immutable bit; demo saves do not.""" + def test_production_save_unlocks_then_relocks(self) -> None: """rename(2) onto an immutable file is EPERM, so the unlock is required.""" with ( @@ -160,54 +183,3 @@ class TestSaveStateImmutability: save_state(PlaytimeState(day_key="x"), demo=True) mock_unlock.assert_not_called() mock_lock.assert_not_called() - - -class TestRulesFor: - def test_production_budget_comes_from_the_resolver(self) -> None: - with fixed_budget(100.0): - rules = rules_for(Config(), demo=False) - assert rules.budget_seconds == 100.0 - assert rules.demo is False - - def test_demo_budget_is_sixty_seconds(self) -> None: - rules = rules_for(Config(), demo=True) - assert rules.budget_seconds == 60.0 - assert rules.demo is True - - def test_demo_warns_in_seconds_not_minutes(self) -> None: - assert rules_for(Config(), demo=True).warn_at == (30, 10) - assert rules_for(Config(), demo=False).warn_at == (3600, 1800, 600, 300) - - def test_demo_escalates_to_sigkill_sooner(self) -> None: - assert rules_for(Config(), demo=True).sigkill_after == 10.0 - assert rules_for(Config(), demo=False).sigkill_after == 30.0 - - def test_launcher_toggle_is_carried_through(self) -> None: - assert ( - rules_for( - Config(count_launcher_processes=False), demo=False - ).count_launchers - is False - ) - assert ( - rules_for(Config(count_launcher_processes=True), demo=False).count_launchers - is True - ) - - def test_enforcement_toggle_is_carried_through(self) -> None: - assert ( - rules_for(Config(playtime_enforcement=False), demo=False).enforcement - is False - ) - - def test_demo_uses_the_same_qualifying_predicate(self) -> None: - """Demo may differ in budget and warnings only.""" - prod = rules_for(Config(count_launcher_processes=True), demo=False) - demo = rules_for(Config(count_launcher_processes=True), demo=True) - assert prod.count_launchers == demo.count_launchers - - def test_rules_are_frozen(self) -> None: - """Frozen so the decision helpers stay pure and cheap to reason about.""" - rules = rules_for(Config(), demo=False) - with pytest.raises(FrozenInstanceError): - rules.budget_seconds = 1.0 diff --git a/steam_backlog_enforcer/tests/test_playtime_block.py b/steam_backlog_enforcer/tests/test_playtime_block.py index 8d5be9b..c038e23 100644 --- a/steam_backlog_enforcer/tests/test_playtime_block.py +++ b/steam_backlog_enforcer/tests/test_playtime_block.py @@ -6,6 +6,8 @@ import subprocess from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch +import pytest + from steam_backlog_enforcer import _playtime_block as blk from steam_backlog_enforcer._playtime_block import ( _ensure_stub, @@ -41,63 +43,84 @@ def _write_mountinfo(tmp_path: Path, points: list[str]) -> None: class TestUnescapeMountinfo: + """_unescape_mountinfo decodes the octal escapes of mountinfo.""" + def test_space(self) -> None: + """\\040 decodes to a space.""" assert _unescape_mountinfo(r"/mnt/my\040dir") == "/mnt/my dir" def test_tab_newline_backslash(self) -> None: + """Tab, newline and backslash escapes decode.""" assert _unescape_mountinfo(r"a\011b") == "a\tb" assert _unescape_mountinfo(r"a\012b") == "a\nb" assert _unescape_mountinfo(r"a\134b") == "a\\b" def test_plain_string_untouched(self) -> None: + """A string without escapes is unchanged.""" assert _unescape_mountinfo("/usr/bin/steam") == "/usr/bin/steam" class TestMountpoints: + """_mountpoints reads the mount points out of a mountinfo file.""" + def test_unreadable_file_returns_empty(self, tmp_path: Path) -> None: + """A missing file yields no mount points.""" assert _mountpoints(tmp_path / "does-not-exist") == set() def test_empty_file(self, tmp_path: Path) -> None: + """An empty file yields no mount points.""" path = tmp_path / "mi" path.write_text("", encoding="utf-8") assert _mountpoints(path) == set() def test_skips_short_lines(self, tmp_path: Path) -> None: + """Lines too short to hold a mount point are skipped.""" path = tmp_path / "mi" path.write_text("1 2 3\n" + _mountinfo_line("/a") + "\n", encoding="utf-8") assert {str(p) for p in _mountpoints(path)} == {"/a"} def test_decodes_escapes(self, tmp_path: Path) -> None: + """Mount points are unescaped.""" path = tmp_path / "mi" path.write_text(_mountinfo_line(r"/mnt/a\040b") + "\n", encoding="utf-8") assert {str(p) for p in _mountpoints(path)} == {"/mnt/a b"} class TestMountedTargets: + """mounted_targets lists only block targets that are mounted.""" + def test_none_mounted(self, tmp_path: Path) -> None: + """Nothing mounted yields no targets.""" _write_mountinfo(tmp_path, []) assert mounted_targets() == set() def test_ignores_unrelated_mounts(self, tmp_path: Path) -> None: + """Unrelated mounts are not targets.""" _write_mountinfo(tmp_path, ["/", "/home", "/etc/hosts"]) assert mounted_targets() == set() def test_detects_a_target(self, tmp_path: Path) -> None: + """A mounted target is found.""" target = block_targets()[0] _write_mountinfo(tmp_path, ["/", str(target)]) assert mounted_targets() == {target} def test_detects_all_targets(self, tmp_path: Path) -> None: + """All mounted targets are found.""" _write_mountinfo(tmp_path, [str(t) for t in block_targets()]) assert mounted_targets() == set(block_targets()) class TestMountsAreVisible: + """mounts_are_visible checks that init sees our mounts.""" + def test_true_when_nothing_mounted(self, tmp_path: Path) -> None: + """Nothing mounted counts as visible.""" _write_mountinfo(tmp_path, []) assert mounts_are_visible() is True def test_true_when_init_sees_the_mount(self, tmp_path: Path) -> None: + """A mount init also sees counts as visible.""" target = block_targets()[0] _write_mountinfo(tmp_path, [str(target)]) (tmp_path / "init_mountinfo").write_text( @@ -116,18 +139,24 @@ class TestMountsAreVisible: class TestEnsureStub: + """_ensure_stub writes the blocked-gaming stub binary.""" + def test_writes_executable_stub(self, tmp_path: Path) -> None: + """The stub is written executable with its message.""" assert _ensure_stub() is True stub = tmp_path / "run" / "gaming-blocked" assert stub.exists() assert stub.stat().st_mode & 0o111 assert "Gaming blocked" in stub.read_text(encoding="utf-8") - def test_idempotent(self, tmp_path: Path) -> None: + @pytest.mark.usefixtures("tmp_path") + def test_idempotent(self) -> None: + """Writing the stub twice succeeds both times.""" assert _ensure_stub() is True assert _ensure_stub() is True def test_returns_false_on_oserror(self) -> None: + """An OSError makes _ensure_stub return False.""" with patch( "steam_backlog_enforcer._playtime_block.Path.mkdir", side_effect=OSError("boom"), @@ -136,7 +165,10 @@ class TestEnsureStub: class TestRun: + """_run reports whether a command exited 0.""" + def test_true_on_zero_exit(self) -> None: + """Exit 0 is success.""" with patch( "steam_backlog_enforcer._playtime_run.subprocess.run", return_value=MagicMock(returncode=0), @@ -145,6 +177,7 @@ class TestRun: mock_run.assert_called_once() def test_false_on_nonzero_exit(self) -> None: + """A non-zero exit is failure.""" with patch( "steam_backlog_enforcer._playtime_run.subprocess.run", return_value=MagicMock(returncode=1), @@ -152,6 +185,7 @@ class TestRun: assert _run(["mount"]) is False def test_false_on_oserror(self) -> None: + """An OSError is failure.""" with patch( "steam_backlog_enforcer._playtime_run.subprocess.run", side_effect=OSError("nope"), @@ -159,6 +193,7 @@ class TestRun: assert _run(["mount"]) is False def test_false_on_timeout(self) -> None: + """A timeout is failure.""" with patch( "steam_backlog_enforcer._playtime_run.subprocess.run", side_effect=subprocess.TimeoutExpired("mount", 15), @@ -167,7 +202,10 @@ class TestRun: class TestBinaryResolution: + """mount and umount resolve via PATH with a /usr/bin fallback.""" + def test_mount_bin_found(self) -> None: + """mount is taken from PATH.""" with patch( "steam_backlog_enforcer._playtime_block.shutil.which", return_value="/bin/mount", @@ -175,12 +213,14 @@ class TestBinaryResolution: assert _mount_bin() == "/bin/mount" def test_mount_bin_fallback(self) -> None: + """mount falls back to /usr/bin/mount.""" with patch( "steam_backlog_enforcer._playtime_block.shutil.which", return_value=None ): assert _mount_bin() == "/usr/bin/mount" def test_umount_bin_found(self) -> None: + """umount is taken from PATH.""" with patch( "steam_backlog_enforcer._playtime_block.shutil.which", return_value="/bin/umount", @@ -188,6 +228,7 @@ class TestBinaryResolution: assert _umount_bin() == "/bin/umount" def test_umount_bin_fallback(self) -> None: + """umount falls back to /usr/bin/umount.""" with patch( "steam_backlog_enforcer._playtime_block.shutil.which", return_value=None ): @@ -195,7 +236,10 @@ class TestBinaryResolution: class TestBlockTargets: + """block_targets exposes the isolated target list.""" + def test_accessor_returns_the_constant(self) -> None: + """block_targets returns BLOCK_TARGETS.""" assert block_targets() == blk.BLOCK_TARGETS def test_four_targets_are_isolated_in_tests(self) -> None: diff --git a/steam_backlog_enforcer/tests/test_playtime_block_part2.py b/steam_backlog_enforcer/tests/test_playtime_block_part2.py index b20b3dd..74d832d 100644 --- a/steam_backlog_enforcer/tests/test_playtime_block_part2.py +++ b/steam_backlog_enforcer/tests/test_playtime_block_part2.py @@ -31,7 +31,10 @@ def _make_targets_exist() -> None: class TestApplyBlock: + """apply_block bind-mounts read-only stubs over the targets.""" + def test_deferred_while_pacman_holds_the_lock(self, tmp_path: Path) -> None: + """No mount runs while pacman holds its database lock.""" _make_targets_exist() _set_mounted(tmp_path, []) (tmp_path / "db.lck").write_text("", encoding="utf-8") @@ -40,6 +43,7 @@ class TestApplyBlock: mock_run.assert_not_called() def test_returns_empty_when_stub_cannot_be_written(self, tmp_path: Path) -> None: + """Nothing is mounted when the stub file cannot be written.""" _make_targets_exist() _set_mounted(tmp_path, []) with ( @@ -50,24 +54,28 @@ class TestApplyBlock: mock_run.assert_not_called() def test_mounts_every_existing_target(self, tmp_path: Path) -> None: + """Every existing target is masked.""" _make_targets_exist() _set_mounted(tmp_path, []) with patch("steam_backlog_enforcer._playtime_block._run", return_value=True): assert apply_block() == list(block_targets()) def test_skips_missing_targets(self, tmp_path: Path) -> None: + """Targets that do not exist are skipped.""" _set_mounted(tmp_path, []) block_targets()[0].write_text("x", encoding="utf-8") with patch("steam_backlog_enforcer._playtime_block._run", return_value=True): assert apply_block() == [block_targets()[0]] def test_skips_already_mounted(self, tmp_path: Path) -> None: + """Targets already mounted are not mounted again.""" _make_targets_exist() _set_mounted(tmp_path, [block_targets()[0]]) with patch("steam_backlog_enforcer._playtime_block._run", return_value=True): assert apply_block() == list(block_targets()[1:]) def test_bind_failure_skips_remount(self, tmp_path: Path) -> None: + """A failed bind mount is not followed by a remount.""" _make_targets_exist() _set_mounted(tmp_path, []) with patch( @@ -78,6 +86,7 @@ class TestApplyBlock: assert all("--bind" in call.args[0] for call in mock_run.call_args_list) def test_remount_failure_is_not_counted(self, tmp_path: Path) -> None: + """A target whose read-only remount fails is not reported masked.""" _make_targets_exist() _set_mounted(tmp_path, []) # First call (--bind) succeeds, second (remount) fails, repeatedly. @@ -87,6 +96,7 @@ class TestApplyBlock: assert apply_block() == [] def test_uses_mount_and_remount_argv(self, tmp_path: Path) -> None: + """The bind and remount use the expected mount argv.""" _set_mounted(tmp_path, []) block_targets()[0].write_text("x", encoding="utf-8") with ( @@ -105,13 +115,17 @@ class TestApplyBlock: class TestReleaseBlock: + """release_block lazily unmounts every masked target.""" + def test_nothing_mounted(self, tmp_path: Path) -> None: + """With nothing mounted no umount runs.""" _set_mounted(tmp_path, []) with patch("steam_backlog_enforcer._playtime_block._run") as mock_run: assert release_block() == [] mock_run.assert_not_called() def test_releases_a_single_mount(self, tmp_path: Path) -> None: + """A single mount is released and reported.""" target = block_targets()[0] _set_mounted(tmp_path, [target]) @@ -123,6 +137,7 @@ class TestReleaseBlock: assert release_block() == [target] def test_loops_for_stacked_mounts(self, tmp_path: Path) -> None: + """Stacked mounts are unmounted until none remain.""" target = block_targets()[0] _set_mounted(tmp_path, [target]) remaining = {"n": 3} @@ -139,6 +154,7 @@ class TestReleaseBlock: assert mock_run.call_count == 3 def test_breaks_when_umount_fails(self, tmp_path: Path) -> None: + """A failing umount stops the loop and reports nothing.""" target = block_targets()[0] _set_mounted(tmp_path, [target]) with patch( @@ -158,6 +174,7 @@ class TestReleaseBlock: assert mock_run.call_count == 20 def test_uses_lazy_umount_argv(self, tmp_path: Path) -> None: + """The release uses a lazy umount argv.""" target = block_targets()[0] _set_mounted(tmp_path, [target]) with ( @@ -174,7 +191,10 @@ class TestReleaseBlock: class TestReconcile: + """reconcile applies or releases the block as asked.""" + def test_release_path(self, tmp_path: Path) -> None: + """should_block=False releases masked targets.""" target = block_targets()[0] _set_mounted(tmp_path, [target]) @@ -188,6 +208,7 @@ class TestReconcile: assert released == [target] def test_apply_path(self, tmp_path: Path) -> None: + """should_block=True masks every existing target.""" _make_targets_exist() _set_mounted(tmp_path, []) with ( @@ -199,6 +220,7 @@ class TestReconcile: assert released == [] def test_logs_when_mounts_are_invisible(self, tmp_path: Path) -> None: + """Masks that the system cannot see are logged as an error.""" _make_targets_exist() _set_mounted(tmp_path, []) with ( @@ -210,6 +232,7 @@ class TestReconcile: mock_error.assert_called_once() def test_no_visibility_check_when_nothing_was_masked(self, tmp_path: Path) -> None: + """With nothing masked the visibility check is skipped.""" _set_mounted(tmp_path, []) with ( patch("steam_backlog_enforcer._playtime_block._run", return_value=True), diff --git a/steam_backlog_enforcer/tests/test_playtime_cutoff.py b/steam_backlog_enforcer/tests/test_playtime_cutoff.py index cac10bc..957619b 100644 --- a/steam_backlog_enforcer/tests/test_playtime_cutoff.py +++ b/steam_backlog_enforcer/tests/test_playtime_cutoff.py @@ -1,8 +1,6 @@ """Tests for the main CLI: the warn/cutoff/kill escalation.""" -from contextlib import ExitStack from datetime import datetime, timedelta, timezone -import logging from unittest.mock import MagicMock, patch import pytest @@ -11,12 +9,8 @@ from steam_backlog_enforcer._playtime_cutoff import ( _begin_cutoff, _kill_set, _sustain_block, - _warn, reconcile_for, ) -from steam_backlog_enforcer._playtime_notify import ( - notify_desktop_user, -) from steam_backlog_enforcer._playtime_state import ( PlaytimeState, rules_for, @@ -29,33 +23,13 @@ NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL) TODAY = "2026-07-27" -@pytest.fixture -def quiet_tick() -> object: - """Neutralise every side effect the tick can produce.""" - with ExitStack() as stack: - # Each name is patched in the module that *resolves* it, not in the - # package that re-exports it -- otherwise the patch never bites. - cutoff = "steam_backlog_enforcer._playtime_cutoff" - mocks = { - name: stack.enter_context(patch(f"{where}.{name}")) - for name, where in ( - ("reconcile", cutoff), - ("request_steam_shutdown", cutoff), - ("kill_gaming_processes", cutoff), - ("notify_desktop_user", cutoff), - ("mounted_targets", PKG), - ) - } - mocks["mounted_targets"].return_value = set() - stack.enter_context(patch(f"{PKG}.is_total_block_active", return_value=False)) - yield mocks - - def _rules(*, demo: bool = False, **cfg: object) -> object: return rules_for(Config(**cfg), demo=demo) class TestBeginCutoff: + """Starting the cutoff: shutdown order, kill, notify and stamp.""" + def test_asks_steam_to_close_before_masking_anything(self) -> None: """Masking makes `steam -shutdown` a no-op, so order is load-bearing.""" state = PlaytimeState(day_key=TODAY, seconds=60.0) @@ -84,6 +58,7 @@ class TestBeginCutoff: def test_demo_says_out_loud_that_it_will_not_mask( self, caplog: pytest.LogCaptureFixture ) -> None: + """Only a demo cutoff logs that it will not mask.""" state = PlaytimeState(day_key=TODAY, seconds=60.0) with ( patch("steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown"), @@ -101,19 +76,25 @@ class TestBeginCutoff: class TestReconcileFor: + """Only production cutoffs drive the store mounts.""" + def test_production_drives_the_mounts(self) -> None: + """Production passes should_block straight to reconcile.""" with patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as rec: reconcile_for(_rules(), should_block=True) rec.assert_called_once_with(should_block=True) @pytest.mark.parametrize("should_block", [True, False]) def test_demo_never_touches_them(self, *, should_block: bool) -> None: + """Demo never reconciles the mounts.""" with patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as rec: reconcile_for(_rules(demo=True), should_block=should_block) rec.assert_not_called() class TestSustainBlock: + """While blocked: grace before mounting, then SIGTERM, then SIGKILL.""" + def _run( self, elapsed: float, *, demo: bool = False ) -> tuple[MagicMock, MagicMock]: @@ -133,32 +114,39 @@ class TestSustainBlock: return mock_rec, mock_kill def test_grace_period_defers_the_mount(self) -> None: + """Inside the grace period games are terminated but nothing is mounted.""" mock_rec, mock_kill = self._run(1.0) mock_rec.assert_not_called() mock_kill.assert_called_once_with({7}, force=False) def test_mounts_once_the_grace_has_passed(self) -> None: + """After the grace period the block mounts.""" mock_rec, _ = self._run(3.0) mock_rec.assert_called_once_with(should_block=True) def test_escalates_to_sigkill(self) -> None: + """Past 30 s the kill escalates to SIGKILL.""" _, mock_kill = self._run(31.0) mock_kill.assert_called_once_with({7}, force=True) def test_no_escalation_just_before_the_threshold(self) -> None: + """At 29 s the kill is still SIGTERM.""" _, mock_kill = self._run(29.0) mock_kill.assert_called_once_with({7}, force=False) def test_demo_never_mounts_but_keeps_killing(self) -> None: + """Demo keeps killing but never mounts.""" mock_rec, mock_kill = self._run(5.0, demo=True) mock_rec.assert_not_called() mock_kill.assert_called_once() def test_demo_escalates_sooner(self) -> None: + """Demo escalates to SIGKILL after 10 s.""" _, mock_kill = self._run(11.0, demo=True) mock_kill.assert_called_once_with({7}, force=True) def test_state_is_unchanged(self) -> None: + """Sustaining a block returns the same state object.""" state = PlaytimeState(day_key=TODAY, seconds=99.0, blocked_at=1.0) with ( patch("steam_backlog_enforcer._playtime_cutoff.reconcile"), @@ -172,6 +160,8 @@ class TestSustainBlock: class TestKillSet: + """The kill set covers more than the budget's qualifying pids.""" + def test_is_wider_than_the_budget_predicate(self) -> None: """A Lutris Wine game is invisible to both budget matchers.""" with ( @@ -185,61 +175,3 @@ class TestKillSet: ), ): assert _kill_set(_rules()) == {1, 2} - - -class TestWarn: - def test_records_the_threshold(self) -> None: - state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600]) - with patch( - "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" - ) as mock_notify: - out = _warn(state, _rules()) - assert out.warned_seconds == [3600, 1800, 600, 300] - assert mock_notify.call_args.args == ("⏳🎮", "5m") - - def test_a_fired_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: - state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600]) - with ( - caplog.at_level(logging.INFO), - patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"), - ): - _warn(state, _rules()) - assert "5 minutes" in caplog.text - - def test_no_op_when_nothing_is_due(self) -> None: - state = PlaytimeState(seconds=0.0) - with patch( - "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" - ) as mock_notify: - out = _warn(state, _rules()) - mock_notify.assert_not_called() - assert out is state - - -class TestNotifyDesktopUser: - def test_routes_through_the_desktop_session(self) -> None: - """notify-send as root with no DBUS address never reaches the user.""" - with ( - patch( - "steam_backlog_enforcer._playtime_notify._resolve_desktop_user", - return_value="kuhy", - ), - patch("steam_backlog_enforcer._playtime_notify._run_as_user") as mock_run, - ): - notify_desktop_user("T", "B") - cmd, user = mock_run.call_args.args - assert cmd[0] == "notify-send" - assert user == "kuhy" - - def test_swallows_errors(self) -> None: - with ( - patch( - "steam_backlog_enforcer._playtime_notify._resolve_desktop_user", - return_value="kuhy", - ), - patch( - "steam_backlog_enforcer._playtime_notify._run_as_user", - side_effect=OSError("no session"), - ), - ): - notify_desktop_user("T", "B") diff --git a/steam_backlog_enforcer/tests/test_playtime_cutoff_warn.py b/steam_backlog_enforcer/tests/test_playtime_cutoff_warn.py new file mode 100644 index 0000000..f05e4b6 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_playtime_cutoff_warn.py @@ -0,0 +1,87 @@ +"""Tests for the playtime warnings and the desktop notification route.""" + +import logging +from typing import TYPE_CHECKING +from unittest.mock import patch + +from steam_backlog_enforcer._playtime_cutoff import _warn +from steam_backlog_enforcer._playtime_notify import notify_desktop_user +from steam_backlog_enforcer._playtime_state import ( + PlaytimeRules, + PlaytimeState, + rules_for, +) +from steam_backlog_enforcer.config import Config + +if TYPE_CHECKING: + import pytest + + +def _rules() -> PlaytimeRules: + return rules_for(Config(), demo=False) + + +class TestWarn: + """Warnings fire once per threshold and are recorded.""" + + def test_records_the_threshold(self) -> None: + """A due threshold notifies and is added to warned_seconds.""" + state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600]) + with patch( + "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" + ) as mock_notify: + out = _warn(state, _rules()) + assert out.warned_seconds == [3600, 1800, 600, 300] + assert mock_notify.call_args.args == ("⏳🎮", "5m") + + def test_a_fired_warning_is_logged(self, caplog: pytest.LogCaptureFixture) -> None: + """A fired warning is also logged.""" + state = PlaytimeState(seconds=8 * 3600 - 300, warned_seconds=[3600, 1800, 600]) + with ( + caplog.at_level(logging.INFO), + patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"), + ): + _warn(state, _rules()) + assert "5 minutes" in caplog.text + + def test_no_op_when_nothing_is_due(self) -> None: + """With no threshold due nothing is sent and the state is returned as is.""" + state = PlaytimeState(seconds=0.0) + with patch( + "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" + ) as mock_notify: + out = _warn(state, _rules()) + mock_notify.assert_not_called() + assert out is state + + +class TestNotifyDesktopUser: + """Desktop notifications go to the desktop user and never raise.""" + + def test_routes_through_the_desktop_session(self) -> None: + """notify-send as root with no DBUS address never reaches the user.""" + with ( + patch( + "steam_backlog_enforcer._playtime_notify._resolve_desktop_user", + return_value="kuhy", + ), + patch("steam_backlog_enforcer._playtime_notify._run_as_user") as mock_run, + ): + notify_desktop_user("T", "B") + cmd, user = mock_run.call_args.args + assert cmd[0] == "notify-send" + assert user == "kuhy" + + def test_swallows_errors(self) -> None: + """A failure to reach the session is swallowed.""" + with ( + patch( + "steam_backlog_enforcer._playtime_notify._resolve_desktop_user", + return_value="kuhy", + ), + patch( + "steam_backlog_enforcer._playtime_notify._run_as_user", + side_effect=OSError("no session"), + ), + ): + notify_desktop_user("T", "B") diff --git a/steam_backlog_enforcer/tests/test_playtime_history.py b/steam_backlog_enforcer/tests/test_playtime_history.py index 7be7002..d1eb176 100644 --- a/steam_backlog_enforcer/tests/test_playtime_history.py +++ b/steam_backlog_enforcer/tests/test_playtime_history.py @@ -12,46 +12,43 @@ from steam_backlog_enforcer._playtime_history import ( record_day, ) from steam_backlog_enforcer._playtime_state import PlaytimeState +from steam_backlog_enforcer.tests._playtime_history_files import stored _PKG = "steam_backlog_enforcer._playtime_history" -def _raw() -> dict: - return json.loads(history_mod.HISTORY_FILE.read_text(encoding="utf-8")) - - -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()} - - class TestRecordDay: """Tests for record_day.""" def test_creates_and_upserts(self) -> None: + """record_day creates the file and overwrites a day it already has.""" record_day("2026-08-27", 100.0, {}) record_day("2026-08-28", 200.0, {}) record_day("2026-08-28", 250.0, {}) - assert _stored() == {"2026-08-27": 100.0, "2026-08-28": 250.0} + assert stored() == {"2026-08-27": 100.0, "2026-08-28": 250.0} def test_written_world_readable(self) -> None: + """The history file is written mode 0644.""" record_day("2026-08-28", 1.0, {}) assert history_mod.HISTORY_FILE.stat().st_mode & 0o777 == 0o644 def test_trims_to_the_retention_window(self) -> None: + """Days beyond the retention window are dropped.""" with patch(f"{_PKG}._MAX_DAYS", 2): for day in ("2026-08-26", "2026-08-27", "2026-08-28"): record_day(day, 1.0, {}) - assert sorted(_stored()) == ["2026-08-27", "2026-08-28"] + assert sorted(stored()) == ["2026-08-27", "2026-08-28"] class TestLoadHistory: """Tests for load_history and its guards.""" def test_missing_file_is_empty(self) -> None: + """A missing file loads as no history.""" assert load_history() == [] def test_returns_oldest_first_and_limited(self) -> None: + """load_history returns the newest days up to the limit, oldest first.""" for day, seconds in ( ("2026-08-26", 1.0), ("2026-08-27", 2.0), @@ -63,35 +60,42 @@ class TestLoadHistory: assert days[0].seconds == 2.0 def test_zero_limit_returns_nothing(self) -> None: + """A zero limit returns nothing.""" record_day("2026-08-28", 1.0, {}) assert load_history(0) == [] def test_unparseable_file_is_ignored(self) -> None: + """A corrupt file loads as no history.""" history_mod.HISTORY_FILE.write_text("{not json", encoding="utf-8") assert load_history() == [] def test_unreadable_file_is_ignored(self) -> None: + """An unreadable file loads as no history.""" record_day("2026-08-28", 1.0, {}) with patch("pathlib.Path.read_text", side_effect=PermissionError): assert load_history() == [] def test_unknown_schema_is_ignored(self) -> None: + """An unknown schema version loads as no history.""" history_mod.HISTORY_FILE.write_text( json.dumps({"schema_version": 99, "days": {}}), encoding="utf-8" ) assert load_history() == [] def test_non_mapping_payload_is_ignored(self) -> None: + """A non-object payload loads as no history.""" history_mod.HISTORY_FILE.write_text(json.dumps([1, 2, 3]), encoding="utf-8") assert load_history() == [] def test_non_mapping_days_is_ignored(self) -> None: + """A non-object days field loads as no history.""" history_mod.HISTORY_FILE.write_text( json.dumps({"schema_version": 1, "days": ["nope"]}), encoding="utf-8" ) assert load_history() == [] def test_bad_entries_are_dropped(self) -> None: + """Malformed day entries are dropped, good ones kept.""" # The file is neither root-owned nor immutable, so anything can be in it. history_mod.HISTORY_FILE.write_text( json.dumps( @@ -109,46 +113,54 @@ class TestHistoryWriter: """Tests for the throttled writer.""" def test_records_a_new_day_immediately(self) -> None: + """The first observation of a day is written at once.""" writer = HistoryWriter() writer.observe(PlaytimeState(day_key="2026-08-28", seconds=10.0), demo=False) - assert _stored() == {"2026-08-28": 10.0} + assert stored() == {"2026-08-28": 10.0} def test_skips_a_small_move(self) -> None: + """A small change since the last write is not written.""" writer = HistoryWriter() writer.observe(PlaytimeState(day_key="2026-08-28", seconds=10.0), demo=False) writer.observe(PlaytimeState(day_key="2026-08-28", seconds=12.0), demo=False) - assert _stored() == {"2026-08-28": 10.0} + assert stored() == {"2026-08-28": 10.0} def test_records_a_large_move(self) -> None: + """A large change since the last write is written.""" writer = HistoryWriter() writer.observe(PlaytimeState(day_key="2026-08-28", seconds=10.0), demo=False) writer.observe(PlaytimeState(day_key="2026-08-28", seconds=90.0), demo=False) - assert _stored() == {"2026-08-28": 90.0} + assert stored() == {"2026-08-28": 90.0} def test_records_a_refund(self) -> None: + """A downward move (idle refund) is written.""" # backdate() can move the counter *down* by the idle grace. writer = HistoryWriter() writer.observe(PlaytimeState(day_key="2026-08-28", seconds=400.0), demo=False) writer.observe(PlaytimeState(day_key="2026-08-28", seconds=100.0), demo=False) - assert _stored() == {"2026-08-28": 100.0} + assert stored() == {"2026-08-28": 100.0} def test_records_the_new_day_after_rollover(self) -> None: + """A new day after rollover is written beside the old one.""" writer = HistoryWriter() writer.observe(PlaytimeState(day_key="2026-08-27", seconds=500.0), demo=False) writer.observe(PlaytimeState(day_key="2026-08-28", seconds=0.0), demo=False) - assert _stored() == {"2026-08-27": 500.0, "2026-08-28": 0.0} + assert stored() == {"2026-08-27": 500.0, "2026-08-28": 0.0} def test_demo_runs_are_not_recorded(self) -> None: + """Demo observations never touch the file.""" HistoryWriter().observe( PlaytimeState(day_key="2026-08-28", seconds=10.0), demo=True ) assert not history_mod.HISTORY_FILE.exists() def test_stateless_record_is_not_written(self) -> None: + """A state without a day key is not written.""" HistoryWriter().observe(PlaytimeState(day_key="", seconds=10.0), demo=False) assert not history_mod.HISTORY_FILE.exists() def test_write_failure_does_not_raise(self) -> None: + """A failed write is swallowed.""" writer = HistoryWriter() with patch(f"{_PKG}.record_day", side_effect=OSError("read-only")): writer.observe( @@ -157,75 +169,4 @@ class TestHistoryWriter: assert not history_mod.HISTORY_FILE.exists() # Not remembered as written, so the next attempt tries again. writer.observe(PlaytimeState(day_key="2026-08-28", seconds=10.0), demo=False) - assert _stored() == {"2026-08-28": 10.0} - - -class TestPerGameBreakdown: - """The per-game dimension, and the legacy shape it had to grow out of.""" - - def test_round_trips_games_and_labels(self) -> None: - record_day("2026-08-28", 300.0, {"proc:osu-lazer": 200.0}) - stored = _raw() - assert stored["days"]["2026-08-28"]["games"] == {"proc:osu-lazer": 200.0} - assert stored["labels"]["proc:osu-lazer"] == "osu!lazer" - - def test_a_later_flush_preserves_other_days(self) -> None: - """Regression: the writer read through a lossy view and rewrote it. - - A whole-file read-modify-write built from a reader that dropped the - `games` sub-dict destroyed every other day's breakdown roughly once a - minute, so a bar kept its total and silently lost its bands. - """ - record_day("2026-08-27", 300.0, {"proc:osu-lazer": 300.0}) - record_day("2026-08-28", 100.0, {"app:440": 100.0}) - assert _raw()["days"]["2026-08-27"]["games"] == {"proc:osu-lazer": 300.0} - - def test_reads_a_schema_1_bare_float(self) -> None: - """Days recorded before attribution existed keep their total.""" - history_mod.HISTORY_FILE.write_text( - json.dumps({"schema_version": 1, "days": {"2026-08-20": 1234.5}}), - encoding="utf-8", - ) - days = load_history() - assert [(d.day, d.seconds, d.games) for d in days] == [ - ("2026-08-20", 1234.5, {}) - ] - - def test_drops_malformed_games_and_labels(self) -> None: - history_mod.HISTORY_FILE.write_text( - json.dumps( - { - "schema_version": 1, - "days": { - "2026-08-28": {"seconds": 10, "games": {"ok": 5, "bad": "x"}}, - "2026-08-27": {"seconds": True}, - "2026-08-26": {"games": {}}, - "2026-08-25": ["nope"], - }, - "labels": {"ok": "Fine", "bad": 7}, - } - ), - encoding="utf-8", - ) - days = {d.day: d.games for d in load_history()} - assert days == {"2026-08-28": {"ok": 5.0}} - assert history_mod.load_labels() == {"ok": "Fine"} - - def test_non_mapping_games_is_ignored(self) -> None: - history_mod.HISTORY_FILE.write_text( - json.dumps( - { - "schema_version": 1, - "days": {"2026-08-28": {"seconds": 5, "games": 3}}, - } - ), - encoding="utf-8", - ) - assert [d.games for d in load_history()] == [{}] - - def test_non_mapping_labels_is_ignored(self) -> None: - history_mod.HISTORY_FILE.write_text( - json.dumps({"schema_version": 1, "days": {}, "labels": "nope"}), - encoding="utf-8", - ) - assert history_mod.load_labels() == {} + assert stored() == {"2026-08-28": 10.0} diff --git a/steam_backlog_enforcer/tests/test_playtime_history_games.py b/steam_backlog_enforcer/tests/test_playtime_history_games.py new file mode 100644 index 0000000..94c1b29 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_playtime_history_games.py @@ -0,0 +1,90 @@ +"""Tests for _playtime_history: the per-game breakdown and its legacy shape. + +Split from test_playtime_history.py. +""" + +from __future__ import annotations + +import json + +from steam_backlog_enforcer import _playtime_history as history_mod +from steam_backlog_enforcer._playtime_history import ( + load_history, + record_day, +) +from steam_backlog_enforcer.tests._playtime_history_files import raw + + +class TestPerGameBreakdown: + """The per-game dimension, and the legacy shape it had to grow out of.""" + + def test_round_trips_games_and_labels(self) -> None: + """Per-game seconds and their labels are stored.""" + record_day("2026-08-28", 300.0, {"proc:osu-lazer": 200.0}) + stored = raw() + assert stored["days"]["2026-08-28"]["games"] == {"proc:osu-lazer": 200.0} + assert stored["labels"]["proc:osu-lazer"] == "osu!lazer" + + def test_a_later_flush_preserves_other_days(self) -> None: + """Regression: the writer read through a lossy view and rewrote it. + + A whole-file read-modify-write built from a reader that dropped the + `games` sub-dict destroyed every other day's breakdown roughly once a + minute, so a bar kept its total and silently lost its bands. + """ + record_day("2026-08-27", 300.0, {"proc:osu-lazer": 300.0}) + record_day("2026-08-28", 100.0, {"app:440": 100.0}) + assert raw()["days"]["2026-08-27"]["games"] == {"proc:osu-lazer": 300.0} + + def test_reads_a_schema_1_bare_float(self) -> None: + """Days recorded before attribution existed keep their total.""" + history_mod.HISTORY_FILE.write_text( + json.dumps({"schema_version": 1, "days": {"2026-08-20": 1234.5}}), + encoding="utf-8", + ) + days = load_history() + assert [(d.day, d.seconds, d.games) for d in days] == [ + ("2026-08-20", 1234.5, {}) + ] + + def test_drops_malformed_games_and_labels(self) -> None: + """Malformed per-game entries and labels are dropped.""" + history_mod.HISTORY_FILE.write_text( + json.dumps( + { + "schema_version": 1, + "days": { + "2026-08-28": {"seconds": 10, "games": {"ok": 5, "bad": "x"}}, + "2026-08-27": {"seconds": True}, + "2026-08-26": {"games": {}}, + "2026-08-25": ["nope"], + }, + "labels": {"ok": "Fine", "bad": 7}, + } + ), + encoding="utf-8", + ) + days = {d.day: d.games for d in load_history()} + assert days == {"2026-08-28": {"ok": 5.0}} + assert history_mod.load_labels() == {"ok": "Fine"} + + def test_non_mapping_games_is_ignored(self) -> None: + """A non-object games field loads as an empty breakdown.""" + history_mod.HISTORY_FILE.write_text( + json.dumps( + { + "schema_version": 1, + "days": {"2026-08-28": {"seconds": 5, "games": 3}}, + } + ), + encoding="utf-8", + ) + assert [d.games for d in load_history()] == [{}] + + def test_non_mapping_labels_is_ignored(self) -> None: + """A non-object labels field loads as no labels.""" + history_mod.HISTORY_FILE.write_text( + json.dumps({"schema_version": 1, "days": {}, "labels": "nope"}), + encoding="utf-8", + ) + assert history_mod.load_labels() == {} diff --git a/steam_backlog_enforcer/tests/test_playtime_kill.py b/steam_backlog_enforcer/tests/test_playtime_kill.py index e44194d..9d347cb 100644 --- a/steam_backlog_enforcer/tests/test_playtime_kill.py +++ b/steam_backlog_enforcer/tests/test_playtime_kill.py @@ -15,7 +15,10 @@ PKG = "steam_backlog_enforcer._playtime_kill" class TestRequestSteamShutdown: + """request_steam_shutdown asks a present Steam to exit.""" + def test_skips_when_steam_is_absent(self) -> None: + """Without a steam binary nothing is run.""" with ( patch( "steam_backlog_enforcer._playtime_kill.shutil.which", return_value=None @@ -26,6 +29,7 @@ class TestRequestSteamShutdown: mock_run.assert_not_called() def test_sends_shutdown(self) -> None: + """With steam on PATH the shutdown command is sent.""" with ( patch( "steam_backlog_enforcer._playtime_kill.shutil.which", @@ -40,7 +44,10 @@ class TestRequestSteamShutdown: class TestSteamAndLauncherPids: + """steam_and_launcher_pids finds Steam and other launchers.""" + def test_unions_both_name_sets(self) -> None: + """Steam and launcher pids are merged into one set.""" with patch( "steam_backlog_enforcer._playtime_kill.get_pids_by_process_names", return_value={11: "steam", 22: "lutris"}, @@ -52,7 +59,10 @@ class TestSteamAndLauncherPids: class TestKillGamingProcesses: + """kill_gaming_processes signals the game tree but never us.""" + def test_sigterm_by_default(self) -> None: + """Without force the pids get SIGTERM.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", @@ -68,6 +78,7 @@ class TestKillGamingProcesses: mock_kill.assert_called_once_with(7, signal.SIGTERM) def test_sigkill_when_forced(self) -> None: + """With force the pids get SIGKILL.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", @@ -83,6 +94,7 @@ class TestKillGamingProcesses: mock_kill.assert_called_once_with(7, signal.SIGKILL) def test_includes_descendants(self) -> None: + """Descendants of the targets are signalled too.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", @@ -97,6 +109,7 @@ class TestKillGamingProcesses: assert kill_gaming_processes({7}, force=False) == [7, 8, 9] def test_never_signals_our_own_chain(self) -> None: + """Pids in our own ancestor chain are skipped.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", @@ -112,6 +125,7 @@ class TestKillGamingProcesses: mock_kill.assert_called_once_with(8, signal.SIGTERM) def test_process_already_gone(self) -> None: + """A pid that is already gone is not reported as killed.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", @@ -129,6 +143,7 @@ class TestKillGamingProcesses: assert kill_gaming_processes({7}, force=False) == [] def test_permission_denied(self) -> None: + """A pid we may not signal is not reported as killed.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", @@ -146,6 +161,7 @@ class TestKillGamingProcesses: assert kill_gaming_processes({7}, force=False) == [] def test_empty_set(self) -> None: + """No targets means no signal.""" with ( patch( "steam_backlog_enforcer._playtime_kill.descendant_pids", diff --git a/steam_backlog_enforcer/tests/test_playtime_log.py b/steam_backlog_enforcer/tests/test_playtime_log.py index a11c405..71646e8 100644 --- a/steam_backlog_enforcer/tests/test_playtime_log.py +++ b/steam_backlog_enforcer/tests/test_playtime_log.py @@ -24,9 +24,11 @@ from steam_backlog_enforcer._playtime_log import ( from steam_backlog_enforcer._playtime_state import PlaytimeRules, PlaytimeState if TYPE_CHECKING: - from collections.abc import Iterator + from collections.abc import Callable, Iterator from pathlib import Path + _MakeJournal = Callable[..., tuple[TickJournal, Path]] + PL = "steam_backlog_enforcer._playtime_log" RULES = PlaytimeRules( @@ -47,10 +49,14 @@ def _records(path: Path) -> list[dict]: class TestBudgetLogPath: + """Production and demo runs journal to separate trails.""" + def test_production_run_journals_to_the_real_trail(self) -> None: + """A production run writes the real budget log.""" assert budget_log_path(demo=False) == log_mod.BUDGET_LOG_FILE def test_demo_run_cannot_reach_the_production_trail(self) -> None: + """A demo run writes only the demo log.""" # A demo bills against a 60-second budget. Letting those records land # in the production log makes the log unusable for the one job it has: # reconstructing what the real budget actually did. @@ -59,7 +65,10 @@ class TestBudgetLogPath: class TestBudgetLog: + """The JSONL sink: lazy open, one handler per path, never raises.""" + def test_creates_its_directory_on_first_write(self, tmp_path: Path) -> None: + """Missing parent directories are created on the first record.""" target = tmp_path / "deep" / "nested" / "budget.jsonl" log = BudgetLog(path=target) log.record("hello", answer=42) @@ -67,11 +76,13 @@ class TestBudgetLog: assert _records(target)[0]["answer"] == 42 def test_closing_an_unopened_sink_is_a_no_op(self, tmp_path: Path) -> None: + """Closing before any write does nothing.""" BudgetLog(path=tmp_path / "budget.jsonl").close() def test_a_second_sink_on_the_same_path_replaces_the_first( self, tmp_path: Path ) -> None: + """A second sink on one path takes over and both records land.""" # Two sinks share a logger name; the stale handler must be closed # rather than silently dropped, or the file descriptor leaks. target = tmp_path / "budget.jsonl" @@ -83,6 +94,7 @@ class TestBudgetLog: assert [r["event"] for r in _records(target)] == ["one", "two"] def test_every_record_carries_a_timestamp_and_event(self, tmp_path: Path) -> None: + """Each record has its event name and a timestamp.""" target = tmp_path / "budget.jsonl" log = BudgetLog(path=target) log.record("thing") @@ -92,6 +104,7 @@ class TestBudgetLog: assert record["timestamp"] def test_reuses_the_sink_across_records(self, tmp_path: Path) -> None: + """Several records go through one open sink.""" target = tmp_path / "budget.jsonl" log = BudgetLog(path=target) log.record("one") @@ -100,11 +113,13 @@ class TestBudgetLog: assert [r["event"] for r in _records(target)] == ["one", "two"] def test_an_uncreatable_path_does_not_raise(self, tmp_path: Path) -> None: + """A path under a regular file is silently dropped.""" blocker = tmp_path / "file" blocker.write_text("not a directory") BudgetLog(path=blocker / "budget.jsonl").record("dropped") def test_an_unserialisable_payload_does_not_raise(self, tmp_path: Path) -> None: + """A serialisation error is swallowed.""" target = tmp_path / "budget.jsonl" log = BudgetLog(path=target) with patch(f"{PL}.json.dumps", side_effect=ValueError("nope")): @@ -112,6 +127,7 @@ class TestBudgetLog: log.close() def test_a_write_failure_does_not_raise(self, tmp_path: Path) -> None: + """A write error after the first record is swallowed.""" target = tmp_path / "budget.jsonl" log = BudgetLog(path=target) log.record("first") @@ -120,39 +136,49 @@ class TestBudgetLog: log.close() -class TestTickJournal: - def _journal(self, tmp_path: Path, **kwargs: float) -> tuple[TickJournal, Path]: +@pytest.fixture(name="make_journal") +def fixture_make_journal(tmp_path: Path) -> Iterator[_MakeJournal]: + """Build journals over a tmp budget log; close every log afterwards.""" + logs: list[BudgetLog] = [] + + def make(**kwargs: float) -> tuple[TickJournal, Path]: target = tmp_path / "budget.jsonl" log = BudgetLog(path=target) - self._logs.append(log) + logs.append(log) return TickJournal(log, **kwargs), target - @pytest.fixture(autouse=True) - def _close_logs(self) -> Iterator[None]: - self._logs: list[BudgetLog] = [] - yield - for log in self._logs: - log.close() + yield make + for log in logs: + log.close() + + +class TestTickJournal: + """The tick journal records verdict changes and sparse heartbeats only.""" def _observe(self, journal: TickJournal, qualifying: set[int], at: float) -> None: journal.observe(qualifying, STATE, rules=RULES, now_monotonic=at) - def test_a_changed_verdict_is_recorded(self, tmp_path: Path) -> None: - journal, target = self._journal(tmp_path) + def test_a_changed_verdict_is_recorded(self, make_journal: _MakeJournal) -> None: + """A new verdict writes a verdict-change record.""" + journal, target = make_journal() self._observe(journal, {11}, 0.0) events = [r["event"] for r in _records(target)] assert events == [EVENT_VERDICT_CHANGE] - def test_an_unchanged_verdict_is_not_repeated(self, tmp_path: Path) -> None: - journal, target = self._journal(tmp_path, heartbeat=300.0) + def test_an_unchanged_verdict_is_not_repeated( + self, make_journal: _MakeJournal + ) -> None: + """Repeating the same verdict writes nothing more.""" + journal, target = make_journal(heartbeat=300.0) for tick in range(5): self._observe(journal, {11}, float(tick) * 3.0) assert len(_records(target)) == 1 def test_a_heartbeat_is_written_once_the_interval_passes( - self, tmp_path: Path + self, make_journal: _MakeJournal ) -> None: - journal, target = self._journal(tmp_path, heartbeat=10.0) + """An engaged verdict gets a heartbeat after the interval.""" + journal, target = make_journal(heartbeat=10.0) self._observe(journal, {11}, 0.0) self._observe(journal, {11}, 5.0) self._observe(journal, {11}, 50.0) @@ -161,16 +187,22 @@ class TestTickJournal: EVENT_HEARTBEAT, ] - def test_ordinary_desktop_use_produces_no_heartbeat(self, tmp_path: Path) -> None: - journal, target = self._journal(tmp_path, heartbeat=0.0) + def test_ordinary_desktop_use_produces_no_heartbeat( + self, make_journal: _MakeJournal + ) -> None: + """An idle desktop never gets heartbeats.""" + journal, target = make_journal(heartbeat=0.0) self._observe(journal, set(), 0.0) self._observe(journal, set(), 1000.0) assert len(_records(target)) == 1 - def test_the_snapshot_names_the_qualifying_processes(self, tmp_path: Path) -> None: + def test_the_snapshot_names_the_qualifying_processes( + self, make_journal: _MakeJournal + ) -> None: + """A record lists the sorted qualifying pids and budget numbers.""" # Without these, accrual from an orphaned helper process cannot be # attributed after the fact. - journal, target = self._journal(tmp_path) + journal, target = make_journal() self._observe(journal, {22, 11}, 0.0) record = _records(target)[0] assert record["qualifying"] == [11, 22] diff --git a/steam_backlog_enforcer/tests/test_playtime_notify.py b/steam_backlog_enforcer/tests/test_playtime_notify.py index 62a84a8..c441bdb 100644 --- a/steam_backlog_enforcer/tests/test_playtime_notify.py +++ b/steam_backlog_enforcer/tests/test_playtime_notify.py @@ -17,17 +17,24 @@ def test_short_duration(seconds: int, shown: str) -> None: class TestHumanise: + """Durations render as the words the playtime warning speaks.""" + def test_seconds(self) -> None: + """Under a minute reads in seconds.""" assert _humanise(30) == "30 seconds" def test_exact_minute_boundary(self) -> None: + """Exactly sixty seconds already reads in minutes.""" assert _humanise(60) == "1 minutes" def test_minutes(self) -> None: + """Several minutes read as a minute count.""" assert _humanise(300) == "5 minutes" def test_one_hour_is_special_cased(self) -> None: + """One hour reads as the singular ``1 hour``.""" assert _humanise(3600) == "1 hour" def test_thirty_minutes(self) -> None: + """Half an hour stays in minutes.""" assert _humanise(1800) == "30 minutes" diff --git a/steam_backlog_enforcer/tests/test_playtime_part2.py b/steam_backlog_enforcer/tests/test_playtime_part2.py index 33668be..3cd698b 100644 --- a/steam_backlog_enforcer/tests/test_playtime_part2.py +++ b/steam_backlog_enforcer/tests/test_playtime_part2.py @@ -20,11 +20,15 @@ INTERVAL = 3.0 def _state_ticked(seconds_ago: float, **kwargs: object) -> PlaytimeState: + """Return a state whose last tick was seconds_ago before NOW.""" return PlaytimeState(last_tick_at=NOW.timestamp() - seconds_ago, **kwargs) class TestAccumulate: + """Each tick credits the time since the last one, clamped and never negative.""" + def test_first_ever_tick_credits_nothing(self) -> None: + """The first tick ever only starts the clock.""" out = accumulate( PlaytimeState(last_tick_at=0.0), now=NOW, @@ -35,10 +39,12 @@ class TestAccumulate: assert out.last_tick_at == NOW.timestamp() def test_normal_tick_credits_the_delta(self) -> None: + """A regular qualifying tick credits the elapsed seconds.""" out = accumulate(_state_ticked(3.0), now=NOW, qualifying={1}, interval=INTERVAL) assert out.seconds == 3.0 def test_idle_tick_credits_nothing_but_advances_the_clock(self) -> None: + """With no qualifying game the clock moves but nothing is credited.""" out = accumulate( _state_ticked(3.0), now=NOW, qualifying=set(), interval=INTERVAL ) @@ -53,6 +59,7 @@ class TestAccumulate: assert out.seconds == 6.0 def test_gap_exactly_at_the_clamp(self) -> None: + """A gap equal to the clamp is credited in full.""" out = accumulate(_state_ticked(6.0), now=NOW, qualifying={1}, interval=INTERVAL) assert out.seconds == 6.0 @@ -68,26 +75,33 @@ class TestAccumulate: assert out.last_tick_at == NOW.timestamp() def test_zero_delta(self) -> None: + """A zero-length tick credits nothing.""" out = accumulate(_state_ticked(0.0), now=NOW, qualifying={1}, interval=INTERVAL) assert out.seconds == 0.0 def test_accrues_across_ticks(self) -> None: + """Credit adds to the seconds already counted.""" state = PlaytimeState(seconds=100.0, last_tick_at=NOW.timestamp() - 3.0) out = accumulate(state, now=NOW, qualifying={1}, interval=INTERVAL) assert out.seconds == 103.0 def test_is_pure(self) -> None: + """accumulate returns a new state and leaves its input alone.""" state = _state_ticked(3.0) accumulate(state, now=NOW, qualifying={1}, interval=INTERVAL) assert state.seconds == 0.0 class TestRollOver: + """A new day resets the counter, block and warnings.""" + def test_same_day_is_unchanged(self) -> None: + """The same day key returns the very same state.""" state = PlaytimeState(day_key="2026-07-27", seconds=50.0, blocked_at=9.0) assert roll_over(state, day_key="2026-07-27") is state def test_new_day_resets_counter_and_block(self) -> None: + """A new day key clears seconds, block and fired warnings.""" state = PlaytimeState( day_key="2026-07-27", seconds=50.0, @@ -106,22 +120,29 @@ class TestRollOver: assert roll_over(state, day_key="2026-07-28").last_tick_at == 1234.0 def test_empty_day_key_rolls_over(self) -> None: + """A state with no day key adopts the new day.""" assert roll_over(PlaytimeState(), day_key="2026-07-28").day_key == "2026-07-28" class TestPendingWarning: + """The next warning is the largest threshold crossed and not yet fired.""" + def _rules(self, *, demo: bool = False) -> object: + """Return the default production or demo rules.""" return rules_for(Config(), demo=demo) def test_none_when_far_from_the_budget(self) -> None: + """Far from the budget no warning is due.""" state = PlaytimeState(seconds=0.0) assert pending_warning(state, self._rules()) is None def test_fires_at_the_first_threshold(self) -> None: + """Reaching the one-hour mark fires the one-hour warning.""" state = PlaytimeState(seconds=8 * 3600 - 3600) assert pending_warning(state, self._rules()) == 3600 def test_skips_already_fired_thresholds(self) -> None: + """A threshold already warned about is skipped.""" state = PlaytimeState(seconds=8 * 3600 - 1800, warned_seconds=[3600]) assert pending_warning(state, self._rules()) == 1800 @@ -131,15 +152,18 @@ class TestPendingWarning: assert pending_warning(state, self._rules()) == 3600 def test_none_when_all_fired(self) -> None: + """Once every threshold fired nothing is due.""" state = PlaytimeState( seconds=8 * 3600 - 100, warned_seconds=[3600, 1800, 600, 300] ) assert pending_warning(state, self._rules()) is None def test_demo_thresholds(self) -> None: + """Demo rules use their own short thresholds.""" state = PlaytimeState(seconds=35.0) assert pending_warning(state, self._rules(demo=True)) == 30 def test_over_budget_still_reports(self) -> None: + """Past the budget the last unfired threshold still fires.""" state = PlaytimeState(seconds=8 * 3600 + 500, warned_seconds=[3600, 1800, 600]) assert pending_warning(state, self._rules()) == 300 diff --git a/steam_backlog_enforcer/tests/test_playtime_policy.py b/steam_backlog_enforcer/tests/test_playtime_policy.py index 1d6b103..8a39714 100644 --- a/steam_backlog_enforcer/tests/test_playtime_policy.py +++ b/steam_backlog_enforcer/tests/test_playtime_policy.py @@ -5,7 +5,6 @@ Split out of ``test_playtime_tick.py`` to hold both files inside the tests exercise the loop that feeds it. """ -from contextlib import ExitStack from dataclasses import replace from datetime import ( datetime, @@ -14,8 +13,6 @@ from datetime import ( ) from unittest.mock import patch -import pytest - from steam_backlog_enforcer._playtime import ( _policy, ) @@ -25,46 +22,25 @@ from steam_backlog_enforcer._playtime_state import ( ) from steam_backlog_enforcer.config import Config -PKG = "steam_backlog_enforcer._playtime" +CUTOFF = "steam_backlog_enforcer._playtime_cutoff" LOCAL = timezone(timedelta(hours=2)) NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL) TODAY = "2026-07-27" -@pytest.fixture -def quiet_tick() -> object: - """Neutralise every side effect the tick can produce.""" - with ExitStack() as stack: - # Each name is patched in the module that *resolves* it, not in the - # package that re-exports it -- otherwise the patch never bites. - cutoff = "steam_backlog_enforcer._playtime_cutoff" - mocks = { - name: stack.enter_context(patch(f"{where}.{name}")) - for name, where in ( - ("reconcile", cutoff), - ("request_steam_shutdown", cutoff), - ("kill_gaming_processes", cutoff), - ("notify_desktop_user", cutoff), - ("mounted_targets", PKG), - ) - } - mocks["mounted_targets"].return_value = set() - stack.enter_context(patch(f"{PKG}.is_total_block_active", return_value=False)) - yield mocks - - def _rules(*, demo: bool = False, **cfg: object) -> object: return rules_for(Config(**cfg), demo=demo) class TestPolicyBelowBudget: + """Under budget the policy keeps games mounted and warns near the end.""" + def test_releases_and_warns(self) -> None: + """An hour left releases the block and sends the one-hour warning.""" state = PlaytimeState(day_key=TODAY, seconds=8 * 3600 - 3600) with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec, - patch( - "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" - ) as mock_notify, + patch(f"{CUTOFF}.reconcile") as mock_rec, + patch(f"{CUTOFF}.notify_desktop_user") as mock_notify, ): out = _policy(state, _rules(), now=NOW) mock_rec.assert_called_once_with(should_block=False) @@ -72,12 +48,11 @@ class TestPolicyBelowBudget: assert out.warned_seconds == [3600] def test_no_warning_when_far_from_budget(self) -> None: + """With the whole budget left no warning is sent.""" state = PlaytimeState(day_key=TODAY, seconds=0.0) with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile"), - patch( - "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" - ) as mock_notify, + patch(f"{CUTOFF}.reconcile"), + patch(f"{CUTOFF}.notify_desktop_user") as mock_notify, ): out = _policy(state, _rules(), now=NOW) mock_notify.assert_not_called() @@ -85,7 +60,10 @@ class TestPolicyBelowBudget: class TestPolicyAtOrOverBudget: + """At or over budget the policy cuts off, then holds the block.""" + def test_engages_the_cutoff_when_not_yet_blocked(self) -> None: + """The first over-budget tick engages the cutoff.""" state = PlaytimeState(day_key=TODAY, seconds=10**6) with ( patch("steam_backlog_enforcer._playtime._begin_cutoff") as mock_begin, @@ -96,6 +74,7 @@ class TestPolicyAtOrOverBudget: mock_sustain.assert_not_called() def test_sustains_the_block_once_engaged(self) -> None: + """Once blocked, later ticks sustain the block instead of re-engaging.""" state = PlaytimeState(day_key=TODAY, seconds=10**6, blocked_at=1.0) with ( patch("steam_backlog_enforcer._playtime._begin_cutoff") as mock_begin, @@ -107,14 +86,14 @@ class TestPolicyAtOrOverBudget: class TestPolicyEnforcementDisabled: + """With enforcement off the policy never blocks.""" + def test_releases_but_does_not_block(self) -> None: """Disabling must never come to mean 'blocked forever'.""" state = PlaytimeState(day_key=TODAY, seconds=10**6) with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec, - patch( - "steam_backlog_enforcer._playtime_cutoff.request_steam_shutdown" - ) as mock_shutdown, + patch(f"{CUTOFF}.reconcile") as mock_rec, + patch(f"{CUTOFF}.request_steam_shutdown") as mock_shutdown, ): out = _policy(state, _rules(playtime_enforcement=False), now=NOW) mock_rec.assert_called_once_with(should_block=False) @@ -142,8 +121,8 @@ class TestPolicyBudgetRoseMidDay: def test_the_block_is_released(self) -> None: """reconcile is asked to unmount, which is what re-enables gaming.""" with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile") as mock_rec, - patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"), + patch(f"{CUTOFF}.reconcile") as mock_rec, + patch(f"{CUTOFF}.notify_desktop_user"), ): _policy(self._blocked_at_six_hours(), _rules(), now=NOW) mock_rec.assert_called_once_with(should_block=False) @@ -151,8 +130,8 @@ class TestPolicyBudgetRoseMidDay: def test_blocked_at_is_cleared(self) -> None: """Otherwise the UI reports "blocked" while gaming works fine.""" with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile"), - patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"), + patch(f"{CUTOFF}.reconcile"), + patch(f"{CUTOFF}.notify_desktop_user"), ): out = _policy(self._blocked_at_six_hours(), _rules(), now=NOW) assert out.is_blocked() is False @@ -163,10 +142,8 @@ class TestPolicyBudgetRoseMidDay: Left alone, the extra two hours would arrive with no warning at all. """ with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile"), - patch( - "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" - ) as mock_notify, + patch(f"{CUTOFF}.reconcile"), + patch(f"{CUTOFF}.notify_desktop_user") as mock_notify, ): out = _policy(self._blocked_at_six_hours(), _rules(), now=NOW) # Two hours remain, so nothing is due yet -- but the record is clear, @@ -179,8 +156,8 @@ class TestPolicyBudgetRoseMidDay: spent = 8 * 3600 - 3000.0 state = PlaytimeState(day_key=TODAY, seconds=spent, warned_seconds=[3600]) with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile"), - patch("steam_backlog_enforcer._playtime_cutoff.notify_desktop_user"), + patch(f"{CUTOFF}.reconcile"), + patch(f"{CUTOFF}.notify_desktop_user"), ): out = _policy(state, _rules(), now=NOW) assert out.warned_seconds == [3600] @@ -196,10 +173,8 @@ class TestPolicyBudgetRoseMidDay: day_key=TODAY, seconds=5 * 3600.0, warned_seconds=[3600, 1800, 600, 300] ) with ( - patch("steam_backlog_enforcer._playtime_cutoff.reconcile"), - patch( - "steam_backlog_enforcer._playtime_cutoff.notify_desktop_user" - ) as mock_notify, + patch(f"{CUTOFF}.reconcile"), + patch(f"{CUTOFF}.notify_desktop_user") as mock_notify, ): out = _policy(state, _rules(), now=NOW) mock_notify.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_playtime_proctree.py b/steam_backlog_enforcer/tests/test_playtime_proctree.py index 5ee6f24..aeb2b38 100644 --- a/steam_backlog_enforcer/tests/test_playtime_proctree.py +++ b/steam_backlog_enforcer/tests/test_playtime_proctree.py @@ -6,9 +6,6 @@ import os from typing import TYPE_CHECKING from unittest.mock import patch -if TYPE_CHECKING: - from pathlib import Path - from steam_backlog_enforcer._playtime_kill import ( _child_map, _own_process_chain, @@ -16,11 +13,17 @@ from steam_backlog_enforcer._playtime_kill import ( descendant_pids, ) +if TYPE_CHECKING: + from pathlib import Path + PKG = "steam_backlog_enforcer._playtime_kill" class TestReadPpid: + """The parent pid is parsed from /proc//stat.""" + def _write_stat(self, tmp_path: Path, pid: int, comm: str, ppid: int) -> None: + """Write a fake stat line for pid with the given comm and ppid.""" proc = tmp_path / "playtime_proc" / str(pid) proc.mkdir(parents=True, exist_ok=True) (proc / "stat").write_text( @@ -28,6 +31,7 @@ class TestReadPpid: ) def test_reads_simple_comm(self, tmp_path: Path) -> None: + """A plain comm yields the ppid field.""" self._write_stat(tmp_path, 100, "steam", 1) assert _read_ppid(100) == 1 @@ -37,15 +41,18 @@ class TestReadPpid: assert _read_ppid(101) == 42 def test_missing_file(self) -> None: + """A pid with no stat file has no parent.""" assert _read_ppid(999999) is None def test_malformed_stat_too_few_fields(self, tmp_path: Path) -> None: + """A stat line cut before the ppid gives None.""" proc = tmp_path / "playtime_proc" / "102" proc.mkdir(parents=True, exist_ok=True) (proc / "stat").write_text("102 (x) S", encoding="utf-8") assert _read_ppid(102) is None def test_non_integer_ppid(self, tmp_path: Path) -> None: + """A non-numeric ppid field gives None.""" proc = tmp_path / "playtime_proc" / "103" proc.mkdir(parents=True, exist_ok=True) (proc / "stat").write_text("103 (x) S notanumber 1", encoding="utf-8") @@ -53,12 +60,16 @@ class TestReadPpid: class TestChildMap: + """The child map groups every /proc pid under its parent.""" + def _write(self, tmp_path: Path, pid: int, ppid: int) -> None: + """Write a minimal fake stat file for pid under ppid.""" proc = tmp_path / "playtime_proc" / str(pid) proc.mkdir(parents=True, exist_ok=True) (proc / "stat").write_text(f"{pid} (p) S {ppid} 1 0", encoding="utf-8") def test_builds_mapping(self, tmp_path: Path) -> None: + """Children are listed under their parent pid.""" self._write(tmp_path, 10, 1) self._write(tmp_path, 11, 10) self._write(tmp_path, 12, 10) @@ -66,12 +77,14 @@ class TestChildMap: assert sorted(mapping[10]) == [11, 12] def test_skips_non_digit_entries(self, tmp_path: Path) -> None: + """Non-pid /proc entries like cpuinfo and self are ignored.""" (tmp_path / "playtime_proc" / "cpuinfo").touch() (tmp_path / "playtime_proc" / "self").mkdir() self._write(tmp_path, 20, 1) assert 1 in _child_map() def test_unreadable_proc_returns_empty(self) -> None: + """An unlistable /proc gives an empty map.""" with patch( "steam_backlog_enforcer._playtime_block.Path.iterdir", side_effect=OSError("nope"), @@ -79,13 +92,17 @@ class TestChildMap: assert _child_map() == {} def test_entry_without_stat_is_skipped(self, tmp_path: Path) -> None: + """A pid directory with no stat file is left out.""" (tmp_path / "playtime_proc" / "30").mkdir() self._write(tmp_path, 31, 1) assert 30 not in {c for kids in _child_map().values() for c in kids} class TestDescendantPids: + """Descendants are collected below the roots, minus our own chain.""" + def test_walks_the_tree(self) -> None: + """Children and grandchildren are all returned.""" tree = {100: [101, 102], 101: [103]} with ( patch( @@ -99,6 +116,7 @@ class TestDescendantPids: assert descendant_pids({100}) == {101, 102, 103} def test_excludes_the_roots_themselves(self) -> None: + """The root pids are not in their own descendant set.""" with ( patch( "steam_backlog_enforcer._playtime_kill._child_map", @@ -112,6 +130,7 @@ class TestDescendantPids: assert 100 not in descendant_pids({100}) def test_never_returns_our_own_process(self) -> None: + """Pids in our own process chain are never returned.""" with ( patch( "steam_backlog_enforcer._playtime_kill._child_map", @@ -125,6 +144,7 @@ class TestDescendantPids: assert descendant_pids({100}) == {102} def test_protected_root_is_not_walked(self) -> None: + """A root in our own chain is not walked at all.""" with ( patch( "steam_backlog_enforcer._playtime_kill._child_map", @@ -138,6 +158,7 @@ class TestDescendantPids: assert descendant_pids({100}) == set() def test_no_children(self) -> None: + """A root with no children yields nothing.""" with ( patch("steam_backlog_enforcer._playtime_kill._child_map", return_value={}), patch( @@ -148,6 +169,7 @@ class TestDescendantPids: assert descendant_pids({100}) == set() def test_cycle_terminates(self) -> None: + """A cyclic tree ends the walk instead of looping.""" with ( patch( "steam_backlog_enforcer._playtime_kill._child_map", @@ -161,6 +183,7 @@ class TestDescendantPids: assert descendant_pids({1}) == {2, 1} def test_depth_is_bounded(self) -> None: + """The walk stops at the depth cap.""" chain = {i: [i + 1] for i in range(1, 100)} with ( patch( @@ -175,13 +198,17 @@ class TestDescendantPids: class TestOwnProcessChain: + """Our own chain runs from this pid up to init.""" + def test_includes_self(self) -> None: + """The chain always holds our own pid.""" with patch( "steam_backlog_enforcer._playtime_kill._read_ppid", return_value=None ): assert _own_process_chain() == {os.getpid()} def test_walks_to_init(self) -> None: + """The chain follows ppids until pid 0.""" with patch( "steam_backlog_enforcer._playtime_kill._read_ppid", side_effect=[500, 1, 0] ): @@ -189,6 +216,7 @@ class TestOwnProcessChain: assert chain == {os.getpid(), 500, 1} def test_stops_on_repeat(self) -> None: + """A ppid that repeats ends the walk.""" with patch( "steam_backlog_enforcer._playtime_kill._read_ppid", return_value=os.getpid() ): diff --git a/steam_backlog_enforcer/tests/test_playtime_rules_for.py b/steam_backlog_enforcer/tests/test_playtime_rules_for.py new file mode 100644 index 0000000..6cdfe98 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_playtime_rules_for.py @@ -0,0 +1,70 @@ +"""Tests for resolving playtime rules from the config, production and demo.""" + +from __future__ import annotations + +from dataclasses import FrozenInstanceError + +import pytest + +from steam_backlog_enforcer._playtime_state import rules_for +from steam_backlog_enforcer.config import Config +from steam_backlog_enforcer.tests._no_workout_http import fixed_budget + + +class TestRulesFor: + """rules_for maps the config and mode onto PlaytimeRules.""" + + def test_production_budget_comes_from_the_resolver(self) -> None: + """Production takes its budget from the budget resolver.""" + with fixed_budget(100.0): + rules = rules_for(Config(), demo=False) + assert rules.budget_seconds == 100.0 + assert rules.demo is False + + def test_demo_budget_is_sixty_seconds(self) -> None: + """Demo always gets a 60 s budget.""" + rules = rules_for(Config(), demo=True) + assert rules.budget_seconds == 60.0 + assert rules.demo is True + + def test_demo_warns_in_seconds_not_minutes(self) -> None: + """Demo warning points are seconds, production ones minutes.""" + assert rules_for(Config(), demo=True).warn_at == (30, 10) + assert rules_for(Config(), demo=False).warn_at == (3600, 1800, 600, 300) + + def test_demo_escalates_to_sigkill_sooner(self) -> None: + """Demo escalates to SIGKILL after 10 s, production after 30 s.""" + assert rules_for(Config(), demo=True).sigkill_after == 10.0 + assert rules_for(Config(), demo=False).sigkill_after == 30.0 + + def test_launcher_toggle_is_carried_through(self) -> None: + """count_launcher_processes becomes count_launchers.""" + assert ( + rules_for( + Config(count_launcher_processes=False), demo=False + ).count_launchers + is False + ) + assert ( + rules_for(Config(count_launcher_processes=True), demo=False).count_launchers + is True + ) + + def test_enforcement_toggle_is_carried_through(self) -> None: + """playtime_enforcement off becomes enforcement off.""" + assert ( + rules_for(Config(playtime_enforcement=False), demo=False).enforcement + is False + ) + + def test_demo_uses_the_same_qualifying_predicate(self) -> None: + """Demo may differ in budget and warnings only.""" + prod = rules_for(Config(count_launcher_processes=True), demo=False) + demo = rules_for(Config(count_launcher_processes=True), demo=True) + assert prod.count_launchers == demo.count_launchers + + def test_rules_are_frozen(self) -> None: + """Frozen so the decision helpers stay pure and cheap to reason about.""" + rules = rules_for(Config(), demo=False) + with pytest.raises(FrozenInstanceError): + rules.budget_seconds = 1.0 diff --git a/steam_backlog_enforcer/tests/test_playtime_session.py b/steam_backlog_enforcer/tests/test_playtime_session.py index 6eef1d4..9e38adc 100644 --- a/steam_backlog_enforcer/tests/test_playtime_session.py +++ b/steam_backlog_enforcer/tests/test_playtime_session.py @@ -2,27 +2,89 @@ from __future__ import annotations +from typing import TYPE_CHECKING + +import pytest + from steam_backlog_enforcer import _playtime_log as log_mod +from steam_backlog_enforcer import _playtime_session as session_mod from steam_backlog_enforcer._playtime_history import HistoryWriter -from steam_backlog_enforcer._playtime_log import TickJournal +from steam_backlog_enforcer._playtime_log import BudgetLog, TickJournal from steam_backlog_enforcer._playtime_session import new_session +from steam_backlog_enforcer._shadow_log import ShadowJournal + +if TYPE_CHECKING: + from pathlib import Path + + +class _Wiring: + """Records which log file each journal new_session builds writes to.""" + + def __init__(self) -> None: + """Start with nothing built.""" + self.log_paths: dict[int, Path] = {} + self.journal_paths: list[Path] = [] + self.shadow_paths: list[Path] = [] + + +@pytest.fixture(name="wiring") +def fixture_wiring(monkeypatch: pytest.MonkeyPatch) -> _Wiring: + """Swap the session's log and journal classes for recording subclasses.""" + wiring = _Wiring() + + class SpyLog(BudgetLog): + """A budget log that remembers the path it was opened on.""" + + def __init__(self, *, path: Path) -> None: + """Record the path, then build the real log.""" + super().__init__(path=path) + wiring.log_paths[id(self)] = path + + class SpyTick(TickJournal): + """A tick journal that reports which log it writes through.""" + + def __init__(self, log: BudgetLog) -> None: + """Record the log's path, then build the real journal.""" + super().__init__(log) + wiring.journal_paths.append(wiring.log_paths[id(log)]) + + class SpyShadow(ShadowJournal): + """A shadow journal that reports which log it writes through.""" + + def __init__(self, log: BudgetLog) -> None: + """Record the log's path, then build the real journal.""" + super().__init__(log) + wiring.shadow_paths.append(wiring.log_paths[id(log)]) + + monkeypatch.setattr(session_mod, "BudgetLog", SpyLog) + monkeypatch.setattr(session_mod, "TickJournal", SpyTick) + monkeypatch.setattr(session_mod, "ShadowJournal", SpyShadow) + return wiring class TestNewSession: + """new_session wires the journals to the right audit trail.""" + def test_builds_a_journal_and_history(self) -> None: + """A session carries a tick journal and a history writer.""" session = new_session() assert isinstance(session.journal, TickJournal) assert isinstance(session.history, HistoryWriter) - def test_demo_journals_away_from_the_production_trail(self) -> None: - live = new_session() - demo = new_session(demo=True) - assert demo.journal._log._path == log_mod.BUDGET_DEMO_LOG_FILE - assert live.journal._log._path == log_mod.BUDGET_LOG_FILE - - def test_builds_a_shadow_journal_on_the_same_trail(self) -> None: - from steam_backlog_enforcer._shadow_log import ShadowJournal + def test_demo_journals_away_from_the_production_trail( + self, wiring: _Wiring + ) -> None: + """A demo session journals to the demo file, a live one to production.""" + new_session(demo=True) + new_session() + assert wiring.journal_paths == [ + log_mod.BUDGET_DEMO_LOG_FILE, + log_mod.BUDGET_LOG_FILE, + ] + def test_builds_a_shadow_journal_on_the_same_trail(self, wiring: _Wiring) -> None: + """The shadow journal writes through the game journal's own log.""" session = new_session() assert isinstance(session.shadow, ShadowJournal) - assert session.shadow._log._path == log_mod.BUDGET_LOG_FILE + assert wiring.shadow_paths == [log_mod.BUDGET_LOG_FILE] + assert len(wiring.log_paths) == 1 diff --git a/steam_backlog_enforcer/tests/test_playtime_tick.py b/steam_backlog_enforcer/tests/test_playtime_tick.py index 06cf70b..c4e601e 100644 --- a/steam_backlog_enforcer/tests/test_playtime_tick.py +++ b/steam_backlog_enforcer/tests/test_playtime_tick.py @@ -1,6 +1,7 @@ """Tests for the playtime tick state machine and cutoff sequence.""" from contextlib import ExitStack +from dataclasses import replace from datetime import ( datetime, timedelta, @@ -19,9 +20,10 @@ from steam_backlog_enforcer._playtime_state import ( load_state, rules_for, save_state, + state_path, ) from steam_backlog_enforcer.config import Config -from steam_backlog_enforcer.tests._fake_session import fake_session +from steam_backlog_enforcer.tests._fake_session import StubHistory, fake_session from steam_backlog_enforcer.tests._no_workout_http import fixed_budget PKG = "steam_backlog_enforcer._playtime" @@ -30,8 +32,8 @@ NOW = datetime(2026, 7, 27, 20, 0, tzinfo=LOCAL) TODAY = "2026-07-27" -@pytest.fixture -def quiet_tick() -> object: +@pytest.fixture(name="quiet_tick") +def fixture_quiet_tick() -> object: """Neutralise every side effect the tick can produce.""" with ExitStack() as stack: # Each name is patched in the module that *resolves* it, not in the @@ -60,13 +62,17 @@ def _rules(*, demo: bool = False, budget: float | None = None) -> object: class TestStateOrRecover: + """_state_or_recover loads state or fails closed without it.""" + def test_loads_existing_state(self) -> None: + """Stored state for today is loaded as is.""" save_state(PlaytimeState(day_key=TODAY, seconds=42.0), demo=False) with patch(f"{PKG}.mounted_targets", return_value=set()): out = _state_or_recover(_rules(), now=NOW) assert out.seconds == 42.0 def test_fresh_state_when_nothing_stored_and_nothing_mounted(self) -> None: + """With nothing stored and nothing mounted the day starts fresh.""" with patch(f"{PKG}.mounted_targets", return_value=set()): out = _state_or_recover(_rules(), now=NOW) assert out.seconds == 0.0 @@ -82,8 +88,7 @@ class TestStateOrRecover: assert out.is_blocked() is True def test_fails_closed_on_corrupt_state(self) -> None: - from steam_backlog_enforcer._playtime_state import state_path - + """A corrupt state file with mounts present recovers as blocked.""" state_path(demo=False).write_text("{bad", encoding="utf-8") with patch(f"{PKG}.mounted_targets", return_value={"/usr/bin/steam"}): out = _state_or_recover(_rules(budget=50.0), now=NOW) @@ -91,7 +96,11 @@ class TestStateOrRecover: class TestPlaytimeTick: - def test_accumulates_while_a_game_runs(self, quiet_tick: dict) -> None: + """playtime_tick accrues, records, rolls over and cuts off.""" + + @pytest.mark.usefixtures("quiet_tick") + def test_accumulates_while_a_game_runs(self) -> None: + """A tick with a game running adds the interval to the counter.""" save_state( PlaytimeState(day_key=TODAY, last_tick_at=NOW.timestamp() - 3.0), demo=False, @@ -104,31 +113,34 @@ class TestPlaytimeTick: playtime_tick(Config(), interval=3.0, session=fake_session()) assert load_state(demo=False).seconds == 3.0 - def test_records_the_day_in_the_history(self, quiet_tick: dict) -> None: - del quiet_tick + @pytest.mark.usefixtures("quiet_tick") + def test_records_the_day_in_the_history(self) -> None: + """Each tick records the day's total in the history.""" save_state( PlaytimeState(day_key=TODAY, last_tick_at=NOW.timestamp() - 3.0), demo=False, ) - session = fake_session() + history = StubHistory() + session = replace(fake_session(), history=history) with ( patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}), patch(f"{PKG}.datetime") as mock_dt, ): mock_dt.now.return_value = NOW playtime_tick(Config(), interval=3.0, session=session) - assert session.history.recorded == [(TODAY, 3.0)] + assert history.recorded == [(TODAY, 3.0)] - def test_records_the_day_even_under_a_total_block(self, quiet_tick: dict) -> None: + @pytest.mark.usefixtures("quiet_tick") + def test_records_the_day_even_under_a_total_block(self) -> None: """The counter still needs a history point while the block is up.""" - del quiet_tick save_state( PlaytimeState( day_key=TODAY, seconds=5.0, last_tick_at=NOW.timestamp() - 3.0 ), demo=False, ) - session = fake_session() + history = StubHistory() + session = replace(fake_session(), history=history) with ( patch(f"{PKG}.is_total_block_active", return_value=True), patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}), @@ -136,7 +148,7 @@ class TestPlaytimeTick: ): mock_dt.now.return_value = NOW playtime_tick(Config(), interval=3.0, session=session) - assert session.history.recorded == [(TODAY, 5.0)] + assert history.recorded == [(TODAY, 5.0)] def test_total_block_releases_and_stops_accruing(self, quiet_tick: dict) -> None: """Our mounts would make the total block's `pacman -R steam` fail.""" @@ -157,6 +169,7 @@ class TestPlaytimeTick: assert load_state(demo=False).seconds == 5.0 def test_rollover_clears_a_stale_block(self, quiet_tick: dict) -> None: + """A new day clears yesterday's block and counter.""" save_state( PlaytimeState( day_key="2026-07-26", @@ -179,6 +192,7 @@ class TestPlaytimeTick: quiet_tick["reconcile"].assert_called_with(should_block=False) def test_cutoff_engages_at_the_budget(self, quiet_tick: dict) -> None: + """Crossing the budget blocks and asks Steam to shut down.""" save_state( PlaytimeState( day_key=TODAY, seconds=59.0, last_tick_at=NOW.timestamp() - 3.0 @@ -198,7 +212,8 @@ class TestPlaytimeTick: assert stored.is_blocked() is True quiet_tick["request_steam_shutdown"].assert_called_once() - def test_demo_uses_a_separate_state_file(self, quiet_tick: dict) -> None: + @pytest.mark.usefixtures("quiet_tick") + def test_demo_uses_a_separate_state_file(self) -> None: """A demo run must never consume the real day's budget.""" with ( patch(f"{PKG}.qualifying_pids", return_value={7: "app:440"}), @@ -209,7 +224,9 @@ class TestPlaytimeTick: assert load_state(demo=True) is not None assert load_state(demo=False) is None - def test_first_tick_writes_state(self, quiet_tick: dict) -> None: + @pytest.mark.usefixtures("quiet_tick") + def test_first_tick_writes_state(self) -> None: + """The very first tick writes a state file for today.""" with ( patch(f"{PKG}.qualifying_pids", return_value={}), patch(f"{PKG}.datetime") as mock_dt, diff --git a/steam_backlog_enforcer/tests/test_polls_tracking.py b/steam_backlog_enforcer/tests/test_polls_tracking.py index b9d9bd2..d35f273 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking.py @@ -15,23 +15,12 @@ from steam_backlog_enforcer._hltb_types import ( load_hltb_polls_cache, save_hltb_cache, ) -from steam_backlog_enforcer.config import State from steam_backlog_enforcer.steam_api import GameInfo if TYPE_CHECKING: from pathlib import Path _TYPES = "steam_backlog_enforcer._hltb_types" -_CMD = "steam_backlog_enforcer._cmd_done" -_SCAN = "steam_backlog_enforcer.scanning" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s class TestCacheSchema: diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py index ef113e5..fe6d255 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_backfill_polls_for_finished.py @@ -6,146 +6,75 @@ import json from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer import _cmd_done -from steam_backlog_enforcer.config import State +from steam_backlog_enforcer._cmd_done import _backfill_polls_for_finished +from steam_backlog_enforcer.tests._polls_helpers import ( + CMD, + TYPES, + filling_fetch, + hltb_cache_patched, + poll_state, + write_cache, +) if TYPE_CHECKING: from pathlib import Path -_TYPES = "steam_backlog_enforcer._hltb_types" -_CMD = "steam_backlog_enforcer._cmd_done" -_CONF = "steam_backlog_enforcer._hltb_confidence" -_SCAN = "steam_backlog_enforcer.scanning" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s - class TestBackfillPollsForFinished: """Tests for Backfill Polls For Finished.""" def test_no_missing_returns_existing(self, tmp_path: Path) -> None: """Test no missing returns existing.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 1.0, "polls": 5}}), encoding="utf-8" - ) + cache_file = write_cache(tmp_path, {"1": {"hours": 1.0, "polls": 5}}) with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), + patch(f"{TYPES}.HLTB_CACHE_FILE", cache_file), + patch(f"{CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), ): - result = _cmd_done._backfill_polls_for_finished(_state([1])) + result = _backfill_polls_for_finished(poll_state([1])) assert result == {1: 5} def test_no_snapshot_no_missing(self) -> None: """Test no snapshot no missing.""" with ( - patch(f"{_CMD}.load_hltb_polls_cache", return_value={}), - patch(f"{_CMD}.load_snapshot", return_value=None), + patch(f"{CMD}.load_hltb_polls_cache", return_value={}), + patch(f"{CMD}.load_snapshot", return_value=None), ): - assert _cmd_done._backfill_polls_for_finished(_state([1])) == {} + assert _backfill_polls_for_finished(poll_state([1])) == {} def test_missing_triggers_fetch(self, tmp_path: Path) -> None: """Test missing triggers fetch.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8" - ) - - 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]: - """Test fake search: write into the caches it is handed.""" - for aid, _name in games: - cache[aid], polls[aid], count_comp[aid] = 2.0, 9, 0 - return [] - + cache_file = write_cache(tmp_path, {"1": {"hours": 2.0, "polls": 0}}) with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ), - patch(f"{_CMD}._echo"), + hltb_cache_patched(cache_file, tmp_path, filling_fetch(2.0, 9)), + patch(f"{CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), + patch(f"{CMD}._echo"), ): - result = _cmd_done._backfill_polls_for_finished(_state([1])) + result = _backfill_polls_for_finished(poll_state([1])) assert result == {1: 9} def test_extra_app_id_with_zero_polls_added(self, tmp_path: Path) -> None: """Test extra app id with zero polls added.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8" - ) - - 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]: - """Test fake search: write into the caches it is handed.""" - for aid, _name in games: - cache[aid], polls[aid], count_comp[aid] = 1.0, 4, 0 - return [] - + cache_file = write_cache(tmp_path, {"7": {"hours": 1.0, "polls": 0}}) with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ), - patch(f"{_CMD}._echo"), + hltb_cache_patched(cache_file, tmp_path, filling_fetch(1.0, 4)), + patch(f"{CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), + patch(f"{CMD}._echo"), ): - result = _cmd_done._backfill_polls_for_finished( - _state([], current=7), extra_app_id=7 + result = _backfill_polls_for_finished( + poll_state([], current=7), extra_app_id=7 ) assert result == {7: 4} def test_preserves_prior_hours_on_miss(self, tmp_path: Path) -> None: """Test preserves prior hours on miss.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8" - ) - - 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]: - """Test fake search: a clean miss (hours -1, polls 0).""" - for aid, _name in games: - cache[aid], polls[aid], count_comp[aid] = -1, 0, 0 - return [] - + cache_file = write_cache(tmp_path, {"3": {"hours": 4.0, "polls": 0}}) + # A clean miss: hours -1, polls 0. with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ), - patch(f"{_CMD}._echo"), + hltb_cache_patched(cache_file, tmp_path, filling_fetch(-1, 0)), + patch(f"{CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), + patch(f"{CMD}._echo"), ): - _cmd_done._backfill_polls_for_finished(_state([3])) + _backfill_polls_for_finished(poll_state([3])) # Prior hours should be preserved on miss. final = json.loads(cache_file.read_text(encoding="utf-8")) assert final["3"]["hours"] == 4.0 diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py index 0730066..2dd126f 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_part2.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py @@ -2,32 +2,27 @@ from __future__ import annotations +from dataclasses import replace import json from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer import _scanning_confidence, scanning -from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer._scanning_confidence import _backfill_polls_for_finished +from steam_backlog_enforcer.tests._polls_helpers import ( + SCAN, + TYPES, + echo_sink, + filling_fetch, + hltb_cache_patched, + make_game, + poll_confidence_echoes, + poll_state, + write_cache, +) if TYPE_CHECKING: from pathlib import Path -_TYPES = "steam_backlog_enforcer._hltb_types" -_CMD = "steam_backlog_enforcer._cmd_done" -_CONF = "steam_backlog_enforcer._hltb_confidence" -_SCAN = "steam_backlog_enforcer.scanning" -_SCANCONF = "steam_backlog_enforcer._scanning_confidence" -_POLLS = "steam_backlog_enforcer._polls_reporting" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s - class TestScanningPollsIntegration: """Tests for Scanning Polls Integration.""" @@ -37,190 +32,66 @@ class TestScanningPollsIntegration: """Test do scan kept assignment reports.""" echoed: list[str] = [] games = [ - GameInfo( - app_id=1, - name="X", - total_achievements=10, - unlocked_achievements=2, - playtime_minutes=0, - completionist_hours=5.0, - comp_100_count=20, + replace( + make_game(1, "X", unlocked=2, polls=(20, 0)), completionist_hours=5.0 ) ] - state = _state([], current=1) + state = poll_state([], current=1) with ( - patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_SCAN}._report_poll_confidence") as mock_report, + echo_sink(SCAN, echoed) as mock_echo, + patch(f"{SCAN}._report_poll_confidence") as mock_report, ): # Directly invoke just the kept-assignment branch. current = next((g for g in games if g.app_id == state.current_app_id), None) assert current is not None - scanning._echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})") - scanning._report_poll_confidence(current, games, state) + # The patched names, as scanning's branch would call them. + mock_echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})") + mock_report(current, games, state) assert any("CURRENT" in s for s in echoed) mock_report.assert_called_once() def test_report_poll_confidence_new_low(self) -> None: """Test report poll confidence new low.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=0, + chosen = make_game(1, "Chosen") + games = [chosen, make_game(2, "Old", unlocked=10)] + echoed = poll_confidence_echoes( + chosen, games, {1: 1, 2: 5}, poll_state([2], current=1) ) - games = [ - chosen, - GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ), - ] - with ( - patch( - f"{_POLLS}._backfill_polls_for_finished", - return_value={1: 1, 2: 5}, - ), - patch( - f"{_POLLS}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence(chosen, games, _state([2], current=1)) assert any("NEW LOW" in s for s in echoed) assert chosen.comp_100_count == 1 def test_report_poll_confidence_no_history(self) -> None: """Test report poll confidence no history.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=4, + chosen = make_game(1, "Chosen", polls=(4, 0)) + echoed = poll_confidence_echoes( + chosen, [chosen], {1: 4}, poll_state([], current=1) ) - with ( - patch( - f"{_POLLS}._backfill_polls_for_finished", - return_value={1: 4}, - ), - patch( - f"{_POLLS}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence(chosen, [chosen], _state([], current=1)) # No "Historical min" line when no finished games have polls. assert not any("Historical min" in s for s in echoed) assert any("HLTB confidence: 4" in s for s in echoed) def test_scanning_backfill_no_missing(self, tmp_path: Path) -> None: """Test scanning backfill no missing.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"2": {"hours": 1.0, "polls": 5}}), encoding="utf-8" - ) - with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): - result = _scanning_confidence._backfill_polls_for_finished( - _state([2]), - [ - GameInfo( - app_id=2, - name="X", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - ], + cache_file = write_cache(tmp_path, {"2": {"hours": 1.0, "polls": 5}}) + with patch(f"{TYPES}.HLTB_CACHE_FILE", cache_file): + result = _backfill_polls_for_finished( + poll_state([2]), [make_game(2, "X", total=0)] ) assert result == {2: 5} def test_scanning_backfill_with_missing(self, tmp_path: Path) -> None: """Test scanning backfill with missing.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"2": {"hours": 3.0, "polls": 0}}), encoding="utf-8" - ) - - 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]: - """Test fake search: write into the caches it is handed.""" - for aid, _name in games: - cache[aid], polls[aid], count_comp[aid] = 3.0, 8, 0 - return [] - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ), - ): - result = _scanning_confidence._backfill_polls_for_finished( - _state([2]), - [ - GameInfo( - app_id=2, - name="X", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - ], + cache_file = write_cache(tmp_path, {"2": {"hours": 3.0, "polls": 0}}) + with hltb_cache_patched(cache_file, tmp_path, filling_fetch(3.0, 8)): + result = _backfill_polls_for_finished( + poll_state([2]), [make_game(2, "X", total=0)] ) assert result == {2: 8} def test_scanning_backfill_preserves_hours_on_miss(self, tmp_path: Path) -> None: """Test scanning backfill preserves hours on miss.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"2": {"hours": 9.0, "polls": 0}}), encoding="utf-8" - ) - - 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]: - """Test fake search: write into the caches it is handed.""" - for aid, _name in games: - cache[aid], polls[aid], count_comp[aid] = -1, 0, 0 - return [] - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ), - ): - _scanning_confidence._backfill_polls_for_finished( - _state([2]), - [ - GameInfo( - app_id=2, - name="X", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - ], - ) + cache_file = write_cache(tmp_path, {"2": {"hours": 9.0, "polls": 0}}) + with hltb_cache_patched(cache_file, tmp_path, filling_fetch(-1, 0)): + _backfill_polls_for_finished(poll_state([2]), [make_game(2, "X", total=0)]) final = json.loads(cache_file.read_text(encoding="utf-8")) assert final["2"]["hours"] == 9.0 diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py index 9819b74..6dcbcb7 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_assigned_confidence.py @@ -2,22 +2,13 @@ from __future__ import annotations -from unittest.mock import patch +from steam_backlog_enforcer.tests._polls_helpers import ( + assigned_confidence_echoes, + poll_state, +) -from steam_backlog_enforcer import _cmd_done -from steam_backlog_enforcer.config import State - -_TYPES = "steam_backlog_enforcer._hltb_types" -_CMD = "steam_backlog_enforcer._cmd_done" -_SCAN = "steam_backlog_enforcer.scanning" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s +_CHOSEN = {"app_id": 1, "name": "Chosen"} +_OLD = {"app_id": 2, "name": "Old"} class TestReportAssignedConfidence: @@ -25,122 +16,54 @@ class TestReportAssignedConfidence: def test_new_low_warning(self) -> None: """Test new low warning.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 1, 2: 5, 3: 10}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "OldShortest"}, - {"app_id": 3, "name": "Other"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2, 3], current=1)) + echoed = assigned_confidence_echoes( + {1: 1, 2: 5, 3: 10}, + [ + _CHOSEN, + {"app_id": 2, "name": "OldShortest"}, + {"app_id": 3, "name": "Other"}, + ], + poll_state([2, 3], current=1), + ) assert any("NEW LOW" in s for s in echoed) assert any("Historical min" in s and "OldShortest" in s for s in echoed) def test_zero_polls_warning_with_history(self) -> None: """Test zero polls warning with history.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 0, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + echoed = assigned_confidence_echoes( + {1: 0, 2: 5}, [_CHOSEN, _OLD], poll_state([2], current=1) + ) assert any("no polls recorded" in s for s in echoed) def test_zero_polls_warning_no_history(self) -> None: """Test zero polls warning no history.""" - echoed: list[str] = [] - with ( - patch(f"{_CMD}._backfill_polls_for_finished", return_value={1: 0}), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([], current=1)) + echoed = assigned_confidence_echoes( + {1: 0}, [_CHOSEN], poll_state([], current=1) + ) assert any("no polls recorded" in s for s in echoed) assert not any("Historical min" in s for s in echoed) def test_healthy_no_warning(self) -> None: """Test healthy no warning.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 50, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + echoed = assigned_confidence_echoes( + {1: 50, 2: 5}, [_CHOSEN, _OLD], poll_state([2], current=1) + ) assert not any("NEW LOW" in s for s in echoed) assert not any("no polls recorded" in s for s in echoed) assert any("HLTB confidence: 50" in s for s in echoed) def test_unknown_finished_uses_appid_label(self) -> None: """Test unknown finished uses appid label.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 50, 99: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([99], current=1)) + echoed = assigned_confidence_echoes( + {1: 50, 99: 5}, [_CHOSEN], poll_state([99], current=1) + ) assert any("AppID=99" in s for s in echoed) def test_chosen_equals_min_no_warning(self) -> None: # Edge case: chosen_polls == min_polls (not a new low). """Test chosen equals min no warning.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 5, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) + echoed = assigned_confidence_echoes( + {1: 5, 2: 5}, [_CHOSEN, _OLD], poll_state([2], current=1) + ) assert not any("NEW LOW" in s for s in echoed) assert not any("no polls recorded" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py index 861ad9d..d4ecc43 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls.py @@ -4,23 +4,15 @@ from __future__ import annotations from unittest.mock import MagicMock, patch -from steam_backlog_enforcer import _cmd_done, scanning -from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.steam_api import GameInfo - -_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" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.scanning import do_scan +from steam_backlog_enforcer.tests._polls_helpers import ( + SCAN, + assigned_confidence_echoes, + make_game, + poll_confidence_echoes, + poll_state, +) class TestScanningPollsIntegrationGroup2: @@ -28,59 +20,26 @@ class TestScanningPollsIntegrationGroup2: def test_report_poll_confidence_chosen_zero_polls(self) -> None: """Covers scanning.py 301-302: 0-poll chosen with history yields warning.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=0, + chosen = make_game(1, "Chosen") + old = make_game(2, "Old", unlocked=10) + echoed = poll_confidence_echoes( + chosen, [chosen, old], {1: 0, 2: 5}, poll_state([2], current=1) ) - old = GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ) - with ( - patch( - f"{_POLLS}._backfill_polls_for_finished", - return_value={1: 0, 2: 5}, - ), - patch( - f"{_POLLS}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence( - chosen, [chosen, old], _state([2], current=1) - ) assert any("no polls recorded" in s for s in echoed) def test_do_scan_kept_assignment_missing_game(self) -> None: """Covers scanning.py 110->116: current_app_id set but game absent.""" - from steam_backlog_enforcer.config import Config - from steam_backlog_enforcer.scanning import do_scan - - other = GameInfo( - app_id=999, - name="Other", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=0, - ) + other = make_game(999, "Other", unlocked=5) mock_client = MagicMock() mock_client.build_game_list.return_value = [other] with ( - patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client), - patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}), - patch(f"{_SCAN}.save_snapshot"), - patch(f"{_SCAN}.pick_next_game") as mock_pick, - patch(f"{_SCAN}._echo"), - patch(f"{_SCAN}._report_poll_confidence") as mock_report, + patch(f"{SCAN}.SteamAPIClient", return_value=mock_client), + patch(f"{SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}), + patch(f"{SCAN}.save_snapshot"), + patch(f"{SCAN}.pick_next_game") as mock_pick, + patch(f"{SCAN}._echo"), + patch(f"{SCAN}._report_poll_confidence") as mock_report, ): config = Config(steam_api_key="k", steam_id="i") state = State(current_app_id=440) # not in games @@ -90,21 +49,9 @@ class TestScanningPollsIntegrationGroup2: def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None: """Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 7}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([], current=1)) + echoed = assigned_confidence_echoes( + {1: 7}, [{"app_id": 1, "name": "Chosen"}], poll_state([], current=1) + ) assert any("HLTB confidence: 7" in s for s in echoed) assert not any("NEW LOW" in s for s in echoed) assert not any("no polls recorded" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py index 2cc2c5b..ca9eedf 100644 --- a/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py +++ b/steam_backlog_enforcer/tests/test_polls_tracking_report_poll_confidence_chosen_zero_polls_report_poll_confidence_chosen_equals_min.py @@ -2,104 +2,55 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer import _scanning_confidence, scanning -from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer._scanning_confidence import ( + _filter_hltb_confident_candidates, + _refresh_candidate_confidence, +) +from steam_backlog_enforcer.tests._polls_helpers import ( + FETCH_CONFIDENCE, + SCANCONF, + hltb_cache_patched, + make_game, + poll_confidence_echoes, + poll_state, + write_cache, +) if TYPE_CHECKING: 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" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s - class TestScanningPollsIntegrationGroup2Group2: """Tests for Scanning Polls Integration Group2.""" def test_report_poll_confidence_chosen_equals_min(self) -> None: """Covers scanning.py 301->304: chosen_polls >= min_polls, no warning.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=5, + chosen = make_game(1, "Chosen", polls=(5, 0)) + old = make_game(2, "Old", unlocked=10) + echoed = poll_confidence_echoes( + chosen, [chosen, old], {1: 5, 2: 5}, poll_state([2], current=1) ) - old = GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ) - with ( - patch( - f"{_POLLS}._backfill_polls_for_finished", - return_value={1: 5, 2: 5}, - ), - patch( - f"{_POLLS}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence( - chosen, [chosen, old], _state([2], current=1) - ) assert not any("NEW LOW" in s for s in echoed) assert not any("no polls recorded" in s for s in echoed) def test_refresh_candidate_confidence_noop_when_present(self) -> None: """Test refresh candidate confidence noop when present.""" - game = GameInfo( - app_id=1, - name="Known", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=3, - count_comp=15, - ) - with patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence" - ) as mock_fetch: - _scanning_confidence._refresh_candidate_confidence(game) + game = make_game(1, "Known", unlocked=1, polls=(3, 15)) + with patch(FETCH_CONFIDENCE) as mock_fetch: + _refresh_candidate_confidence(game) mock_fetch.assert_not_called() def test_refresh_candidate_confidence_backfills_zeroes( self, tmp_path: Path ) -> None: """Test refresh candidate confidence backfills zeroes.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}), - encoding="utf-8", - ) - game = GameInfo( - app_id=1, - name="NeedsRefresh", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, + cache_file = write_cache( + tmp_path, {"1": {"hours": 4.0, "polls": 0, "count_comp": 0}} ) + game = make_game(1, "NeedsRefresh", unlocked=1) def fake_fetch( games: list[tuple[int, str]], @@ -114,49 +65,25 @@ class TestScanningPollsIntegrationGroup2Group2: return [] with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ), - patch(f"{_SCANCONF}._echo"), + hltb_cache_patched(cache_file, tmp_path, fake_fetch), + patch(f"{SCANCONF}._echo"), ): - _scanning_confidence._refresh_candidate_confidence(game) + _refresh_candidate_confidence(game) assert game.comp_100_count == 3 assert game.count_comp == 15 def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None: """Filtering refreshes missing confidence in one batched cache lookup.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps( - { - "1": {"hours": 4.0, "polls": 0, "count_comp": 0}, - "2": {"hours": 5.0, "polls": 0, "count_comp": 0}, - } - ), - encoding="utf-8", - ) - game_a = GameInfo( - app_id=1, - name="A", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - game_b = GameInfo( - app_id=2, - name="B", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, + cache_file = write_cache( + tmp_path, + { + "1": {"hours": 4.0, "polls": 0, "count_comp": 0}, + "2": {"hours": 5.0, "polls": 0, "count_comp": 0}, + }, ) + game_a = make_game(1, "A", unlocked=1) + game_b = make_game(2, "B", unlocked=1) def fake_fetch( games: list[tuple[int, str]], @@ -172,17 +99,10 @@ class TestScanningPollsIntegrationGroup2Group2: return [] with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch( - "steam_backlog_enforcer._hltb_confidence.fetch_hltb_confidence", - side_effect=fake_fetch, - ) as mock_fetch, - patch(f"{_SCANCONF}._echo"), + hltb_cache_patched(cache_file, tmp_path, fake_fetch) as mock_fetch, + patch(f"{SCANCONF}._echo"), ): - kept = _scanning_confidence._filter_hltb_confident_candidates( - [game_a, game_b] - ) + kept = _filter_hltb_confident_candidates([game_a, game_b]) assert [game.app_id for game in kept] == [1, 2] mock_fetch.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_prompter.py b/steam_backlog_enforcer/tests/test_prompter.py index fb0a38a..710f634 100644 --- a/steam_backlog_enforcer/tests/test_prompter.py +++ b/steam_backlog_enforcer/tests/test_prompter.py @@ -7,8 +7,8 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer import _prompter from steam_backlog_enforcer._prompter import ( + _CLI, CliPrompter, GamePicker, PromptOption, @@ -30,12 +30,16 @@ def _typed(*answers: str) -> AbstractContextManager[MagicMock]: class TestCliPrompterChoice: + """CliPrompter.choice numbers the options and retries bad input.""" + def test_lists_options_and_returns_the_chosen_value(self) -> None: + """The options are listed by number and the typed one returned.""" with _typed("2"), patch("steam_backlog_enforcer._prompter._echo") as echo: assert CliPrompter().choice("Pick", _OPTIONS) == "b" assert [c.args[0] for c in echo.call_args_list] == [" 1. Alpha", " 2. Beta"] def test_retries_on_junk_and_out_of_range(self) -> None: + """Junk and out-of-range numbers are reported and asked again.""" with ( _typed("x", "0", "3", "1"), patch("steam_backlog_enforcer._prompter._echo") as echo, @@ -48,6 +52,8 @@ class TestCliPrompterChoice: class TestCliPrompterConfirm: + """CliPrompter.confirm reads yes/no with a default.""" + @pytest.mark.parametrize( ("answer", "default", "expected"), [ @@ -60,39 +66,49 @@ class TestCliPrompterConfirm: ], ) def test_answers(self, answer: str, *, default: bool, expected: bool) -> None: + """y/yes and n/no answer, an empty line takes the default.""" with _typed(answer): assert CliPrompter().confirm("Sure?", default=default) is expected def test_end_of_input_means_the_default(self) -> None: + """EOF takes the default.""" with patch("builtins.input", side_effect=EOFError): assert CliPrompter().confirm("Sure?", default=True) is True def test_asks_again_after_an_unclear_answer(self) -> None: + """An unclear answer is asked again.""" with _typed("maybe", "y"), patch("steam_backlog_enforcer._prompter._echo"): assert CliPrompter().confirm("Sure?", default=False) is True def test_hint_shows_the_default(self) -> None: + """The prompt hint capitalises the default.""" with _typed("") as typed: CliPrompter().confirm("Sure?", default=True) assert typed.call_args.args[0] == "Sure? [Y/n]: " class TestCliPrompterPhraseAndText: + """CliPrompter.phrase and text read typed lines.""" + def test_exact_phrase_matches_ignoring_outer_whitespace(self) -> None: + """The exact phrase matches with outer whitespace ignored.""" with _typed(" lock in X ") as typed: assert CliPrompter().phrase("ignored", "lock in X") is True assert typed.call_args.args[0] == 'Type "lock in X" to confirm: ' def test_wrong_phrase_is_a_no(self) -> None: + """A phrase differing in case is a no.""" with _typed("LOCK IN X"): assert CliPrompter().phrase("ignored", "lock in X") is False def test_text_is_stripped(self) -> None: + """text returns the stripped line.""" with _typed(" hello ") as typed: assert CliPrompter().text("Name") == "hello" assert typed.call_args.args[0] == "Name: " def test_interactive_follows_stdin(self) -> None: + """interactive mirrors whether stdin is a TTY.""" with patch("steam_backlog_enforcer._prompter.sys.stdin") as stdin: stdin.isatty.return_value = True assert CliPrompter().interactive is True @@ -101,40 +117,53 @@ class TestCliPrompterPhraseAndText: class TestActivePrompter: + """current_prompter is the CLI unless use_prompter overrides it.""" + def test_defaults_to_the_cli(self) -> None: - assert current_prompter() is _prompter._CLI + """Without an override the CLI prompter is active.""" + assert current_prompter() is _CLI def test_use_prompter_scopes_the_override(self) -> None: + """use_prompter sets the override only inside its block.""" fake = FakePrompter() with use_prompter(fake) as active: assert active is fake assert current_prompter() is fake - assert current_prompter() is _prompter._CLI + assert current_prompter() is _CLI def test_the_override_is_dropped_when_the_body_raises(self) -> None: + """The override is dropped even when the block raises.""" with pytest.raises(RuntimeError), use_prompter(FakePrompter()): raise RuntimeError - assert current_prompter() is _prompter._CLI + assert current_prompter() is _CLI class TestGamePickerProtocol: + """GamePicker matches only prompters with a game method.""" + def test_only_prompters_with_a_game_method_are_pickers(self) -> None: + """Only a prompter with a game method is a GamePicker.""" assert isinstance(FakePicker(), GamePicker) assert not isinstance(FakePrompter(), GamePicker) assert not isinstance(CliPrompter(), GamePicker) class TestConfirmPhrase: + """confirm_phrase asks for the phrase registered for a command.""" + def test_asks_for_the_commands_phrase(self) -> None: + """The command phrase is asked, filled with the game name.""" fake = FakePrompter(phrases=[True]) with use_prompter(fake): assert confirm_phrase("pick-manual", "Lock it?", game_name="Doom") is True assert fake.asked == [("Lock it?", "lock in Doom")] def test_a_declined_phrase_is_false(self) -> None: + """A declined phrase returns False.""" with use_prompter(FakePrompter(phrases=[False])): assert confirm_phrase("reset", "Wipe?") is False def test_a_command_without_a_phrase_is_an_error_not_a_yes(self) -> None: + """A command without a phrase raises KeyError.""" with pytest.raises(KeyError, match="no-such-command"): confirm_phrase("no-such-command", "?") diff --git a/steam_backlog_enforcer/tests/test_protondb.py b/steam_backlog_enforcer/tests/test_protondb.py index 732b022..de58af4 100644 --- a/steam_backlog_enforcer/tests/test_protondb.py +++ b/steam_backlog_enforcer/tests/test_protondb.py @@ -2,9 +2,7 @@ from __future__ import annotations -import json from typing import TYPE_CHECKING, Any -from unittest.mock import patch from steam_backlog_enforcer.protondb import ( ProtonDBRating, @@ -13,6 +11,11 @@ from steam_backlog_enforcer.protondb import ( _rating_to_dict, _save_cache, ) +from steam_backlog_enforcer.tests._protondb_cache import ( + cache_at, + writable_cache_at, + write_gold_cache, +) if TYPE_CHECKING: from pathlib import Path @@ -127,35 +130,19 @@ class TestProtonDBCache: def test_load_cache_exists(self, tmp_path: Path) -> None: """Test load cache exists.""" - cache_file = tmp_path / "protondb_cache.json" - cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") - with patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): + with cache_at(write_gold_cache(tmp_path)): result = _load_cache() assert result == {"440": {"tier": "gold"}} def test_load_cache_missing(self, tmp_path: Path) -> None: """Test load cache missing.""" - cache_file = tmp_path / "nonexistent.json" - with patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): + with cache_at(tmp_path / "nonexistent.json"): assert _load_cache() == {} def test_save_cache(self, tmp_path: Path) -> None: """Test save cache.""" cache_file = tmp_path / "protondb_cache.json" - config_dir = tmp_path - with ( - patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ), - patch("steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), - ): + with writable_cache_at(cache_file, tmp_path): _save_cache({"440": {"tier": "gold"}}) assert cache_file.exists() diff --git a/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py b/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py index 5e8b517..24ac8ef 100644 --- a/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py +++ b/steam_backlog_enforcer/tests/test_protondb_fetch_batch.py @@ -3,7 +3,6 @@ from __future__ import annotations import asyncio -import json from typing import TYPE_CHECKING from unittest.mock import AsyncMock, patch @@ -12,6 +11,11 @@ from steam_backlog_enforcer.protondb import ( _fetch_batch, fetch_protondb_ratings, ) +from steam_backlog_enforcer.tests._protondb_cache import ( + cache_at, + writable_cache_at, + write_gold_cache, +) if TYPE_CHECKING: from pathlib import Path @@ -60,26 +64,15 @@ class TestFetchProtondbRatings: def test_all_cached(self, tmp_path: Path) -> None: """Test all cached.""" - cache_file = tmp_path / "protondb_cache.json" - cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") - with patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): + with cache_at(write_gold_cache(tmp_path)): result = fetch_protondb_ratings([440]) assert 440 in result assert result[440].tier == "gold" def test_fetch_uncached(self, tmp_path: Path) -> None: """Test fetch uncached.""" - cache_file = tmp_path / "protondb_cache.json" - config_dir = tmp_path with ( - patch( - "steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ), - patch("steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), + writable_cache_at(tmp_path / "protondb_cache.json", tmp_path), patch( "steam_backlog_enforcer.protondb._fetch_batch", return_value=[ProtonDBRating(app_id=440, tier="platinum")], diff --git a/steam_backlog_enforcer/tests/test_reading_bonus.py b/steam_backlog_enforcer/tests/test_reading_bonus.py index 8409e38..5b79e2a 100644 --- a/steam_backlog_enforcer/tests/test_reading_bonus.py +++ b/steam_backlog_enforcer/tests/test_reading_bonus.py @@ -31,8 +31,8 @@ if TYPE_CHECKING: _PKG = "steam_backlog_enforcer._reading_bonus" -@pytest.fixture(autouse=True) -def reading_key(tmp_path: Path) -> Iterator[Path]: +@pytest.fixture(name="reading_key", autouse=True) +def fixture_reading_key(tmp_path: Path) -> Iterator[Path]: """Point the module at a throwaway signing key and an empty memo. Args: @@ -82,6 +82,7 @@ def reading( def _now() -> datetime: + """Return the current local time.""" return datetime.now().astimezone() @@ -89,10 +90,12 @@ class TestWhichRowsCount: """Only a signed, bonus-eligible credit that ended today counts.""" def test_a_bonus_credit_today_counts(self, tmp_path: Path) -> None: + """A bonus-eligible credit that ended today counts.""" ledger = write_ledger(tmp_path, [reading(when=_now())]) assert read_ledger_read_today(ledger) is True def test_yesterdays_reading_does_not(self, tmp_path: Path) -> None: + """A reading session from yesterday does not count.""" yesterday = _now() - timedelta(days=1) assert ( read_ledger_read_today(write_ledger(tmp_path, [reading(when=yesterday)])) @@ -140,6 +143,7 @@ class TestWhichRowsCount: assert read_ledger_read_today(ledger) is False def test_a_tampered_row_does_not_count(self, tmp_path: Path) -> None: + """A row whose detail was edited after signing does not count.""" row = reading(when=_now(), bonus="0") row["detail"]["bonus"] = "1" assert read_ledger_read_today(write_ledger(tmp_path, [row])) is False @@ -149,9 +153,11 @@ class TestFailingClosed: """Anything unreadable is ``None``; only an absent ledger is a plain no.""" def test_no_ledger_yet_is_an_honest_no(self, tmp_path: Path) -> None: + """A ledger that does not exist yet is a plain no.""" assert read_ledger_read_today(tmp_path / "absent.json") is False def test_an_unreadable_ledger_is_none(self, tmp_path: Path) -> None: + """A ledger path that cannot be read as a file is None.""" assert read_ledger_read_today(tmp_path) is None @pytest.mark.parametrize( @@ -175,11 +181,13 @@ class TestFailingClosed: assert read_ledger_read_today(ledger) is None def test_a_missing_key_is_none(self, tmp_path: Path) -> None: + """A missing signing key makes the answer None.""" ledger = write_ledger(tmp_path, [reading(when=_now())]) with patch(f"{_PKG}.HMAC_KEY_FILE", tmp_path / "absent.key"): assert read_ledger_read_today(ledger) is None def test_an_empty_key_is_none(self, tmp_path: Path, reading_key: Path) -> None: + """A blank signing key makes the answer None.""" reading_key.write_bytes(b" \n") ledger = write_ledger(tmp_path, [reading(when=_now())]) assert read_ledger_read_today(ledger) is None @@ -189,22 +197,26 @@ class TestTheMemo: """Answers are cached like the other earners; failures are not.""" def test_reads_the_configured_ledger(self, tmp_path: Path) -> None: + """read_today reads the ledger named in the config.""" ledger = write_ledger(tmp_path, [reading(when=_now())]) assert read_today(Config(book_ledger_path=str(ledger))) is True def test_a_cached_answer_is_served_without_a_reread(self) -> None: + """A cached answer is served without reading the ledger again.""" with patch(f"{_PKG}.read_ledger_read_today", return_value=False) as read: assert read_today(Config()) is False assert read_today(Config()) is False assert read.call_count == 1 def test_a_failure_is_not_cached(self) -> None: + """A None answer is not cached, so the next call retries.""" with patch(f"{_PKG}.read_ledger_read_today", return_value=None) as read: assert read_today(Config()) is None assert read_today(Config()) is None assert read.call_count == 2 def test_reset_cache_forces_a_reread(self) -> None: + """reset_cache forces the next call to reread.""" with patch(f"{_PKG}.read_ledger_read_today", return_value=True) as read: read_today(Config()) reset_cache() @@ -212,4 +224,5 @@ class TestTheMemo: assert read.call_count == 2 def test_the_default_path_is_book_guards(self) -> None: + """The default ledger path points at book_guard's ledger.""" assert Config().book_ledger_path.endswith("book_guard/ledger.json") diff --git a/steam_backlog_enforcer/tests/test_release_lifecycle.py b/steam_backlog_enforcer/tests/test_release_lifecycle.py index c688979..77b2d73 100644 --- a/steam_backlog_enforcer/tests/test_release_lifecycle.py +++ b/steam_backlog_enforcer/tests/test_release_lifecycle.py @@ -59,7 +59,10 @@ def write_state(data: dict[str, object]) -> None: class TestLoadBackfill: + """Loading old state backfills assignment times and saves once.""" + def test_plain_assignment_is_stamped_once_and_persisted(self) -> None: + """A plain assignment gets one timestamp that later loads keep.""" write_state({"current_app_id": 5, "current_game_name": "G5"}) first = State.load() assert first.current_assigned_at @@ -67,12 +70,14 @@ class TestLoadBackfill: assert first.last_assigned_at == {"5": first.current_assigned_at} def test_current_pick_keeps_its_pick_time(self) -> None: + """An assigned pick is timed from when it was picked.""" write_state({"current_app_id": 5, "manual_picks": [pick(5), pick(6)]}) state = State.load() assert state.current_assigned_at == STARTED_AT assert state.last_assigned_at == {"5": STARTED_AT, "6": STARTED_AT} def test_legacy_pick_migration_is_persisted(self) -> None: + """A legacy single-pick state is migrated and written back.""" write_state({"manual_pick_app_id": 9, "manual_pick_started_at": ""}) State.load() saved = json.loads(config_mod.STATE_FILE.read_text(encoding="utf-8")) @@ -80,6 +85,7 @@ class TestLoadBackfill: assert saved["manual_pick_app_id"] is None def test_up_to_date_state_is_not_rewritten(self) -> None: + """State that needs no upgrade is never rewritten.""" write_state({"current_app_id": None}) with patch.object(State, "save") as mock_save: State.load() @@ -87,7 +93,10 @@ class TestLoadBackfill: class TestReleasedPicksStayPlayable: + """Released and expired picks stay playable but no longer lock.""" + def test_released_and_expired_picks_stay_allowed_but_unlocked(self) -> None: + """Released and expired picks are allowed but leave nothing locked.""" state = State( current_app_id=1, manual_picks=[ @@ -99,12 +108,14 @@ class TestReleasedPicksStayPlayable: assert is_manual_pick_locked(state) is False def test_explicit_choice_drops_only_inactive_picks(self) -> None: + """An explicit choice drops released picks and keeps active ones.""" state = State(manual_picks=[pick(2, released_at=STARTED_AT), pick(3)]) dropped = drop_inactive_picks(state) assert [p["app_id"] for p in dropped] == [2] assert [p["app_id"] for p in state.manual_picks] == [3] def test_new_manual_pick_keeps_released_ones(self) -> None: + """A new manual pick leaves earlier released picks playable.""" state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)]) with patch.object(State, "save"): assert apply_manual_pick(state, 4, "G4", max_picks=1) is None @@ -112,6 +123,7 @@ class TestReleasedPicksStayPlayable: assert state.current_assigned_at == state.manual_picks[-1]["started_at"] def test_released_pick_can_be_abandoned(self) -> None: + """A released pick can be abandoned and the active pick takes over.""" state = State( current_app_id=2, manual_picks=[pick(2, released_at="R"), pick(3)] ) @@ -121,6 +133,7 @@ class TestReleasedPicksStayPlayable: assert (state.current_app_id, state.current_assigned_at) == (3, STARTED_AT) def test_abandoning_the_last_pick_clears_the_assignment(self) -> None: + """Abandoning the only pick clears the assignment.""" state = State(current_app_id=2, current_assigned_at="T", manual_picks=[pick(2)]) with patch.object(State, "save"): abandon_manual_pick(state, 2) @@ -128,7 +141,10 @@ class TestReleasedPicksStayPlayable: class TestPickOrder: + """Candidates are ordered by least recent assignment, then length.""" + def test_least_recently_assigned_first_then_shortest(self) -> None: + """Never-assigned games come first, then the oldest assignment.""" state = State( last_assigned_at={ "1": "2026-09-01T00:00:00+00:00", @@ -145,7 +161,10 @@ class TestPickOrder: class TestTamperingAfterRelease: + """Unlocks after a release are tampering; earlier ones are not.""" + def entry(self) -> dict[str, object]: + """Return the catalogue entry for the released game.""" return { "app_id": 7, "name": "G7", @@ -155,28 +174,35 @@ class TestTamperingAfterRelease: } def check(self, *unlock_times: int) -> tuple[str, int, int] | None: + """Run the tampering check against a refresh with these unlocks.""" client = MagicMock() client.refresh_single_game.return_value = game(7, *unlock_times) state = State(current_app_id=1, released_at={"7": RELEASED.isoformat()}) return _check_game_tampering(client, self.entry(), state) def test_unlocks_earned_while_assigned_are_legitimate(self) -> None: + """Unlocks earned while the game was assigned are legitimate.""" before = int((RELEASED - timedelta(hours=1)).timestamp()) assert self.check(before, before) is None def test_unlocks_after_release_are_flagged(self) -> None: + """Unlocks made after the release are flagged as tampering.""" before = int((RELEASED - timedelta(hours=1)).timestamp()) after = int((RELEASED + timedelta(hours=1)).timestamp()) assert self.check(before, after) == ("G7", 7, 1) def test_unreadable_game_is_not_flagged(self) -> None: + """A game whose refresh fails is not flagged.""" client = MagicMock() client.refresh_single_game.return_value = None assert _check_game_tampering(client, self.entry(), State()) is None class TestCheckCommand: + """The check command applies the one-new-achievement rule.""" + def run_check(self, state: State, refreshed: GameInfo) -> MagicMock: + """Run do_check with every side effect patched; return the pick mock.""" client = MagicMock() client.refresh_single_game.return_value = refreshed with ( @@ -195,6 +221,7 @@ class TestCheckCommand: return mock_pick def test_new_achievement_releases_and_keeps_the_game(self) -> None: + """A new achievement releases the game and keeps it assigned.""" state = State(current_app_id=7, current_assigned_at=RELEASED.isoformat()) after = int((RELEASED + timedelta(hours=1)).timestamp()) self.run_check(state, game(7, after)) @@ -204,6 +231,7 @@ class TestCheckCommand: assert state.current_app_id == 7 def test_no_new_achievement_changes_nothing(self) -> None: + """No new achievement leaves the state and the pick untouched.""" state = State(current_app_id=7, current_assigned_at=RELEASED.isoformat()) before = int((RELEASED - timedelta(hours=1)).timestamp()) mock_pick = self.run_check(state, game(7, before)) diff --git a/steam_backlog_enforcer/tests/test_scanning.py b/steam_backlog_enforcer/tests/test_scanning.py index 17a26be..54b5493 100644 --- a/steam_backlog_enforcer/tests/test_scanning.py +++ b/steam_backlog_enforcer/tests/test_scanning.py @@ -11,29 +11,12 @@ from steam_backlog_enforcer.scanning import ( _pick_playable_candidate, do_scan, ) -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._scanning_games import make_game if TYPE_CHECKING: from collections.abc import Callable - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) + from steam_backlog_enforcer.steam_api import GameInfo class TestDoScan: @@ -41,7 +24,7 @@ class TestDoScan: def test_scans_and_picks(self) -> None: """Test scans and picks.""" - game = _game(app_id=440, name="TF2", total=10, unlocked=5) + game = make_game(app_id=440, name="TF2", total=10, unlocked=5) mock_client = MagicMock() def build_game_list( @@ -85,7 +68,7 @@ class TestDoScan: def test_scan_all_complete(self) -> None: """Test scan all complete.""" - game = _game(app_id=440, name="TF2", total=10, unlocked=10) + game = make_game(app_id=440, name="TF2", total=10, unlocked=10) mock_client = MagicMock() def build_game_list( @@ -119,7 +102,7 @@ class TestDoScan: def test_scan_already_assigned(self) -> None: """Test scan already assigned.""" - game = _game(app_id=440, total=10, unlocked=5) + game = make_game(app_id=440, total=10, unlocked=5) mock_client = MagicMock() mock_client.build_game_list.return_value = [game] with ( @@ -151,7 +134,7 @@ class TestPickPlayableCandidate: def test_finds_playable(self) -> None: """Test finds playable.""" - game = _game(app_id=440, name="TF2") + game = make_game(app_id=440, name="TF2") with ( patch( "steam_backlog_enforcer._scanning_candidates.fetch_protondb_ratings", @@ -167,8 +150,8 @@ class TestPickPlayableCandidate: def test_skips_bad_rating(self) -> None: """Test skips bad rating.""" - bad = _game(app_id=1, name="Bad") - good = _game(app_id=2, name="Good") + bad = make_game(app_id=1, name="Bad") + good = make_game(app_id=2, name="Good") with ( patch( "steam_backlog_enforcer._scanning_candidates.fetch_protondb_ratings", @@ -185,7 +168,7 @@ class TestPickPlayableCandidate: def test_all_unplayable(self) -> None: """Test all unplayable.""" - game = _game(app_id=1, name="Bad") + game = make_game(app_id=1, name="Bad") with ( patch( "steam_backlog_enforcer._scanning_candidates.fetch_protondb_ratings", @@ -203,7 +186,7 @@ class TestPickPlayableCandidate: def test_first_in_batch_playable(self) -> None: """First game in first batch is playable — no skip message.""" - game = _game(app_id=440, name="TF2") + game = make_game(app_id=440, name="TF2") with ( patch( "steam_backlog_enforcer._scanning_candidates.fetch_protondb_ratings", diff --git a/steam_backlog_enforcer/tests/test_scanning_assign_pick.py b/steam_backlog_enforcer/tests/test_scanning_assign_pick.py index 7c56b9d..a3c2dc4 100644 --- a/steam_backlog_enforcer/tests/test_scanning_assign_pick.py +++ b/steam_backlog_enforcer/tests/test_scanning_assign_pick.py @@ -7,33 +7,27 @@ from unittest.mock import patch from steam_backlog_enforcer._prompter import use_prompter from steam_backlog_enforcer._scanning_assign import _prompt_user_pick 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 _PKG = "steam_backlog_enforcer._scanning_assign" -def _game(app_id: int, name: str, hours: float = -1) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=60, - completionist_hours=hours, - ) - - +_game = half_done_game _RANKED = [_game(1, "Short", hours=2.5), _game(2, "Unknown")] class TestPromptUserPick: + """Asking the user to pick the next game from the ranked list.""" + def test_choosing_a_ranked_entry_returns_that_game(self) -> None: + """Choosing a ranked entry returns that game.""" prompter = FakePrompter(choices=["1"]) with use_prompter(prompter): assert _prompt_user_pick(_RANKED, _RANKED, State()) is _RANKED[1] def test_options_carry_hours_when_known_and_end_with_own_pick(self) -> None: + """Options show known hours and end with the own-pick entry.""" prompter = FakePrompter(choices=["0"]) with ( use_prompter(prompter), @@ -49,6 +43,7 @@ class TestPromptUserPick: assert options[-1].value == "own" def test_own_pick_returns_the_game_the_user_searched_for(self) -> None: + """The own-pick option returns the game the user searched for.""" mine = _game(9, "Mine") with ( use_prompter(FakePrompter(choices=["own"])), @@ -59,6 +54,7 @@ class TestPromptUserPick: own.assert_called_once_with([*_RANKED, mine], state) def test_backing_out_of_own_pick_shows_the_list_again(self) -> None: + """Backing out of the own-pick search shows the list again.""" prompter = FakePrompter(choices=["own", "0"]) with ( use_prompter(prompter), diff --git a/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py index 5491be9..b92f1c8 100644 --- a/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py +++ b/steam_backlog_enforcer/tests/test_scanning_cached_confidence_overlay_avoids_refetch.py @@ -2,30 +2,25 @@ from __future__ import annotations +from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.scanning import pick_next_game -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._scanning_games import ( + first_candidate, + make_config, + make_game, + pick_env, + pick_without_batch_refresh, +) +if TYPE_CHECKING: + from steam_backlog_enforcer.steam_api import GameInfo -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +_CONFIDENCE = "steam_backlog_enforcer._scanning_confidence" +# The retry messages come from the CLI prompter now. +_PROMPT_ECHO = ("_scanning_assign", "_prompter") class TestPickNextGameGroup2: @@ -35,174 +30,74 @@ class TestPickNextGameGroup2: self, ) -> None: """Use cached confidence before deciding whether refresh is needed.""" - low = _game(app_id=1, name="Low", hours=1.0) + low = make_game(app_id=1, name="Low", hours=1.0) low.comp_100_count = 0 low.count_comp = 0 - fallback = _game(app_id=2, name="Fallback", hours=2.0) + fallback = make_game(app_id=2, name="Fallback", hours=2.0) fallback.comp_100_count = 3 fallback.count_comp = 20 - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", + f"{_CONFIDENCE}.load_hltb_polls_cache", return_value={1: 1, 2: 3}, ), patch( - "steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", + f"{_CONFIDENCE}.load_hltb_count_comp_cache", return_value={1: 8, 2: 20}, ), - patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch" - ) as mock_refresh_batch, - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), ): - pick_next_game([low, fallback], state, config) - + state = pick_without_batch_refresh([low, fallback]) assert state.current_app_id == 2 - mock_refresh_batch.assert_not_called() def test_stops_collecting_after_n_qualified(self) -> None: """Collection stops once _PICK_LIST_SIZE candidates are qualified.""" # Create 11 games that all pass filters; only the first 10 should be # presented and the 11th should never trigger a ProtonDB call. - games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 12)] + games = [ + make_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 12) + ] protondb_call_count = 0 def playable_side_effect(c: list[GameInfo]) -> GameInfo | None: """Test playable side effect.""" nonlocal protondb_call_count protondb_call_count += 1 - return c[0] if c else None + return first_candidate(c) - config = Config(steam_api_key="k", steam_id="i") state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=playable_side_effect, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game(games, state, config) + with pick_env(playable=playable_side_effect): + pick_next_game(games, state, make_config()) assert state.current_app_id == 1 assert protondb_call_count == 10 def test_user_picks_second_candidate(self) -> None: """User can select a game other than the shortest one.""" - g1 = _game(app_id=1, name="Short", hours=5.0) - g2 = _game(app_id=2, name="Medium", hours=15.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="Short", hours=5.0) + g2 = make_game(app_id=2, name="Medium", hours=15.0) state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="2"), - ): - pick_next_game([g1, g2], state, config) + with pick_env(answers="2"): + pick_next_game([g1, g2], state, make_config()) assert state.current_app_id == 2 def test_invalid_input_then_valid(self) -> None: """Non-numeric input prints error and loops until valid input.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=5.0) state = State() echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer._scanning_assign._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - # The retry messages come from the CLI prompter now. - patch( - "steam_backlog_enforcer._prompter._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", side_effect=["abc", "1"]), - ): - pick_next_game([g1], state, config) + with pick_env(echo=_PROMPT_ECHO, echoed=echoed, answers=["abc", "1"]): + pick_next_game([g1], state, make_config()) assert state.current_app_id == 1 assert any("Invalid input" in line for line in echoed) def test_out_of_range_then_valid(self) -> None: """Out-of-range number prints error and loops until valid input.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=5.0) state = State() echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer._scanning_assign._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - # The retry messages come from the CLI prompter now. - patch( - "steam_backlog_enforcer._prompter._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", side_effect=["99", "1"]), - ): - pick_next_game([g1], state, config) + with pick_env(echo=_PROMPT_ECHO, echoed=echoed, answers=["99", "1"]): + pick_next_game([g1], state, make_config()) assert state.current_app_id == 1 assert any("Out of range" in line for line in echoed) diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py index e2d542c..2c30d01 100644 --- a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py +++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers.py @@ -12,26 +12,7 @@ from steam_backlog_enforcer._scanning_confidence import ( from steam_backlog_enforcer.scanning import ( _pick_next_shortest_candidate, ) -from steam_backlog_enforcer.steam_api import GameInfo - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +from steam_backlog_enforcer.tests._scanning_games import make_game class TestConfidenceHelpers: @@ -39,7 +20,7 @@ class TestConfidenceHelpers: def test_force_refresh_candidate_confidence_delegates(self) -> None: """Test force refresh candidate confidence delegates.""" - game = _game(app_id=10, name="A") + game = make_game(app_id=10, name="A") with patch( "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", ) as mock_batch: @@ -48,7 +29,7 @@ class TestConfidenceHelpers: def test_refresh_candidate_confidence_batch_no_missing_skips_fetch(self) -> None: """Test refresh candidate confidence batch no missing skips fetch.""" - game = _game(app_id=20, name="B", hours=12.0) + game = make_game(app_id=20, name="B", hours=12.0) game.comp_100_count = 3 game.count_comp = 15 with patch( @@ -59,7 +40,7 @@ class TestConfidenceHelpers: def test_refresh_candidate_confidence_batch_preserves_existing_hours(self) -> None: """Test refresh candidate confidence batch preserves existing hours.""" - game = _game(app_id=30, name="C", hours=9.5) + game = make_game(app_id=30, name="C", hours=9.5) game.comp_100_count = 0 game.count_comp = 0 # refresh_confidence owns the hours-preservation rule (see @@ -76,7 +57,7 @@ class TestConfidenceHelpers: def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None: """Test filter hltb confident candidates skips low confidence.""" - low = _game(app_id=40, name="Low", hours=2.0) + low = make_game(app_id=40, name="Low", hours=2.0) low.comp_100_count = 1 low.count_comp = 2 with ( @@ -91,8 +72,8 @@ class TestConfidenceHelpers: def test_pick_next_shortest_candidate_logs_skipped_unplayable_batches(self) -> None: """Test pick next shortest candidate logs skipped unplayable batches.""" - bad = _game(app_id=50, name="Bad", hours=1.0) - good = _game(app_id=51, name="Good", hours=2.0) + bad = make_game(app_id=50, name="Bad", hours=1.0) + good = make_game(app_id=51, name="Good", hours=2.0) bad.comp_100_count = 3 bad.count_comp = 15 good.comp_100_count = 3 diff --git a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py index d186071..e6417ff 100644 --- a/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py +++ b/steam_backlog_enforcer/tests/test_scanning_confidence_helpers_pick_next_shortest_candidate_no_echo_whe.py @@ -9,26 +9,7 @@ from steam_backlog_enforcer.scanning import ( _pick_next_shortest_candidate, do_check, ) -from steam_backlog_enforcer.steam_api import GameInfo - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +from steam_backlog_enforcer.tests._scanning_games import make_game class TestConfidenceHelpersGroup2: @@ -38,7 +19,7 @@ class TestConfidenceHelpersGroup2: self, ) -> None: """Covers 419->423: no echo printed when linux_skipped == 0.""" - good = _game(app_id=51, name="Good", hours=2.0) + good = make_game(app_id=51, name="Good", hours=2.0) with ( patch( "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", @@ -55,7 +36,7 @@ class TestConfidenceHelpersGroup2: def test_pick_next_shortest_candidate_skips_low_confidence(self) -> None: """Covers lines 413-414: confidence_skipped += 1; continue.""" - low_conf = _game(app_id=10, name="Low", hours=1.0) + low_conf = make_game(app_id=10, name="Low", hours=1.0) low_conf.comp_100_count = 0 low_conf.count_comp = 0 with ( @@ -73,7 +54,7 @@ class TestConfidenceHelpersGroup2: def test_pick_next_shortest_candidate_all_protondb_fail(self) -> None: """Covers lines 426-428: linux_skipped > 0 after loop, return None.""" - g1 = _game(app_id=10, name="Borked", hours=1.0) + g1 = make_game(app_id=10, name="Borked", hours=1.0) with ( patch( "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", @@ -91,7 +72,7 @@ class TestConfidenceHelpersGroup2: for call in mock_echo.call_args_list ) - game = _game(app_id=440, name="TF2", total=5, unlocked=5) + game = make_game(app_id=440, name="TF2", total=5, unlocked=5) mock_client = MagicMock() mock_client.refresh_single_game.return_value = game snap = [game.to_snapshot()] @@ -119,7 +100,7 @@ class TestConfidenceHelpersGroup2: def test_complete_no_snapshot(self) -> None: """Test complete no snapshot.""" - game = _game(app_id=440, name="TF2", total=5, unlocked=5) + game = make_game(app_id=440, name="TF2", total=5, unlocked=5) mock_client = MagicMock() mock_client.refresh_single_game.return_value = game with ( @@ -142,7 +123,7 @@ class TestConfidenceHelpersGroup2: def test_not_complete(self) -> None: """Test not complete.""" - game = _game(app_id=440, name="TF2", total=10, unlocked=5) + game = make_game(app_id=440, name="TF2", total=10, unlocked=5) mock_client = MagicMock() mock_client.refresh_single_game.return_value = game with ( diff --git a/steam_backlog_enforcer/tests/test_scanning_part2.py b/steam_backlog_enforcer/tests/test_scanning_part2.py index a1c51d7..cc6046d 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part2.py +++ b/steam_backlog_enforcer/tests/test_scanning_part2.py @@ -10,6 +10,7 @@ from steam_backlog_enforcer.scanning import ( _check_game_tampering, detect_tampering, ) +from steam_backlog_enforcer.tests._main_helpers import snap PKG = "steam_backlog_enforcer._scanning_tampering" @@ -21,19 +22,18 @@ def _entry( unlocked: int = 5, playtime: int = 60, ) -> dict[str, Any]: - return { - "app_id": app_id, - "name": name, - "total_achievements": total, - "unlocked_achievements": unlocked, - "playtime_minutes": playtime, - } + # A scan entry is a snapshot entry without the HLTB hours column. + entry = snap(app_id, name, total, unlocked) + del entry["completionist_hours"] + entry["playtime_minutes"] = playtime + return entry class TestCheckGameTampering: """Tests for _check_game_tampering.""" def test_current_game_skipped(self) -> None: + """The assigned game is never checked for tampering.""" state = State(current_app_id=1) result = _check_game_tampering(MagicMock(), _entry(app_id=1), state) assert result is None @@ -41,6 +41,7 @@ class TestCheckGameTampering: def test_finished_manual_pick_is_not_tampering(self) -> None: # The completion that retired the pick is legitimate progress; the # snapshot is simply stale. Reported as TAMPERING before this exemption. + """New unlocks on a finished manual pick are legitimate progress.""" client = MagicMock() game = MagicMock() game.unlocked_achievements = 16 @@ -57,6 +58,7 @@ class TestCheckGameTampering: def test_active_second_pick_is_not_tampering(self) -> None: # A second manual pick is allowed but is not current_app_id. + """New unlocks on an active second manual pick are allowed.""" client = MagicMock() game = MagicMock() game.unlocked_achievements = 9 @@ -72,6 +74,7 @@ class TestCheckGameTampering: def test_unrelated_game_still_flagged(self) -> None: # The detector must keep working for games never allowed. + """New unlocks on a game never allowed are reported with the gain.""" client = MagicMock() game = MagicMock() game.unlocked_achievements = 8 @@ -81,6 +84,7 @@ class TestCheckGameTampering: assert _check_game_tampering(client, entry, state) == ("AoE2", 813780, 3) def test_already_complete_skipped(self) -> None: + """A game that was already 100% is not checked.""" state = State() result = _check_game_tampering( MagicMock(), @@ -90,6 +94,7 @@ class TestCheckGameTampering: assert result is None def test_zero_playtime_skipped(self) -> None: + """A game never played is not checked.""" state = State() result = _check_game_tampering( MagicMock(), @@ -99,6 +104,7 @@ class TestCheckGameTampering: assert result is None def test_no_new_achievements(self) -> None: + """An unchanged unlock count is not tampering.""" client = MagicMock() game = MagicMock() game.unlocked_achievements = 5 @@ -108,6 +114,7 @@ class TestCheckGameTampering: assert result is None def test_tampering_detected(self) -> None: + """More unlocks than the snapshot is reported as name, id and gain.""" client = MagicMock() game = MagicMock() game.unlocked_achievements = 8 @@ -119,6 +126,7 @@ class TestCheckGameTampering: assert result == ("Cheated", 99, 3) def test_refresh_returns_none(self) -> None: + """A failed refresh is not tampering.""" client = MagicMock() client.refresh_single_game.return_value = None state = State() @@ -142,6 +150,8 @@ def _client_showing(extra: dict[int, int]) -> MagicMock: class TestDetectTampering: """Tests for detect_tampering.""" + notify: MagicMock + def _run(self, entries: list[dict[str, Any]], client: MagicMock) -> MagicMock: with ( patch(f"{PKG}.load_snapshot", return_value=entries), @@ -154,15 +164,18 @@ class TestDetectTampering: return mock_echo def test_no_snapshot(self) -> None: + """With no snapshot, detection returns quietly.""" with patch(f"{PKG}.load_snapshot", return_value=None): detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) def test_no_tampering(self) -> None: + """Clean games produce no output and no notification.""" mock_echo = self._run([_entry(app_id=1)], _client_showing({})) mock_echo.assert_not_called() self.notify.assert_not_called() def test_tampering_found(self) -> None: + """A tampered game is echoed with its gain and notified once.""" entries = [_entry(app_id=1, name="BadGame")] mock_echo = self._run(entries, _client_showing({1: 3})) assert any("TAMPERING" in str(c) for c in mock_echo.call_args_list) diff --git a/steam_backlog_enforcer/tests/test_scanning_part3.py b/steam_backlog_enforcer/tests/test_scanning_part3.py index 73c43c0..da2828c 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part3.py +++ b/steam_backlog_enforcer/tests/test_scanning_part3.py @@ -2,30 +2,22 @@ from __future__ import annotations +from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.scanning import pick_next_game -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._scanning_games import ( + make_config, + make_game, + pick_env, + pick_without_batch_refresh, +) +if TYPE_CHECKING: + from steam_backlog_enforcer.steam_api import GameInfo -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +_CONFIDENCE = "steam_backlog_enforcer._scanning_confidence" class TestPickNextGame: @@ -33,12 +25,11 @@ class TestPickNextGame: def test_zero_confidence_is_refreshed_before_skipping(self) -> None: """Missing confidence fields are refreshed once before final skip decision.""" - stale = _game(app_id=1, name="Celeste", hours=1.0) + stale = make_game(app_id=1, name="Celeste", hours=1.0) stale.comp_100_count = 0 stale.count_comp = 0 - fallback = _game(app_id=2, name="Fallback", hours=2.0) + fallback = make_game(app_id=2, name="Fallback", hours=2.0) - config = Config(steam_api_key="k", steam_id="i") state = State() echoed: list[str] = [] @@ -50,32 +41,12 @@ class TestPickNextGame: with ( patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence", + f"{_CONFIDENCE}._refresh_candidate_confidence", side_effect=refresh_side_effect, ) as mock_refresh, - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), + pick_env(echo=("scanning", "_scanning_confidence"), echoed=echoed), ): - pick_next_game([stale, fallback], state, config) + pick_next_game([stale, fallback], state, make_config()) assert state.current_app_id == 1 mock_refresh.assert_called_once_with(stale) @@ -83,34 +54,10 @@ class TestPickNextGame: def test_nonzero_low_confidence_does_not_force_refetch(self) -> None: """Non-zero low-confidence entries are skipped using cached values.""" - low = _game(app_id=1, name="Low", hours=1.0) + low = make_game(app_id=1, name="Low", hours=1.0) low.comp_100_count = 1 low.count_comp = 8 - fallback = _game(app_id=2, name="Fallback", hours=2.0) - - config = Config(steam_api_key="k", steam_id="i") - state = State() - - with ( - patch( - "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch" - ) as mock_refresh_batch, - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, fallback], state, config) + fallback = make_game(app_id=2, name="Fallback", hours=2.0) + state = pick_without_batch_refresh([low, fallback]) assert state.current_app_id == 2 - mock_refresh_batch.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_scanning_part4.py b/steam_backlog_enforcer/tests/test_scanning_part4.py index 2ffc1aa..86e2748 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part4.py +++ b/steam_backlog_enforcer/tests/test_scanning_part4.py @@ -9,26 +9,7 @@ from steam_backlog_enforcer.scanning import ( _collect_top_candidates, do_check, ) -from steam_backlog_enforcer.steam_api import GameInfo - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +from steam_backlog_enforcer.tests._scanning_games import make_game class TestCollectTopCandidates: @@ -36,7 +17,7 @@ class TestCollectTopCandidates: def test_collects_up_to_n(self) -> None: """Returns at most n qualified candidates.""" - games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 6)] + games = [make_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 6)] with patch( "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", side_effect=lambda c: c[0] if c else None, @@ -49,8 +30,8 @@ class TestCollectTopCandidates: def test_skips_linux_incompatible(self) -> None: """Games failing ProtonDB are counted in linux_skipped.""" - g1 = _game(app_id=1, name="Borked", hours=1.0) - g2 = _game(app_id=2, name="Good", hours=2.0) + g1 = make_game(app_id=1, name="Borked", hours=1.0) + g2 = make_game(app_id=2, name="Good", hours=2.0) with ( patch( "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", @@ -72,7 +53,7 @@ class TestCollectTopCandidates: def test_no_linux_skip_message_when_zero(self) -> None: """No skip message is printed when linux_skipped is 0.""" - g = _game(app_id=1, name="Good", hours=1.0) + g = make_game(app_id=1, name="Good", hours=1.0) with ( patch( "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py index fe8dc8b..177f71b 100644 --- a/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py @@ -7,32 +7,19 @@ from unittest.mock import patch import pytest -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.game_uninstall import uninstall_other_games from steam_backlog_enforcer.scanning import ( pick_next_game, ) -from steam_backlog_enforcer.steam_api import GameInfo from steam_backlog_enforcer.tests._main_helpers import STARTED_AT +from steam_backlog_enforcer.tests._scanning_games import ( + make_config, + make_game, + pick_env, +) - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +_CONF_ECHO = ("scanning", "_scanning_confidence") class TestPickNextGame: @@ -40,159 +27,72 @@ class TestPickNextGame: def test_picks_shortest(self) -> None: """Test picks shortest.""" - g1 = _game(app_id=1, name="Long", hours=100.0) - g2 = _game(app_id=2, name="Short", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="Long", hours=100.0) + g2 = make_game(app_id=2, name="Short", hours=10.0) state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) + with pick_env(echo=_CONF_ECHO): + pick_next_game([g1, g2], state, make_config()) assert state.current_app_id == 2 def test_no_candidates(self) -> None: """Test no candidates.""" - g1 = _game(app_id=1, total=5, unlocked=5) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, total=5, unlocked=5) state = State() with patch("steam_backlog_enforcer.scanning._echo"): - pick_next_game([g1], state, config) + pick_next_game([g1], state, make_config()) assert state.current_app_id is None def test_skips_finished(self) -> None: """Test skips finished.""" - g1 = _game(app_id=1, name="G1", hours=10.0) - g2 = _game(app_id=2, name="G2", hours=20.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=10.0) + g2 = make_game(app_id=2, name="G2", hours=20.0) state = State(finished_app_ids=[1]) - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) + with pick_env(): + pick_next_game([g1, g2], state, make_config()) assert state.current_app_id == 2 def test_no_playable(self) -> None: """Test no playable.""" - g1 = _game(app_id=1, name="G1") - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1") state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - return_value=None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - ): - pick_next_game([g1], state, config) + with pick_env(playable=lambda _c: None): + pick_next_game([g1], state, make_config()) assert state.current_app_id is None def test_uninstalls_others(self) -> None: """Test uninstalls others.""" - g1 = _game(app_id=1, name="G1", hours=10.0) - config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True) + g1 = make_game(app_id=1, name="G1", hours=10.0) + config = make_config(uninstall_other_games=True) state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=2, - ), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch("builtins.input", return_value="1"), - ): + with pick_env(echo=_CONF_ECHO) as mocks: + mocks.uninstall.return_value = 2 pick_next_game([g1], state, config) assert state.current_app_id == 1 def test_auto_installs(self) -> None: """Test auto installs.""" - g1 = _game(app_id=1, name="G1", hours=10.0) - config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False) + g1 = make_game(app_id=1, name="G1", hours=10.0) + config = make_config(uninstall_other_games=False) state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=False, - ), - patch( - "steam_backlog_enforcer._scanning_assign.install_game" - ) as mock_install, - patch("builtins.input", return_value="1"), - ): + with pick_env(echo=_CONF_ECHO, installed=False) as mocks: pick_next_game([g1], state, config) - mock_install.assert_called_once() + mocks.install.assert_called_once() class TestAssignUninstallsAgainstTheAllowedSet: """_assign_chosen_game must pass the allowed *set*, never a bare app id.""" def test_passes_set_including_concurrent_manual_pick(self) -> None: - g = _game(app_id=2, name="Short", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") + """Uninstall keeps both the new assignment and an existing manual pick.""" + g = make_game(app_id=2, name="Short", hours=10.0) state = State( manual_picks=[ {"app_id": 900, "game_name": "Pick", "started_at": STARTED_AT} ], ) - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch("steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ) as mock_uninstall, - patch("builtins.input", return_value="1"), - ): - pick_next_game([g], state, config) - (allowed,) = mock_uninstall.call_args.args + with pick_env(echo=_CONF_ECHO) as mocks: + pick_next_game([g], state, make_config()) + (allowed,) = mocks.uninstall.call_args.args assert isinstance(allowed, set) # The new assignment survives, and so does the untouched manual pick. assert allowed == {2, 900} diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py index 0250a3a..9ff37a5 100644 --- a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_sequential.py @@ -2,82 +2,61 @@ from __future__ import annotations -import contextlib +from contextlib import contextmanager +from typing import TYPE_CHECKING from unittest.mock import patch -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.scanning import pick_next_game -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._scanning_games import ( + first_candidate, + make_config, + make_game, + pick_env, +) + +if TYPE_CHECKING: + from collections.abc import Callable, Iterator + + from steam_backlog_enforcer.steam_api import GameInfo -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +@contextmanager +def _sequential_env( + echoed: list[str], + playable: Callable[[list[GameInfo]], GameInfo | None] = first_candidate, +) -> Iterator[None]: + """Patch the on_select path: assign echo recorded, no prompt, no disk.""" + with ( + patch("steam_backlog_enforcer.config._atomic_write"), + pick_env( + echo=("_scanning_assign",), + echoed=echoed, + answers=None, + playable=playable, + ), + ): + yield class TestPickNextGameSequential: """Tests for the on_select sequential branch of pick_next_game.""" - @staticmethod - def _common_patches(echoed: list[str]) -> contextlib.ExitStack: - """Test common patches.""" - stack = contextlib.ExitStack() - stack.enter_context( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ) - ) - stack.enter_context( - patch( - "steam_backlog_enforcer._scanning_assign._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ) - ) - stack.enter_context( - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ) - ) - stack.enter_context( - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ) - ) - stack.enter_context(patch("steam_backlog_enforcer.config._atomic_write")) - return stack - def test_on_select_accepts_pick(self) -> None: """Test on select accepts pick.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=5.0) + config = make_config() state = State() echoed: list[str] = [] - with self._common_patches(echoed): + with _sequential_env(echoed): pick_next_game([g1], state, config, on_select=lambda _g: True) assert state.current_app_id == 1 def test_on_select_rejection_records_skip_and_picks_next(self) -> None: """Test on select rejection records skip and picks next.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - g2 = _game(app_id=2, name="G2", hours=6.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=5.0) + g2 = make_game(app_id=2, name="G2", hours=6.0) + config = make_config() state = State() echoed: list[str] = [] calls: list[int] = [] @@ -87,7 +66,7 @@ class TestPickNextGameSequential: calls.append(game.app_id) return game.app_id == 2 # reject g1, accept g2 - with self._common_patches(echoed): + with _sequential_env(echoed): pick_next_game([g1, g2], state, config, on_select=on_select) assert calls == [1, 2] @@ -97,56 +76,38 @@ class TestPickNextGameSequential: def test_on_select_no_candidates(self) -> None: """Sequential branch with no candidates keeps the current game.""" - complete = _game(app_id=1, hours=1.0, total=10, unlocked=10) - config = Config(steam_api_key="k", steam_id="i") + complete = make_game(app_id=1, hours=1.0, total=10, unlocked=10) + config = make_config() state = State(current_app_id=99, current_game_name="X") echoed: list[str] = [] - with self._common_patches(echoed): + with _sequential_env(echoed): pick_next_game([complete], state, config, on_select=lambda _g: True) assert state.current_app_id == 99 def test_on_select_no_playable_branch(self) -> None: """Sequential branch when all candidates lack Linux compatibility.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=5.0) + config = make_config() state = State() echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - return_value=None, - ), - patch( - "steam_backlog_enforcer._scanning_assign._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("steam_backlog_enforcer.config._atomic_write"), - ): + with _sequential_env(echoed, playable=lambda _c: None): pick_next_game([g1], state, config, on_select=lambda _g: True) assert state.current_app_id is None assert any("No playable games" in line for line in echoed) def test_on_select_no_confidence_branch(self) -> None: """Sequential branch when all candidates fail HLTB confidence.""" - g1 = _game(app_id=1, name="G1", hours=5.0) + g1 = make_game(app_id=1, name="G1", hours=5.0) g1.comp_100_count = 0 g1.count_comp = 0 - config = Config(steam_api_key="k", steam_id="i") + config = make_config() state = State() echoed: list[str] = [] with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer._scanning_assign._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), patch( "steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence", ), - patch("steam_backlog_enforcer.config._atomic_write"), + _sequential_env(echoed), ): pick_next_game([g1], state, config, on_select=lambda _g: True) assert state.current_app_id is None @@ -154,11 +115,11 @@ class TestPickNextGameSequential: def test_enforcement_started_at_not_overwritten_when_set(self) -> None: """enforcement_started_at is preserved when already populated.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=5.0) + config = make_config() existing_ts = "2024-01-01T00:00:00+00:00" state = State(enforcement_started_at=existing_ts) - with self._common_patches([]): + with _sequential_env([]): pick_next_game([g1], state, config, on_select=lambda _g: True) assert state.current_app_id == 1 assert state.enforcement_started_at == existing_ts diff --git a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py index f97afa7..30eb8e2 100644 --- a/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game_unknown_hours.py @@ -2,32 +2,15 @@ from __future__ import annotations -from unittest.mock import patch - -from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.config import State from steam_backlog_enforcer.scanning import ( pick_next_game, ) -from steam_backlog_enforcer.steam_api import GameInfo - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) +from steam_backlog_enforcer.tests._scanning_games import ( + make_config, + make_game, + pick_env, +) class TestPickNextGameGroup2: @@ -35,123 +18,54 @@ class TestPickNextGameGroup2: def test_unknown_hours(self) -> None: """Test unknown hours.""" - g1 = _game(app_id=1, name="G1", hours=-1) - g2 = _game(app_id=2, name="G2", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=-1) + g2 = make_game(app_id=2, name="G2", hours=10.0) state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) + with pick_env(): + pick_next_game([g1, g2], state, make_config()) assert state.current_app_id == 2 def test_picks_game_no_hours(self) -> None: """Chosen game has no HLTB hours — covers no-hours output branch.""" - g1 = _game(app_id=1, name="G1", hours=-1) - config = Config(steam_api_key="k", steam_id="i") + g1 = make_game(app_id=1, name="G1", hours=-1) state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("steam_backlog_enforcer.scanning._echo"), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) + with pick_env(): + pick_next_game([g1], state, make_config()) assert state.current_app_id == 1 def test_skips_low_confidence_and_picks_next(self) -> None: """Test skips low confidence and picks next.""" - low = _game(app_id=1, name="LowConfidence", hours=1.0) + low = make_game(app_id=1, name="LowConfidence", hours=1.0) low.comp_100_count = 1 low.count_comp = 5 - valid = _game(app_id=2, name="ValidConfidence", hours=2.0) + valid = make_game(app_id=2, name="ValidConfidence", hours=2.0) valid.comp_100_count = 3 valid.count_comp = 15 echoed: list[str] = [] - config = Config(steam_api_key="k", steam_id="i") state = State() - with ( - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", - return_value=True, - ), - patch( - "steam_backlog_enforcer._scanning_assign.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, valid], state, config) + with pick_env(echo=("scanning", "_scanning_confidence"), echoed=echoed): + pick_next_game([low, valid], state, make_config()) assert state.current_app_id == 2 assert any("Skipping LowConfidence" in line for line in echoed) assert any("comp_100 polls 1 < 3" in line for line in echoed) def test_all_candidates_filtered_by_confidence(self) -> None: """Test all candidates filtered by confidence.""" - low_a = _game(app_id=1, name="LowA", hours=1.0) + low_a = make_game(app_id=1, name="LowA", hours=1.0) low_a.comp_100_count = 2 low_a.count_comp = 15 - low_b = _game(app_id=2, name="LowB", hours=2.0) + low_b = make_game(app_id=2, name="LowB", hours=2.0) low_b.comp_100_count = 3 low_b.count_comp = 14 echoed: list[str] = [] - config = Config(steam_api_key="k", steam_id="i") state = State() - with ( - patch( - "steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - # The "nothing assignable" line is spoken where the slot is cleared. - patch( - "steam_backlog_enforcer._scanning_assign._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "steam_backlog_enforcer._scanning_candidates._pick_playable_candidate", - return_value=None, - ) as mock_pick, - ): - pick_next_game([low_a, low_b], state, config) + # The "nothing assignable" line is spoken where the slot is cleared. + with pick_env( + echo=("scanning", "_scanning_confidence", "_scanning_assign"), + echoed=echoed, + playable=lambda _c: None, + ) as mocks: + pick_next_game([low_a, low_b], state, make_config()) assert state.current_app_id is None - mock_pick.assert_not_called() + mocks.pick.assert_not_called() assert any("No assignable games found" in line for line in echoed) diff --git a/steam_backlog_enforcer/tests/test_scanning_tampering_window.py b/steam_backlog_enforcer/tests/test_scanning_tampering_window.py index 6577657..9fb2e73 100644 --- a/steam_backlog_enforcer/tests/test_scanning_tampering_window.py +++ b/steam_backlog_enforcer/tests/test_scanning_tampering_window.py @@ -7,6 +7,11 @@ from unittest.mock import MagicMock, patch from steam_backlog_enforcer import _scanning_tampering as tamper from steam_backlog_enforcer._progress import use_progress +from steam_backlog_enforcer._scanning_tampering import ( + _TAMPER_WINDOW, + _needs_refetch, + _refetch_in_order, +) from steam_backlog_enforcer.config import Config, State _PKG = "steam_backlog_enforcer._scanning_tampering" @@ -36,36 +41,47 @@ def _client(unlocked_by_id: dict[int, int]) -> MagicMock: class TestNeedsRefetch: + """Which snapshot games are worth a Steam request.""" + def test_exempt_complete_and_unplayed_games_are_not_worth_a_request(self) -> None: - assert not tamper._needs_refetch(_entry(1), {1}) - assert not tamper._needs_refetch(_entry(1, unlocked_achievements=10), set()) - assert not tamper._needs_refetch(_entry(1, playtime_minutes=0), set()) + """Exempt, complete and unplayed games are skipped.""" + assert not _needs_refetch(_entry(1), {1}) + assert not _needs_refetch(_entry(1, unlocked_achievements=10), set()) + assert not _needs_refetch(_entry(1, playtime_minutes=0), set()) def test_an_unfinished_played_game_is_checked(self) -> None: - assert tamper._needs_refetch(_entry(1), set()) + """A played, unfinished game is re-fetched.""" + assert _needs_refetch(_entry(1), set()) class TestRefetchInOrder: + """The sliding-window re-fetch keeps input order.""" + def test_yields_every_entry_in_order_beyond_the_window(self) -> None: - entries = [_entry(i) for i in range(tamper._TAMPER_WINDOW * 2 + 3)] + """Results come back in input order even past the window size.""" + entries = [_entry(i) for i in range(_TAMPER_WINDOW * 2 + 3)] client = _client({e["app_id"]: 5 for e in entries}) - seen = [e["app_id"] for e, _ in tamper._refetch_in_order(client, entries)] + seen = [e["app_id"] for e, _ in _refetch_in_order(client, entries)] assert seen == [e["app_id"] for e in entries] def test_a_game_steam_cannot_answer_for_comes_back_as_none(self) -> None: - pairs = list(tamper._refetch_in_order(_client({}), [_entry(1)])) + """A game Steam cannot answer for pairs with None.""" + pairs = list(_refetch_in_order(_client({}), [_entry(1)])) assert pairs[0][1] is None def test_closing_early_stops_asking(self) -> None: - entries = [_entry(i) for i in range(tamper._TAMPER_WINDOW * 4)] + """Closing the stream stops further requests.""" + entries = [_entry(i) for i in range(_TAMPER_WINDOW * 4)] client = _client({e["app_id"]: 5 for e in entries}) - stream = tamper._refetch_in_order(client, entries) + stream = _refetch_in_order(client, entries) next(stream) stream.close() assert client.refresh_single_game.call_count < len(entries) class TestDetectTamperingProgress: + """detect_tampering reports progress per checked game.""" + def _run(self, entries: list[dict[str, Any]], client: MagicMock) -> MagicMock: progress = MagicMock() with ( @@ -79,12 +95,14 @@ class TestDetectTamperingProgress: return progress def test_one_step_per_game_checked(self) -> None: + """The phase counts only checked games and advances once per game.""" entries = [_entry(1), _entry(2), _entry(3, unlocked_achievements=10)] progress = self._run(entries, _client({1: 5, 2: 5})) progress.phase.assert_called_once_with("Checking other games for tampering", 2) assert [c.args[0] for c in progress.advance.call_args_list] == ["G1", "G2"] def test_hitting_the_limit_completes_the_bar(self) -> None: + """Stopping at the limit advances the bar to its full step count.""" entries = [_entry(i) for i in range(6)] progress = self._run(entries, _client(dict.fromkeys(range(6), 6))) assert progress.advance.call_args_list[-1].kwargs == {"step": 6} diff --git a/steam_backlog_enforcer/tests/test_serve_guard.py b/steam_backlog_enforcer/tests/test_serve_guard.py index dae221a..e96ade1 100644 --- a/steam_backlog_enforcer/tests/test_serve_guard.py +++ b/steam_backlog_enforcer/tests/test_serve_guard.py @@ -18,9 +18,6 @@ from steam_backlog_enforcer._serve_guard import ( _hex_v6, _listening_inodes, _pid_owns_inode, - find_port_owner, - is_our_server, - read_cmdline, ) from steam_backlog_enforcer.tests._fake_proc import ( add_opaque_pid, @@ -43,15 +40,19 @@ class TestHexRendering: """Tests for the /proc address column encoders.""" def test_port_is_upper_hex(self) -> None: + """Ports render as upper-case hex.""" assert _hex_port(8000) == "1F40" def test_ipv4_is_little_endian(self) -> None: + """IPv4 addresses render little-endian.""" assert _hex_v4("127.0.0.1") == "0100007F" def test_ipv6_swaps_each_word(self) -> None: + """IPv6 addresses render with each 32-bit word byte-swapped.""" assert _hex_v6("::1") == "0" * 24 + "01000000" def test_v4_mapped_v6_is_rendered(self) -> None: + """A v4-mapped v6 address ends in the v4 encoding.""" mapped = _hex_v6("::ffff:127.0.0.1") assert mapped.endswith("0100007F") @@ -60,12 +61,14 @@ class TestConflictingAddresses: """Tests for which listeners actually block a bind.""" def test_includes_self_wildcard_and_mapped(self) -> None: + """The host itself, the v4 wildcard and the v6 wildcard all conflict.""" addrs = _conflicting_addresses(_HOST) assert _hex_v4(_HOST) in addrs assert _WILDCARD_V4 in addrs assert _hex_v6("::") in addrs def test_excludes_an_unrelated_interface(self) -> None: + """A LAN address does not conflict with a loopback bind.""" # A listener on a LAN address must never be reported as the owner of a # loopback bind; naming it would blame an innocent process. assert _hex_v4("192.168.1.5") not in _conflicting_addresses(_HOST) @@ -75,12 +78,14 @@ class TestListeningInodes: """Tests for parsing /proc/net/tcp.""" def test_finds_matching_listener(self, tmp_path: Path) -> None: + """A listener on host:port is found.""" proc = make_proc(tmp_path) write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242)]) with patch_proc(proc): assert _listening_inodes(_HOST, _PORT) == {4242} def test_finds_wildcard_listener(self, tmp_path: Path) -> None: + """A v4 wildcard listener on the port is found.""" proc = make_proc(tmp_path) wildcard = f"{_WILDCARD_V4}:{_hex_port(_PORT)}" write_net_tcp(proc, [tcp_row(wildcard, 7)]) @@ -88,6 +93,7 @@ class TestListeningInodes: assert _listening_inodes(_HOST, _PORT) == {7} def test_finds_v6_wildcard_listener(self, tmp_path: Path) -> None: + """A v6 wildcard listener in tcp6 is found.""" # A dual-stack bind lives in tcp6 even when our bind is IPv4, which is # why both files are scanned. proc = make_proc(tmp_path) @@ -97,6 +103,7 @@ class TestListeningInodes: assert _listening_inodes(_HOST, _PORT) == {9} def test_ignores_non_listening_state(self, tmp_path: Path) -> None: + """An established connection is not a listener.""" # 01 is ESTABLISHED: an outbound connection, not a bind. proc = make_proc(tmp_path) write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242, state="01")]) @@ -104,6 +111,7 @@ class TestListeningInodes: assert _listening_inodes(_HOST, _PORT) == set() def test_ignores_other_port(self, tmp_path: Path) -> None: + """A listener on another port is ignored.""" proc = make_proc(tmp_path) other = f"{_hex_v4(_HOST)}:{_hex_port(9999)}" write_net_tcp(proc, [tcp_row(other, 4242)]) @@ -111,6 +119,7 @@ class TestListeningInodes: assert _listening_inodes(_HOST, _PORT) == set() def test_ignores_other_interface(self, tmp_path: Path) -> None: + """A listener on another interface is ignored.""" proc = make_proc(tmp_path) lan = f"{_hex_v4('192.168.1.5')}:{_hex_port(_PORT)}" write_net_tcp(proc, [tcp_row(lan, 4242)]) @@ -118,18 +127,21 @@ class TestListeningInodes: assert _listening_inodes(_HOST, _PORT) == set() def test_ignores_truncated_row(self, tmp_path: Path) -> None: + """A row too short to parse is skipped.""" proc = make_proc(tmp_path) write_net_tcp(proc, [f" 0: {_LOCAL_V4} 0A 4242"]) with patch_proc(proc): assert _listening_inodes(_HOST, _PORT) == set() def test_ignores_non_numeric_inode(self, tmp_path: Path) -> None: + """A row with a non-numeric inode is skipped.""" proc = make_proc(tmp_path) write_net_tcp(proc, [tcp_row(_LOCAL_V4, inode="notanumber")]) with patch_proc(proc): assert _listening_inodes(_HOST, _PORT) == set() def test_unreadable_file_is_skipped(self, tmp_path: Path) -> None: + """A missing tcp6 file does not hide tcp listeners.""" proc = make_proc(tmp_path) write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242)]) (proc / "net" / "tcp6").unlink() @@ -141,21 +153,25 @@ class TestPidOwnsInode: """Tests for matching a socket inode to a process.""" def test_matching_fd_is_found(self, tmp_path: Path) -> None: + """A process holding the socket inode owns it.""" proc = make_proc(tmp_path) pid_dir = add_pid(proc, 100, inodes=[1, 4242]) assert _pid_owns_inode(pid_dir, {4242}) is True def test_unrelated_fds_do_not_match(self, tmp_path: Path) -> None: + """Other fds do not make a process the owner.""" proc = make_proc(tmp_path) pid_dir = add_pid(proc, 100, inodes=[1, 2]) assert _pid_owns_inode(pid_dir, {4242}) is False def test_unreadable_fd_dir_is_not_an_owner(self, tmp_path: Path) -> None: + """A process whose fds cannot be read is not an owner.""" proc = make_proc(tmp_path) pid_dir = add_opaque_pid(proc, 100) assert _pid_owns_inode(pid_dir, {4242}) is False def test_unreadable_fds_are_skipped(self, tmp_path: Path) -> None: + """fd entries that cannot be readlink()ed are skipped.""" # A plain file in fd/ cannot be readlink()ed. Only unreadable entries # here, so the skip is exercised whatever order iterdir() returns — # a version of this that mixed in a real socket passed locally and @@ -169,76 +185,10 @@ class TestPidOwnsInode: def test_an_unreadable_fd_does_not_abandon_the_process( self, tmp_path: Path ) -> None: + """A broken fd does not stop the scan from finding the socket.""" # The matching socket must still be found alongside a broken entry, # rather than the scan giving up on the process at the first OSError. proc = make_proc(tmp_path) pid_dir = add_pid(proc, 100, inodes=[4242]) (pid_dir / "fd" / "broken").write_text("", encoding="utf-8") assert _pid_owns_inode(pid_dir, {4242}) is True - - -class TestFindPortOwner: - """Tests for end-to-end owner resolution.""" - - def test_no_listener_returns_none(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - with patch_proc(proc): - assert find_port_owner(_HOST, _PORT) is None - - def test_resolves_the_listening_pid(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242)]) - add_pid(proc, 100, inodes=[9]) - add_pid(proc, 200, inodes=[4242]) - with patch_proc(proc): - assert find_port_owner(_HOST, _PORT) == 200 - - def test_uninspectable_owner_resolves_to_none(self, tmp_path: Path) -> None: - # Another user's listener: occupied, but not attributable. Callers must - # read this as "cannot identify", never as "free". - proc = make_proc(tmp_path) - write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242)]) - add_opaque_pid(proc, 100) - with patch_proc(proc): - assert find_port_owner(_HOST, _PORT) is None - - -class TestReadCmdline: - """Tests for argv reading.""" - - def test_returns_argv(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - add_pid(proc, 100, cmdline=["python", "-m", "steam_backlog_enforcer.main"]) - with patch_proc(proc): - assert read_cmdline(100)[0] == "python" - - def test_missing_process_is_empty(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - with patch_proc(proc): - assert read_cmdline(4321) == [] - - -class TestIsOurServer: - """Tests for recognising one of our own serve processes.""" - - def test_our_serve_process_matches(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - add_pid( - proc, 100, cmdline=["python", "-m", "steam_backlog_enforcer.main", "serve"] - ) - with patch_proc(proc): - assert is_our_server(100) is True - - def test_other_subcommand_does_not_match(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - add_pid( - proc, 100, cmdline=["python", "-m", "steam_backlog_enforcer.main", "status"] - ) - with patch_proc(proc): - assert is_our_server(100) is False - - def test_unrelated_process_does_not_match(self, tmp_path: Path) -> None: - proc = make_proc(tmp_path) - add_pid(proc, 100, cmdline=["nginx", "serve"]) - with patch_proc(proc): - assert is_our_server(100) is False diff --git a/steam_backlog_enforcer/tests/test_serve_guard_lifecycle.py b/steam_backlog_enforcer/tests/test_serve_guard_lifecycle.py index c25d33a..c91b67e 100644 --- a/steam_backlog_enforcer/tests/test_serve_guard_lifecycle.py +++ b/steam_backlog_enforcer/tests/test_serve_guard_lifecycle.py @@ -18,6 +18,7 @@ from typing import TYPE_CHECKING from unittest.mock import patch from steam_backlog_enforcer import _serve_guard, _serve_stale +from steam_backlog_enforcer._serve_guard import _own_pid_chain, _read_ppid from steam_backlog_enforcer.tests._fake_proc import add_pid, make_proc, patch_proc if TYPE_CHECKING: @@ -30,6 +31,7 @@ class TestProcessStartedAt: """Tests for reading a process start time.""" def test_returns_directory_mtime(self, tmp_path: Path) -> None: + """The start time is the mtime of the process's /proc directory.""" proc = make_proc(tmp_path) pid_dir = add_pid(proc, 100) os.utime(pid_dir, (1000.0, 1000.0)) @@ -37,6 +39,7 @@ class TestProcessStartedAt: assert _serve_guard.process_started_at(100) == 1000.0 def test_missing_process_returns_none(self, tmp_path: Path) -> None: + """A pid with no /proc entry has no start time.""" proc = make_proc(tmp_path) with patch_proc(proc): assert _serve_guard.process_started_at(4321) is None @@ -46,12 +49,14 @@ class TestNewestPySince: """Tests for deciding whether a running server loaded outdated code.""" def test_no_newer_file_returns_none(self, tmp_path: Path) -> None: + """No source file newer than the server start means not stale.""" (tmp_path / "a.py").write_text("x", encoding="utf-8") os.utime(tmp_path / "a.py", (1000.0, 1000.0)) with patch.object(_serve_stale, "_PACKAGE_ROOT", tmp_path): assert _serve_guard.newest_py_since(2000.0) is None def test_newer_file_is_named(self, tmp_path: Path) -> None: + """A newer source file is returned by path, so a restart can name it.""" # The path is returned rather than a bool so the restart message can # name the file: killing a server is always auditable. nested = tmp_path / "pkg" @@ -62,6 +67,7 @@ class TestNewestPySince: assert _serve_guard.newest_py_since(2000.0) == nested / "b.py" def test_the_newest_of_several_wins(self, tmp_path: Path) -> None: + """Among several newer files the most recent one is named.""" for name, mtime in (("a.py", 3000.0), ("b.py", 4000.0), ("c.py", 3500.0)): (tmp_path / name).write_text("x", encoding="utf-8") os.utime(tmp_path / name, (mtime, mtime)) @@ -69,6 +75,7 @@ class TestNewestPySince: assert _serve_guard.newest_py_since(2000.0) == tmp_path / "b.py" def test_vanished_file_is_skipped(self, tmp_path: Path) -> None: + """A dangling symlink in the tree is skipped, not an error.""" (tmp_path / "gone.py").symlink_to(tmp_path / "missing.py") with patch.object(_serve_stale, "_PACKAGE_ROOT", tmp_path): assert _serve_guard.newest_py_since(1000.0) is None @@ -78,64 +85,73 @@ class TestReadPpid: """Tests for parsing the parent pid out of /proc//stat.""" def test_reads_ppid_past_a_comm_with_spaces(self, tmp_path: Path) -> None: + """The parent pid is read past a command name that contains spaces.""" proc = make_proc(tmp_path) add_pid(proc, 100, ppid=42) with patch_proc(proc): - assert _serve_guard._read_ppid(100) == 42 + assert _read_ppid(100) == 42 def test_missing_stat_returns_none(self, tmp_path: Path) -> None: + """A process with no stat file has no parent pid.""" proc = make_proc(tmp_path) add_pid(proc, 100) with patch_proc(proc): - assert _serve_guard._read_ppid(100) is None + assert _read_ppid(100) is None def test_truncated_stat_returns_none(self, tmp_path: Path) -> None: + """A stat line cut short before the ppid field yields None.""" proc = make_proc(tmp_path) add_pid(proc, 100) (proc / "100" / "stat").write_text("100 (py) S", encoding="utf-8") with patch_proc(proc): - assert _serve_guard._read_ppid(100) is None + assert _read_ppid(100) is None def test_non_numeric_ppid_returns_none(self, tmp_path: Path) -> None: + """A non-numeric ppid field yields None.""" proc = make_proc(tmp_path) add_pid(proc, 100) (proc / "100" / "stat").write_text("100 (py) S bogus 1", encoding="utf-8") with patch_proc(proc): - assert _serve_guard._read_ppid(100) is None + assert _read_ppid(100) is None class TestOwnPidChain: """Tests for the self-protection ancestry walk.""" def test_includes_self_and_ancestors(self) -> None: + """The chain holds this process and every ancestor walked.""" with patch.object(_serve_guard, "_read_ppid", side_effect=[7, None]): - chain = _serve_guard._own_pid_chain() + chain = _own_pid_chain() assert os.getpid() in chain assert 7 in chain def test_stops_at_pid_zero(self) -> None: + """The walk stops at pid 0, which is not a process.""" # init's parent is 0, which is not a process and must not be walked. with patch.object(_serve_guard, "_read_ppid", return_value=0): - chain = _serve_guard._own_pid_chain() + chain = _own_pid_chain() assert chain == {os.getpid()} def test_a_cycle_terminates(self) -> None: + """A process that claims to be its own parent cannot hang the walk.""" # A stat file claiming a process is its own parent must not hang the # walk; the guard runs before the server starts, so a spin is a freeze. with patch.object(_serve_guard, "_read_ppid", return_value=os.getpid()): - assert _serve_guard._own_pid_chain() == {os.getpid()} + assert _own_pid_chain() == {os.getpid()} class TestPortIsFree: """Tests for the real bind probe.""" def test_unused_port_is_free(self) -> None: + """A port nothing listens on is reported free.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as probe: probe.bind((_HOST, 0)) port = probe.getsockname()[1] assert _serve_guard.port_is_free(_HOST, port) is True def test_bound_port_is_not_free(self) -> None: + """A port with a listener bound is reported taken.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as held: held.bind((_HOST, 0)) held.listen(1) @@ -151,6 +167,7 @@ class TestTerminate: """ def test_refuses_to_signal_self(self) -> None: + """The guard never signals the process running it.""" # The process running this check matches is_our_server by construction, # so a wrong owner lookup would otherwise have it kill itself. with ( @@ -162,6 +179,7 @@ class TestTerminate: fake_os.kill.assert_not_called() def test_sigterm_frees_the_port(self) -> None: + """SIGTERM alone is enough when the port frees in time.""" with ( patch.object(_serve_guard, "_own_pid_chain", return_value=set()), patch.object(_serve_guard, "_TERM_WAIT_SECONDS", 0.05), @@ -173,6 +191,7 @@ class TestTerminate: fake_os.kill.assert_called_once_with(999, signal.SIGTERM) def test_escalates_to_sigkill(self) -> None: + """A server that ignores SIGTERM gets SIGKILL.""" # The port frees only once SIGKILL lands, driven off the recorded # signals rather than a call count, so the SIGTERM window's length # cannot make this flaky. @@ -193,6 +212,7 @@ class TestTerminate: assert sent == [signal.SIGTERM, signal.SIGKILL] def test_gives_up_when_the_port_never_frees(self) -> None: + """The guard gives up when the port never frees.""" with ( patch.object(_serve_guard, "_own_pid_chain", return_value=set()), patch.object(_serve_guard, "_TERM_WAIT_SECONDS", 0.0), @@ -203,6 +223,7 @@ class TestTerminate: assert _serve_guard.terminate(999, _HOST, 8000) is False def test_already_exited_process_is_not_an_error(self) -> None: + """A process that already exited counts as stopped.""" with ( patch.object(_serve_guard, "_own_pid_chain", return_value=set()), patch.object(_serve_guard, "_TERM_WAIT_SECONDS", 0.0), @@ -214,6 +235,7 @@ class TestTerminate: assert _serve_guard.terminate(999, _HOST, 8000) is True def test_permission_denied_is_not_an_error(self) -> None: + """A process owned by someone else is a failure, not a crash.""" with ( patch.object(_serve_guard, "_own_pid_chain", return_value=set()), patch.object(_serve_guard, "_TERM_WAIT_SECONDS", 0.0), diff --git a/steam_backlog_enforcer/tests/test_serve_guard_owner.py b/steam_backlog_enforcer/tests/test_serve_guard_owner.py new file mode 100644 index 0000000..ee46c1c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_serve_guard_owner.py @@ -0,0 +1,108 @@ +"""Tests for _serve_guard's port-owner resolution and serve-process check. + +Split from test_serve_guard.py to stay inside the 250-line file cap. Every test +runs against a fake /proc built in tmp_path; none reads the host's real process +table. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from steam_backlog_enforcer._serve_guard import ( + _hex_port, + _hex_v4, + find_port_owner, + is_our_server, + read_cmdline, +) +from steam_backlog_enforcer.tests._fake_proc import ( + add_opaque_pid, + add_pid, + make_proc, + patch_proc, + tcp_row, + write_net_tcp, +) + +if TYPE_CHECKING: + from pathlib import Path + +_HOST = "127.0.0.1" +_PORT = 8000 +_LOCAL_V4 = f"{_hex_v4(_HOST)}:{_hex_port(_PORT)}" + + +class TestFindPortOwner: + """Tests for end-to-end owner resolution.""" + + def test_no_listener_returns_none(self, tmp_path: Path) -> None: + """A free port has no owner.""" + proc = make_proc(tmp_path) + with patch_proc(proc): + assert find_port_owner(_HOST, _PORT) is None + + def test_resolves_the_listening_pid(self, tmp_path: Path) -> None: + """The pid holding the listening inode is the owner.""" + proc = make_proc(tmp_path) + write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242)]) + add_pid(proc, 100, inodes=[9]) + add_pid(proc, 200, inodes=[4242]) + with patch_proc(proc): + assert find_port_owner(_HOST, _PORT) == 200 + + def test_uninspectable_owner_resolves_to_none(self, tmp_path: Path) -> None: + """An owner whose fds cannot be read resolves to None.""" + # Another user's listener: occupied, but not attributable. Callers must + # read this as "cannot identify", never as "free". + proc = make_proc(tmp_path) + write_net_tcp(proc, [tcp_row(_LOCAL_V4, 4242)]) + add_opaque_pid(proc, 100) + with patch_proc(proc): + assert find_port_owner(_HOST, _PORT) is None + + +class TestReadCmdline: + """Tests for argv reading.""" + + def test_returns_argv(self, tmp_path: Path) -> None: + """read_cmdline returns the process argv.""" + proc = make_proc(tmp_path) + add_pid(proc, 100, cmdline=["python", "-m", "steam_backlog_enforcer.main"]) + with patch_proc(proc): + assert read_cmdline(100)[0] == "python" + + def test_missing_process_is_empty(self, tmp_path: Path) -> None: + """A missing pid reads as an empty argv.""" + proc = make_proc(tmp_path) + with patch_proc(proc): + assert read_cmdline(4321) == [] + + +class TestIsOurServer: + """Tests for recognising one of our own serve processes.""" + + def test_our_serve_process_matches(self, tmp_path: Path) -> None: + """Our main module running serve is recognised.""" + proc = make_proc(tmp_path) + add_pid( + proc, 100, cmdline=["python", "-m", "steam_backlog_enforcer.main", "serve"] + ) + with patch_proc(proc): + assert is_our_server(100) is True + + def test_other_subcommand_does_not_match(self, tmp_path: Path) -> None: + """Our main module running another subcommand is not.""" + proc = make_proc(tmp_path) + add_pid( + proc, 100, cmdline=["python", "-m", "steam_backlog_enforcer.main", "status"] + ) + with patch_proc(proc): + assert is_our_server(100) is False + + def test_unrelated_process_does_not_match(self, tmp_path: Path) -> None: + """A foreign program is not ours.""" + proc = make_proc(tmp_path) + add_pid(proc, 100, cmdline=["nginx", "serve"]) + with patch_proc(proc): + assert is_our_server(100) is False diff --git a/steam_backlog_enforcer/tests/test_serve_stale.py b/steam_backlog_enforcer/tests/test_serve_stale.py index 35c7de9..4e54972 100644 --- a/steam_backlog_enforcer/tests/test_serve_stale.py +++ b/steam_backlog_enforcer/tests/test_serve_stale.py @@ -10,6 +10,7 @@ server, where no launch path can skip it. from __future__ import annotations import logging +import os from typing import TYPE_CHECKING from unittest.mock import patch @@ -40,8 +41,8 @@ def _clear() -> Iterator[None]: reset_cache() -@pytest.fixture -def fake_package(tmp_path: Path) -> Path: +@pytest.fixture(name="fake_package") +def fixture_fake_package(tmp_path: Path) -> Path: """Point the module at a throwaway package tree. Args: @@ -64,8 +65,6 @@ def _touch(path: Path, mtime: float) -> None: mtime: Epoch seconds to set. """ path.write_text("x = 2\n", encoding="utf-8") - import os - os.utime(path, (mtime, mtime)) diff --git a/steam_backlog_enforcer/tests/test_serve_startup.py b/steam_backlog_enforcer/tests/test_serve_startup.py index 98bdcdb..8738ed7 100644 --- a/steam_backlog_enforcer/tests/test_serve_startup.py +++ b/steam_backlog_enforcer/tests/test_serve_startup.py @@ -12,6 +12,7 @@ from unittest.mock import patch import pytest from steam_backlog_enforcer import _serve_startup +from steam_backlog_enforcer._serve_startup import _describe, _replace_or_defer from steam_backlog_enforcer._web_server import DEFAULT_HOST, DEFAULT_PORT _PKG = "steam_backlog_enforcer._serve_startup" @@ -28,52 +29,65 @@ class TestParseServeArgs: """Tests for argument parsing.""" def test_defaults_when_no_flags(self) -> None: + """No flags give the default host and port.""" assert _serve_startup.parse_serve_args([]) == (DEFAULT_HOST, DEFAULT_PORT) def test_port_flag(self) -> None: + """--port overrides the port only.""" assert _serve_startup.parse_serve_args(["--port", "8123"]) == ( DEFAULT_HOST, 8123, ) def test_host_flag_accepts_literal_loopback(self) -> None: + """Any literal 127/8 loopback address is accepted.""" assert _serve_startup.parse_serve_args(["--host", "127.0.0.2"]) == ( "127.0.0.2", DEFAULT_PORT, ) def test_host_alias_canonicalises(self) -> None: + """localhost resolves to the default host.""" host, _ = _serve_startup.parse_serve_args(["--host", "localhost"]) assert host == DEFAULT_HOST def test_both_flags_together(self) -> None: + """--host and --port combine.""" assert _serve_startup.parse_serve_args( ["--host", "localhost", "--port", "9000"] ) == (DEFAULT_HOST, 9000) def test_unknown_argument_exits(self) -> None: + """An unknown flag exits 1.""" _exits_1(["--wat"]) def test_flag_without_value_exits(self) -> None: + """A flag missing its value exits 1.""" _exits_1(["--port"]) def test_non_numeric_port_exits(self) -> None: + """A non-numeric port exits 1.""" _exits_1(["--port", "eight"]) def test_privileged_port_exits(self) -> None: + """A port below 1024 exits 1.""" _exits_1(["--port", "80"]) def test_port_above_range_exits(self) -> None: + """A port above 65535 exits 1.""" _exits_1(["--port", "99999"]) def test_non_address_host_exits(self) -> None: + """A hostname that is not an IP exits 1.""" _exits_1(["--host", "example.com"]) def test_routable_host_exits(self) -> None: + """A routable address exits 1.""" # The dataset API is unauthenticated; a bind must never leave the box. _exits_1(["--host", "8.8.8.8"]) def test_ipv6_loopback_exits(self) -> None: + """IPv6 loopback exits 1.""" # ThreadingHTTPServer is AF_INET, so ::1 would always crash at bind. _exits_1(["--host", "::1"]) @@ -82,23 +96,27 @@ class TestDescribe: """Tests for the pid description helper.""" def test_includes_argv_when_readable(self) -> None: + """A readable cmdline is shown after the pid.""" with patch(f"{_PKG}.read_cmdline", return_value=["python", "-m", "x"]): - assert _serve_startup._describe(42) == "pid 42 (python -m x)" + assert _describe(42) == "pid 42 (python -m x)" def test_falls_back_to_bare_pid(self) -> None: + """An unreadable cmdline shows only the pid.""" with patch(f"{_PKG}.read_cmdline", return_value=[]): - assert _serve_startup._describe(42) == "pid 42" + assert _describe(42) == "pid 42" class TestEnsurePortAvailable: """Tests for the start-or-defer decision.""" def test_free_port_returns(self) -> None: + """A free port lets startup continue.""" # Returning normally *is* the assertion: every other path exits. with patch(f"{_PKG}.port_is_free", return_value=True): _serve_startup.ensure_port_available(DEFAULT_HOST, 8000) def test_uninspectable_owner_exits_1(self) -> None: + """A busy port with an unknown owner exits 1.""" with ( patch(f"{_PKG}.port_is_free", return_value=False), patch(f"{_PKG}.find_port_owner", return_value=None), @@ -110,6 +128,7 @@ class TestEnsurePortAvailable: assert "cannot inspect" in echo.call_args_list[0][0][0] def test_foreign_owner_exits_1_and_is_named(self) -> None: + """A foreign owner exits 1 and is named.""" with ( patch(f"{_PKG}.port_is_free", return_value=False), patch(f"{_PKG}.find_port_owner", return_value=99), @@ -123,6 +142,7 @@ class TestEnsurePortAvailable: assert "pid 99 (nginx)" in echo.call_args_list[0][0][0] def test_our_server_delegates_to_replace_or_defer(self) -> None: + """Our own server on the port goes to replace-or-defer.""" with ( patch(f"{_PKG}.port_is_free", return_value=False), patch(f"{_PKG}.find_port_owner", return_value=99), @@ -137,17 +157,19 @@ class TestReplaceOrDefer: """Tests for the stale-or-current decision about our own server.""" def test_current_server_exits_0_with_url(self) -> None: + """An up-to-date server is reused: exit 0 with its URL.""" with ( patch(f"{_PKG}.process_started_at", return_value=1000.0), patch(f"{_PKG}.newest_py_since", return_value=None), patch(f"{_PKG}._echo") as echo, pytest.raises(SystemExit) as exc, ): - _serve_startup._replace_or_defer(DEFAULT_HOST, 8000, 99) + _replace_or_defer(DEFAULT_HOST, 8000, 99) assert exc.value.code == 0 assert f"http://{DEFAULT_HOST}:8000" in echo.call_args_list[0][0][0] - def test_stale_server_is_terminated(self, tmp_path: object) -> None: + def test_stale_server_is_terminated(self) -> None: + """A server older than the source is terminated.""" newer = "steam_backlog_enforcer/main/misc.py" with ( patch(f"{_PKG}.process_started_at", return_value=1000.0), @@ -156,21 +178,23 @@ class TestReplaceOrDefer: patch(f"{_PKG}.terminate", return_value=True) as term, patch(f"{_PKG}._echo") as echo, ): - _serve_startup._replace_or_defer(DEFAULT_HOST, 8000, 99) + _replace_or_defer(DEFAULT_HOST, 8000, 99) term.assert_called_once_with(99, DEFAULT_HOST, 8000) assert f"{newer} is newer" in echo.call_args_list[0][0][0] def test_unreadable_start_time_restarts(self) -> None: + """An unreadable start time counts as stale.""" with ( patch(f"{_PKG}.process_started_at", return_value=None), patch(f"{_PKG}.is_our_server", return_value=True), patch(f"{_PKG}.terminate", return_value=True), patch(f"{_PKG}._echo") as echo, ): - _serve_startup._replace_or_defer(DEFAULT_HOST, 8000, 99) + _replace_or_defer(DEFAULT_HOST, 8000, 99) assert "start time unreadable" in echo.call_args_list[0][0][0] def test_recycled_pid_is_never_signalled(self) -> None: + """A pid that is no longer our server is never signalled.""" # Between the port scan and here the process can exit and its pid be # reused. Signalling on the stale answer would kill a bystander. with ( @@ -181,11 +205,12 @@ class TestReplaceOrDefer: patch(f"{_PKG}._echo"), pytest.raises(SystemExit) as exc, ): - _serve_startup._replace_or_defer(DEFAULT_HOST, 8000, 99) + _replace_or_defer(DEFAULT_HOST, 8000, 99) assert exc.value.code == 1 term.assert_not_called() def test_failed_termination_exits_1(self) -> None: + """A server that will not die exits 1.""" with ( patch(f"{_PKG}.process_started_at", return_value=None), patch(f"{_PKG}.is_our_server", return_value=True), @@ -193,6 +218,6 @@ class TestReplaceOrDefer: patch(f"{_PKG}._echo") as echo, pytest.raises(SystemExit) as exc, ): - _serve_startup._replace_or_defer(DEFAULT_HOST, 8000, 99) + _replace_or_defer(DEFAULT_HOST, 8000, 99) assert exc.value.code == 1 assert "Could not free port 8000" in echo.call_args_list[-1][0][0] diff --git a/steam_backlog_enforcer/tests/test_shadow.py b/steam_backlog_enforcer/tests/test_shadow.py index 8374d7f..3f95a1f 100644 --- a/steam_backlog_enforcer/tests/test_shadow.py +++ b/steam_backlog_enforcer/tests/test_shadow.py @@ -23,49 +23,66 @@ _FOCUS = Focus(app="Helium", title="t", domain="youtube.com", idle=False) def _now(state: str, *keys: str) -> Now: + """Build a now.json fact in the given state with one reason per key.""" reasons = tuple(Reason("focus", key, None, key) for key in keys) return Now(ts=T0, seq=1, state=state, paused_by=None, reasons=reasons, focus=_FOCUS) def _state(**over: object) -> PlaytimeState: + """Return a mid-day playtime state with the given fields overridden.""" base = PlaytimeState(day_key="2026-10-19", last_tick_at=T0, seconds=50.0) return replace(base, **over) class TestShadowKeys: + """The shadow bills running games first, then the watcher's reasons.""" + def test_games_win_over_the_watcher(self) -> None: + """Running games are billed, sorted and deduplicated, ahead of the watcher.""" owners = {2: "app:9", 1: "app:440", 3: "app:9"} keys = shadow_keys(owners, _now("leisure", "domain:youtube.com")) assert keys == ("app:440", "app:9") def test_a_missing_fact_fails_closed(self) -> None: + """No watcher fact bills the fail-closed key.""" assert shadow_keys({}, None) == (FAILCLOSED_KEY,) def test_leisure_uses_the_reason_keys_deduplicated(self) -> None: + """A leisure fact bills each reason key once, in order.""" fact = _now("leisure", "domain:youtube.com", "app:mpv", "app:mpv") assert shadow_keys({}, fact) == ("domain:youtube.com", "app:mpv") def test_leisure_without_reasons_fails_closed(self) -> None: + """A leisure fact with no reasons bills the fail-closed key.""" assert shadow_keys({}, _now("leisure")) == (FAILCLOSED_KEY,) @pytest.mark.parametrize("state", ["free", "paused"]) def test_free_or_watcher_paused_bills_nothing(self, state: str) -> None: + """A free or paused watcher bills nothing.""" assert shadow_keys({}, _now(state, "x")) == () class TestVerdictState: + """A verdict's state follows from its pause and keys.""" + def test_paused(self) -> None: + """A verdict with a pauser is paused.""" assert ShadowVerdict(paused_by="i3lock", keys=()).state == "paused" def test_leisure(self) -> None: + """A verdict with keys and no pauser is leisure.""" assert ShadowVerdict(paused_by=None, keys=("a",)).state == "leisure" def test_free(self) -> None: + """A verdict with neither is free.""" assert ShadowVerdict(paused_by=None, keys=()).state == "free" class TestObserveShadow: + """observe_shadow reads the lock claims and the watcher fact.""" + def test_paused_skips_the_fact_file(self) -> None: + """A held lock short-circuits before the fact file is read.""" with ( patch.object(_shadow, "paused_by", return_value="gatelock:x") as lock, patch.object(_shadow, "read_now") as read, @@ -76,6 +93,7 @@ class TestObserveShadow: read.assert_not_called() def test_reads_the_fact_once_with_the_contract_age(self) -> None: + """The fact is read once with the contract's 15 s max age.""" fact = _now("leisure", "domain:youtube.com") with ( patch.object(_shadow, "paused_by", return_value=None), @@ -99,38 +117,48 @@ class TestObserveShadow: assert observe_shadow({}, stamp=T0).keys == (FAILCLOSED_KEY,) def test_root_paths_are_the_desktop_users(self) -> None: + """The fact and claims paths live in uid 1000's runtime dir.""" assert str(_shadow.NOW_PATH) == "/run/user/1000/leisure-watch/now.json" assert str(_shadow.CLAIMS_DIR) == "/run/user/1000/gatelock/claims" class TestShadowAccumulate: + """Shadow billing splits each tick across sources, leaving enforced fields.""" + def _bill(self, state: PlaytimeState, verdict: ShadowVerdict) -> PlaytimeState: + """Bill one 3 s tick of state under verdict.""" return shadow_accumulate(state, stamp=T0 + 3.0, interval=3.0, verdict=verdict) def test_first_tick_credits_nothing(self) -> None: + """The first tick ever credits nothing.""" state = _state(last_tick_at=0.0) assert self._bill(state, ShadowVerdict(None, ("a",))) == state def test_a_backward_clock_credits_nothing(self) -> None: + """A clock step backwards credits nothing.""" state = _state(last_tick_at=T0 + 10.0) assert self._bill(state, ShadowVerdict(None, ("a",))) == state def test_paused_goes_to_paused_seconds_only(self) -> None: + """A paused tick only adds to paused seconds.""" out = self._bill(_state(), ShadowVerdict("i3lock", ())) assert out.shadow_paused_seconds == 3.0 assert out.shadow_seconds == 0.0 assert out.shadow_per_source == {} def test_free_bills_nothing(self) -> None: + """A free tick leaves the state unchanged.""" state = _state() assert self._bill(state, ShadowVerdict(None, ())) == state def test_one_source_takes_the_whole_tick(self) -> None: + """A single source is billed the whole tick.""" out = self._bill(_state(), ShadowVerdict(None, ("app:440",))) assert out.shadow_seconds == 3.0 assert out.shadow_per_source == {"app:440": 3.0} def test_overlapping_sources_bill_once_and_split(self) -> None: + """Overlapping sources share one tick, never billing it twice.""" state = _state(shadow_per_source={"a": 1.0}, shadow_seconds=1.0) out = self._bill(state, ShadowVerdict(None, ("a", "b"))) assert out.shadow_seconds == 4.0 @@ -138,6 +166,7 @@ class TestShadowAccumulate: assert sum(out.shadow_per_source.values()) <= out.shadow_seconds + 1e-9 def test_the_delta_is_clamped_like_the_enforced_one(self) -> None: + """A long gap is clamped exactly like the enforced budget.""" out = shadow_accumulate( _state(), stamp=T0 + 3600.0, @@ -147,6 +176,7 @@ class TestShadowAccumulate: assert out.shadow_seconds == 6.0 def test_never_touches_enforced_fields(self) -> None: + """Shadow billing leaves every enforced field as it was.""" state = _state(per_game={"app:1": 5.0}, last_credited_key="app:1") out = self._bill(state, ShadowVerdict(None, ("app:440",))) assert (out.seconds, out.last_tick_at, out.per_game) == ( diff --git a/steam_backlog_enforcer/tests/test_shadow_budget.py b/steam_backlog_enforcer/tests/test_shadow_budget.py index 1900c2d..32a48ff 100644 --- a/steam_backlog_enforcer/tests/test_shadow_budget.py +++ b/steam_backlog_enforcer/tests/test_shadow_budget.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime, timedelta, timezone import logging from typing import TYPE_CHECKING @@ -23,23 +24,21 @@ from steam_backlog_enforcer._playtime_state import ( ) from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.tests._budget_dates import AFTER_CUT, pin_today -from steam_backlog_enforcer.tests._fake_session import fake_session +from steam_backlog_enforcer.tests._budget_earners import HOUR, resolve_with +from steam_backlog_enforcer.tests._fake_session import StubShadow, fake_session 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" -_READING = "steam_backlog_enforcer._budget_resolve.read_today" -_HOUR = 3600.0 +_PKG = "steam_backlog_enforcer._playtime" _LOCAL = timezone(timedelta(hours=2)) _NOW = datetime(2026, 7, 27, 20, 0, tzinfo=_LOCAL) _TODAY = "2026-07-27" -_PKG = "steam_backlog_enforcer._playtime" @pytest.fixture(autouse=True) def _fresh_caches() -> Iterator[None]: + """Start each test with empty earner caches and a pinned date.""" _workout_budget.reset_cache() _leetcode_bonus.reset_cache() with pin_today(AFTER_CUT): @@ -49,23 +48,22 @@ def _fresh_caches() -> Iterator[None]: def _resolve(*, done: bool) -> object: - with ( - patch(_WORKOUT, return_value=done), - patch(_LEETCODE, return_value=done), - patch(_READING, return_value=done), - ): - return resolve_budget(Config()) + return resolve_with(workout=done, leetcode=done, reading=done) class TestShadowResolution: + """The shadow leisure budget resolves alongside the enforced one.""" + def test_a_fully_done_day_prices_ten_hours_and_enforces_eight(self) -> None: + """A fully earned day prices ten shadow hours against eight enforced.""" resolved = _resolve(done=True) - assert resolved.shadow_seconds == 10 * _HOUR - assert resolved.seconds == 8 * _HOUR + assert resolved.shadow_seconds == 10 * HOUR + assert resolved.seconds == 8 * HOUR def test_the_leisure_base_is_two_hours_above_the_enforced_one(self) -> None: + """The shadow base sits two hours above the enforced base.""" resolved = _resolve(done=False) - assert resolved.shadow_seconds == resolved.seconds + 2 * _HOUR + assert resolved.shadow_seconds == resolved.seconds + 2 * HOUR def test_a_missing_answer_warns_once_not_once_per_pricing( self, caplog: pytest.LogCaptureFixture @@ -82,28 +80,36 @@ class TestShadowResolution: class TestHeldShadow: + """The shadow budget holds its same-day high-water mark.""" + def test_same_day_holds_the_high_water(self) -> None: - stored = PlaytimeState(day_key=_TODAY, shadow_budget_seconds=9 * _HOUR) - assert held_shadow_today(stored, _NOW) == 9 * _HOUR + """A stored budget from today is held.""" + stored = PlaytimeState(day_key=_TODAY, shadow_budget_seconds=9 * HOUR) + assert held_shadow_today(stored, _NOW) == 9 * HOUR def test_another_day_or_no_state_holds_nothing(self) -> None: - stored = PlaytimeState(day_key="2026-07-26", shadow_budget_seconds=_HOUR) + """A budget from another day, or none at all, holds nothing.""" + stored = PlaytimeState(day_key="2026-07-26", shadow_budget_seconds=HOUR) assert held_shadow_today(stored, _NOW) == 0.0 assert held_shadow_today(None, _NOW) == 0.0 def test_rules_take_the_higher_of_live_and_held(self) -> None: + """Rules use the higher of the live and held shadow budgets.""" held = "steam_backlog_enforcer._playtime_state.held_shadow_today" - with patch(held, return_value=20 * _HOUR): - assert rules_for(Config(), demo=False).shadow_budget_seconds == 20 * (_HOUR) + with patch(held, return_value=20 * HOUR): + assert rules_for(Config(), demo=False).shadow_budget_seconds == 20 * (HOUR) with patch(held, return_value=0.0): live = rules_for(Config(), demo=False).shadow_budget_seconds - assert 0.0 < live < 20 * _HOUR + assert 0.0 < live < 20 * HOUR def test_demo_has_no_shadow_budget(self) -> None: + """Demo runs have no shadow budget.""" assert rules_for(Config(), demo=True).shadow_budget_seconds == 0.0 class TestShadowTick: + """The playtime tick bills shadow seconds next to enforced ones.""" + def _tick(self, owners: dict[int, str], *, total_block: bool = False) -> None: save_state( PlaytimeState(day_key=_TODAY, last_tick_at=_NOW.timestamp() - 3.0), @@ -124,6 +130,7 @@ class TestShadowTick: playtime_tick(Config(), interval=3.0, session=fake_session()) def test_bills_shadow_beside_the_enforced_count(self) -> None: + """A game tick bills both the enforced count and the shadow count.""" self._tick({7: "app:440"}) state = load_state(demo=False) assert state is not None @@ -133,6 +140,7 @@ class TestShadowTick: assert state.shadow_budget_seconds > 0.0 def test_no_game_and_no_watcher_fails_closed(self) -> None: + """With no game and no watcher the shadow bills the fail-closed key.""" self._tick({}) state = load_state(demo=False) assert state is not None @@ -140,6 +148,7 @@ class TestShadowTick: assert state.shadow_per_source == {_shadow.FAILCLOSED_KEY: 3.0} def test_no_shadow_under_a_total_block(self) -> None: + """A total block bills no shadow seconds but keeps the budget.""" self._tick({}, total_block=True) state = load_state(demo=False) assert state is not None @@ -147,7 +156,9 @@ class TestShadowTick: assert state.shadow_budget_seconds > 0.0 def test_the_session_journals_the_shadow_verdict(self) -> None: - session = fake_session() + """The tick journals the shadow verdict through the session.""" + shadow = StubShadow() + session = replace(fake_session(), shadow=shadow) save_state( PlaytimeState(day_key=_TODAY, last_tick_at=_NOW.timestamp() - 3.0), demo=False, @@ -161,11 +172,14 @@ class TestShadowTick: ): mock_dt.now.return_value = _NOW playtime_tick(Config(), interval=3.0, session=session) - assert [v.state for v in session.shadow.observed] == ["leisure"] + assert [v.state for v in shadow.observed] == ["leisure"] class TestShadowViews: + """Budget views expose the shadow block.""" + def test_mcp_reports_the_shadow_block(self) -> None: + """The MCP gaming-time view reports the full shadow block.""" stored = PlaytimeState( day_key=_TODAY, seconds=10.0, @@ -189,6 +203,7 @@ class TestShadowViews: } def test_would_block_at_zero(self) -> None: + """A spent shadow budget reports would_block with zero remaining.""" stored = PlaytimeState(shadow_seconds=100.0, shadow_budget_seconds=100.0) shadow = budget_view.build_today(stored, rules_for(Config(), demo=True)) assert shadow["shadow"]["would_block"] is True diff --git a/steam_backlog_enforcer/tests/test_shadow_guard.py b/steam_backlog_enforcer/tests/test_shadow_guard.py index 256cb21..e8989d3 100644 --- a/steam_backlog_enforcer/tests/test_shadow_guard.py +++ b/steam_backlog_enforcer/tests/test_shadow_guard.py @@ -22,7 +22,12 @@ from steam_backlog_enforcer._playtime_state import ( load_state, save_state, ) -from steam_backlog_enforcer._shadow import FAILCLOSED_KEY, ShadowVerdict, observe_shadow +from steam_backlog_enforcer._shadow import ( + FAILCLOSED_KEY, + ShadowVerdict, + _last_warned, + observe_shadow, +) from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.tests._fake_session import fake_session @@ -58,6 +63,7 @@ def quiet_tick() -> Iterator[None]: @pytest.mark.parametrize("where", ["paused_by", "read_now"]) @pytest.mark.parametrize("exc", [RecursionError("deep"), RuntimeError("boom")]) def test_any_exception_fails_closed(where: str, exc: Exception) -> None: + """Any shadow error fails closed: games keep their key, idle is fail-closed.""" with patch.object(_shadow, where, side_effect=exc): game = observe_shadow({1: "app:440"}, stamp=T0) idle = observe_shadow({}, stamp=T0) @@ -81,6 +87,7 @@ def _warnings(caplog: pytest.LogCaptureFixture) -> list[str]: def test_logs_once_per_distinct_error_per_window( caplog: pytest.LogCaptureFixture, ) -> None: + """Each distinct error is logged once per window.""" errors = [RecursionError("deep")] * 3 + [RuntimeError("other")] with ( caplog.at_level(logging.WARNING, logger=_shadow.__name__), @@ -96,6 +103,7 @@ def test_logs_once_per_distinct_error_per_window( def test_logs_again_after_the_window(caplog: pytest.LogCaptureFixture) -> None: + """The same error is logged again once the window has passed.""" window = _shadow.WARN_EVERY_SECONDS clock = [100.0, 100.0 + window - 1, 100.0 + window + 1] with ( @@ -117,6 +125,7 @@ def test_logs_again_after_the_window(caplog: pytest.LogCaptureFixture) -> None: def test_tick_completes_and_enforced_count_is_untouched( where: str, owners: dict[int, str], seconds: float, shadow: str ) -> None: + """A failing shadow never stops the tick or changes the enforced count.""" save_state(PlaytimeState(day_key="2026-07-27", last_tick_at=T0 - 3.0), demo=False) with ( patch.object(_shadow, where, side_effect=RecursionError("deep")), @@ -142,4 +151,4 @@ def test_errors_older_than_the_window_are_forgotten() -> None: ): for _ in errors: observe_shadow({}, stamp=T0) - assert list(_shadow._last_warned) == ["ValueError: char 2"] + assert list(_last_warned) == ["ValueError: char 2"] diff --git a/steam_backlog_enforcer/tests/test_shadow_log.py b/steam_backlog_enforcer/tests/test_shadow_log.py index b095485..bed26fc 100644 --- a/steam_backlog_enforcer/tests/test_shadow_log.py +++ b/steam_backlog_enforcer/tests/test_shadow_log.py @@ -21,16 +21,21 @@ _STATE = PlaytimeState( def _records() -> list[dict[str, object]]: + """Return every record in the budget log, oldest first.""" text = log_mod.BUDGET_LOG_FILE.read_text(encoding="utf-8") return [json.loads(line) for line in text.splitlines()] def _journal() -> ShadowJournal: + """Return a shadow journal writing to the production budget log.""" return ShadowJournal(BudgetLog(path=log_mod.BUDGET_LOG_FILE)) class TestShadowJournal: + """The shadow journal writes one record per verdict change.""" + def test_writes_on_change_only(self) -> None: + """A repeated verdict is not written again.""" journal = _journal() leisure = ShadowVerdict(paused_by=None, keys=("app:440",)) journal.observe(leisure, _STATE) @@ -41,12 +46,14 @@ class TestShadowJournal: assert {r["event"] for r in records} == {EVENT_SHADOW_CHANGE} def test_a_new_source_is_a_change(self) -> None: + """A different source set counts as a change.""" journal = _journal() journal.observe(ShadowVerdict(None, ("a",)), _STATE) journal.observe(ShadowVerdict(None, ("b",)), _STATE) assert [r["sources"] for r in _records()] == [["a"], ["b"]] def test_carries_the_shadow_fields(self) -> None: + """Each record carries the shadow budget fields.""" _journal().observe(ShadowVerdict(None, ("app:440",)), _STATE) record = _records()[0] assert record["day_key"] == "2026-10-19" diff --git a/steam_backlog_enforcer/tests/test_stats.py b/steam_backlog_enforcer/tests/test_stats.py index 78f9084..0e5d9c2 100644 --- a/steam_backlog_enforcer/tests/test_stats.py +++ b/steam_backlog_enforcer/tests/test_stats.py @@ -2,77 +2,29 @@ from __future__ import annotations -from unittest.mock import patch - from steam_backlog_enforcer._stats import ( _filter_qualifying_games, - _GameTimes, ) from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._stats_games import ( + FilterInputs, + filter_env, + make_game, + run_filter, +) _PKG = "steam_backlog_enforcer._stats_gathering" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestFilterQualifyingGames: """Tests for _filter_qualifying_games.""" - def _run( - self, - games: list[GameInfo], - state: State, - rush_cache: dict[int, float] | None = None, - leisure_cache: dict[int, float] | None = None, - game_id_cache: dict[int, int] | None = None, - ) -> tuple[list[_GameTimes], int, int, int]: - """Test run.""" - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}), - patch( - f"{_PKG}.load_hltb_leisure_100h_cache", - return_value=leisure_cache or {}, - ), - patch( - f"{_PKG}.load_hltb_game_id_cache", - return_value=game_id_cache or {}, - ), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value={}), - ): - return _filter_qualifying_games(games, state) - def test_current_app_id_excluded(self) -> None: """Test current app id excluded.""" state = State(current_app_id=1) - g1 = _game(app_id=1) - g2 = _game(app_id=2) - qualified, _, _, _ = self._run([g1, g2], state) + g1 = make_game(app_id=1) + g2 = make_game(app_id=2) + qualified, _, _, _ = run_filter([g1, g2], state) ids = [e.game.app_id for e in qualified] assert 1 not in ids assert 2 in ids @@ -80,41 +32,33 @@ class TestFilterQualifyingGames: def test_no_current_app_id_branch(self) -> None: """current_app_id is None — the exclude.add branch is not taken.""" state = State(current_app_id=None) - g = _game(app_id=3) - qualified, _, _, _ = self._run([g], state) + g = make_game(app_id=3) + qualified, _, _, _ = run_filter([g], state) assert len(qualified) == 1 def test_finished_app_ids_excluded(self) -> None: """Test finished app ids excluded.""" state = State() state.finished_app_ids = [1] - g1 = _game(app_id=1) - g2 = _game(app_id=2) - qualified, _, _, _ = self._run([g1, g2], state) + g1 = make_game(app_id=1) + g2 = make_game(app_id=2) + qualified, _, _, _ = run_filter([g1, g2], state) assert all(e.game.app_id != 1 for e in qualified) def test_complete_games_excluded(self) -> None: """Games where is_complete is True are excluded from candidates.""" state = State() - complete = _game(app_id=1, total=5, unlocked=5) - incomplete = _game(app_id=2, total=5, unlocked=0) - qualified, _, _, _ = self._run([complete, incomplete], state) + complete = make_game(app_id=1, total=5, unlocked=5) + incomplete = make_game(app_id=2, total=5, unlocked=0) + qualified, _, _, _ = run_filter([complete, incomplete], state) assert len(qualified) == 1 assert qualified[0].game.app_id == 2 def test_low_confidence_counts_hltb_skipped(self) -> None: """Test low confidence counts hltb skipped.""" state = State() - g = _game(app_id=1) - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=["low"]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value={}), - ): + g = make_game(app_id=1) + with filter_env(FilterInputs(fail_reasons=["low"])): qualified, hltb_skip, _, _ = _filter_qualifying_games([g], state) assert hltb_skip == 1 assert len(qualified) == 0 @@ -122,15 +66,7 @@ class TestFilterQualifyingGames: def test_no_candidates_skips_protondb_call(self) -> None: """When confidence filters all out, fetch_protondb_ratings is not called.""" state = State() - g = _game(app_id=1) - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=["low"]), - patch(f"{_PKG}.fetch_protondb_ratings") as mock_proton, - ): + g = make_game(app_id=1) + with filter_env(FilterInputs(fail_reasons=["low"])) as mock_proton: _filter_qualifying_games([g], state) mock_proton.assert_not_called() diff --git a/steam_backlog_enforcer/tests/test_stats_cmd_stats.py b/steam_backlog_enforcer/tests/test_stats_cmd_stats.py index e426c8b..824513f 100644 --- a/steam_backlog_enforcer/tests/test_stats_cmd_stats.py +++ b/steam_backlog_enforcer/tests/test_stats_cmd_stats.py @@ -7,18 +7,21 @@ from unittest.mock import patch import pytest -from steam_backlog_enforcer._stats import ( - _GameTimes, - cmd_stats, +from steam_backlog_enforcer._stats import cmd_stats +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.tests._scanning_games import echo_into +from steam_backlog_enforcer.tests._stats_cmd_env import ( + STATS, + cmd_stats_env, + snapshot_game, + stats_config, + times_entry, ) -from steam_backlog_enforcer.config import Config, State -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo if TYPE_CHECKING: from collections.abc import Iterator -_PKG = "steam_backlog_enforcer._stats" +_PKG = STATS @pytest.fixture(autouse=True) @@ -29,49 +32,9 @@ def _no_live_refresh() -> Iterator[None]: yield -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestCmdStats: """Tests for cmd_stats.""" - def _config(self) -> Config: - """Test config.""" - return Config(steam_api_key="k", steam_id="i") - - def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]: - """Test snapshot game.""" - 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 _run_cmd_stats( self, state: State, @@ -79,28 +42,13 @@ class TestCmdStats: linux_skip: int = 0, no_data_skip: int = 0, ) -> list[str]: - """Test run cmd stats.""" - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) + """Run cmd_stats over one qualifying game and return what it echoed.""" + snapshot = [snapshot_game()] + entry = times_entry(snapshot[0]) echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], hltb_skip, linux_skip, no_data_skip), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) + filtered = ([entry], hltb_skip, linux_skip, no_data_skip) + with cmd_stats_env(snapshot, filtered, echoed): + cmd_stats(stats_config(), state) return echoed def test_no_snapshot(self) -> None: @@ -112,10 +60,10 @@ class TestCmdStats: # The "run scan" hint is printed by the shared snapshot helper. patch( "steam_backlog_enforcer._snapshot._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), + side_effect=echo_into(echoed), ), ): - cmd_stats(self._config(), state) + cmd_stats(stats_config(), state) assert any("No snapshot found" in s for s in echoed) def test_with_no_current_game(self) -> None: @@ -163,30 +111,14 @@ class TestCmdStats: # finished_app_ids has 1 entry, but snapshot has 2 complete games — count = 2. state.finished_app_ids = [99] snapshot_complete = { - **self._snapshot_game(app_id=2), + **snapshot_game(app_id=2), "unlocked_achievements": 10, } - snapshot = [self._snapshot_game(app_id=1), snapshot_complete] - game = GameInfo.from_snapshot(self._snapshot_game()) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) + snapshot = [snapshot_game(app_id=1), snapshot_complete] + entry = times_entry(snapshot_game()) echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) + with cmd_stats_env(snapshot, ([entry], 0, 0, 0), echoed): + cmd_stats(stats_config(), state) assert any("Finished games" in s and "1" in s for s in echoed) def test_detail_data_complete_message_shown(self) -> None: @@ -199,52 +131,31 @@ class TestCmdStats: def test_note_missing_rush_shown_when_rush_absent(self) -> None: """'Note: X games still missing...' shown when rush_hours <= 0 after fetch.""" state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=-1.0, leisure_100h=-1.0 - ) + snapshot = [snapshot_game()] + entry = times_entry(snapshot[0], rush=-1.0, leisure=-1.0) echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._ensure_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), + with cmd_stats_env( + snapshot, + ([entry], 0, 0, 0), + echoed, + _ensure_rush_data={"return_value": False}, + _print_worst_example={}, ): - cmd_stats(self._config(), state) + cmd_stats(stats_config(), state) assert any("still missing" in s for s in echoed) def test_no_detail_message_when_no_qualifying_games(self) -> None: """Neither 'Note' nor 'Detail data' shown when qualified list is empty.""" state = State() - snapshot = [self._snapshot_game()] + snapshot = [snapshot_game()] echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._ensure_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), + with cmd_stats_env( + snapshot, + ([], 0, 0, 0), + echoed, + _ensure_rush_data={"return_value": False}, + _print_worst_example={}, ): - cmd_stats(self._config(), state) + cmd_stats(stats_config(), state) assert not any("Detail data" in s for s in echoed) assert not any("still missing" in s for s in echoed) diff --git a/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py b/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py index f447449..0e71d63 100644 --- a/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py +++ b/steam_backlog_enforcer/tests/test_stats_cmd_stats_refilter_called_when_ensure_rush_data_re.py @@ -3,102 +3,30 @@ from __future__ import annotations from datetime import UTC, datetime -from unittest.mock import patch +from typing import TYPE_CHECKING -from steam_backlog_enforcer._stats import ( - _GameTimes, - cmd_stats, -) +from steam_backlog_enforcer._stats import cmd_stats from steam_backlog_enforcer._web_dataset import PaceVsHLTB -from steam_backlog_enforcer.config import Config, State -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.config import State +from steam_backlog_enforcer.tests._stats_cmd_env import ( + cmd_stats_env, + snapshot_game, + stats_config, + times_entry, +) -_PKG = "steam_backlog_enforcer._stats" - - -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") +if TYPE_CHECKING: + from steam_backlog_enforcer._stats import _GameTimes class TestCmdStatsGroup2: """Tests for cmd_stats.""" - def _config(self) -> Config: - """Test config.""" - return Config(steam_api_key="k", steam_id="i") - - def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]: - """Test snapshot game.""" - 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 _run_cmd_stats( - self, - state: State, - hltb_skip: int = 0, - linux_skip: int = 0, - no_data_skip: int = 0, - ) -> list[str]: - """Test run cmd stats.""" - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - echoed: list[str] = [] - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], hltb_skip, linux_skip, no_data_skip), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) - return echoed - def test_refilter_called_when_ensure_rush_data_returns_true(self) -> None: """_filter_qualifying_games called twice when _ensure_rush_data returns True.""" state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) + snapshot = [snapshot_game()] + entry = times_entry(snapshot[0]) filter_calls: list[int] = [] def count_filter( @@ -108,20 +36,13 @@ class TestCmdStatsGroup2: filter_calls.append(1) return [entry], 0, 0, 0 - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch(f"{_PKG}._filter_qualifying_games", side_effect=count_filter), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._ensure_rush_data", return_value=True), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), + with cmd_stats_env( + snapshot, + count_filter, + _ensure_rush_data={"return_value": True}, + _print_worst_example={}, ): - cmd_stats(self._config(), state) + cmd_stats(stats_config(), state) assert len(filter_calls) == 2 def test_games_done_since_start_passed_to_pace(self) -> None: @@ -144,53 +65,38 @@ class TestCmdStatsGroup2: # app_id=2: completed AFTER enforcement start → should count snapshot_after = { - **self._snapshot_game(app_id=2), + **snapshot_game(app_id=2), "unlocked_achievements": 10, "achievements": [_ach(after_ts)] * 10, } # app_id=3: completed BEFORE enforcement start → should NOT count snapshot_before = { - **self._snapshot_game(app_id=3), + **snapshot_game(app_id=3), "unlocked_achievements": 10, "achievements": [_ach(before_ts)] * 10, } - snapshot = [self._snapshot_game(app_id=1), snapshot_after, snapshot_before] - game = GameInfo.from_snapshot(self._snapshot_game()) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) + snapshot = [snapshot_game(app_id=1), snapshot_after, snapshot_before] + entry = times_entry(snapshot_game()) captured: dict[str, int] = {} def capture_pace(_state: object, _remaining: object, games_done: int) -> None: """Test capture pace.""" captured["games_done"] = games_done - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}._print_player_speed_scenario"), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario", side_effect=capture_pace), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), + with cmd_stats_env( + snapshot, + ([entry], 0, 0, 0), + _print_pace_scenario={"side_effect": capture_pace}, + _print_worst_example={}, ): - cmd_stats(self._config(), state) + cmd_stats(stats_config(), state) assert captured["games_done"] == 1 # only the post-start game def test_player_speed_scenario_called_with_pace_and_totals(self) -> None: """_print_player_speed_scenario receives pace, rush_total, and leisure_total.""" state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) + snapshot = [snapshot_game()] + entry = times_entry(snapshot[0]) pace = PaceVsHLTB( calibration_count=5, ratio_vs_rush=1.1, @@ -206,26 +112,14 @@ class TestCmdStatsGroup2: captured["rush"] = rush captured["leisure"] = leisure - with ( - patch( - "steam_backlog_enforcer._snapshot.load_snapshot", return_value=snapshot - ), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_completed_rush_data", return_value=False), - patch(f"{_PKG}.compute_pace_vs_hltb", return_value=pace), - patch( - f"{_PKG}._print_player_speed_scenario", - side_effect=capture_player_speed, - ), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), + with cmd_stats_env( + snapshot, + ([entry], 0, 0, 0), + compute_pace_vs_hltb={"return_value": pace}, + _print_player_speed_scenario={"side_effect": capture_player_speed}, + _print_worst_example={}, ): - cmd_stats(self._config(), state) + cmd_stats(stats_config(), state) assert captured["pace"] is pace assert captured["rush"] == 15.0 assert captured["leisure"] == 25.0 diff --git a/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py b/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py index 2b119fe..01a2707 100644 --- a/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py +++ b/steam_backlog_enforcer/tests/test_stats_ensure_rush_data.py @@ -9,42 +9,19 @@ from steam_backlog_enforcer._stats import ( _ensure_rush_data, _GameTimes, ) -from steam_backlog_enforcer.protondb import ProtonDBRating from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._stats_games import make_game _PKG = "steam_backlog_enforcer._stats_gathering" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestEnsureRushData: """Tests for _ensure_rush_data.""" def _entry(self, rush: float) -> _GameTimes: """Test entry.""" return _GameTimes( - game=_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0 + game=make_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0 ) def test_empty_qualified_returns_false(self) -> None: @@ -92,7 +69,7 @@ class TestEnsureCompletedRushData: def test_no_complete_games_returns_false_without_fetch(self) -> None: """Test no complete games returns false without fetch.""" - incomplete = _game(app_id=1, total=10, unlocked=0) + incomplete = make_game(app_id=1, total=10, unlocked=0) with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: result = _ensure_completed_rush_data([incomplete]) assert result is False diff --git a/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py b/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py index a999dc7..c745b0a 100644 --- a/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py +++ b/steam_backlog_enforcer/tests/test_stats_print_player_speed_scenario.py @@ -8,35 +8,10 @@ from steam_backlog_enforcer._stats import ( _print_player_speed_scenario, ) from steam_backlog_enforcer._web_dataset import PaceVsHLTB -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo _PKG = "steam_backlog_enforcer._stats" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestPrintPlayerSpeedScenario: """Tests for _print_player_speed_scenario — 100 % branch coverage.""" diff --git a/steam_backlog_enforcer/tests/test_stats_print_worst_example.py b/steam_backlog_enforcer/tests/test_stats_print_worst_example.py index 1c2b40e..3524ae4 100644 --- a/steam_backlog_enforcer/tests/test_stats_print_worst_example.py +++ b/steam_backlog_enforcer/tests/test_stats_print_worst_example.py @@ -8,35 +8,11 @@ from steam_backlog_enforcer._stats import ( _GameTimes, _print_worst_example, ) -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._stats_games import make_game _PKG = "steam_backlog_enforcer._stats_display" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestPrintWorstExample: """Tests for _print_worst_example.""" @@ -50,7 +26,7 @@ class TestPrintWorstExample: def test_example_with_rush_and_leisure(self) -> None: """Test example with rush and leisure.""" entry = _GameTimes( - game=_game(name="Portal"), + game=make_game(name="Portal"), worst_hours=15.0, rush_hours=5.0, leisure_100h=20.0, @@ -66,7 +42,7 @@ class TestPrintWorstExample: def test_example_without_rush(self) -> None: """Test example without rush.""" entry = _GameTimes( - game=_game(name="X"), + game=make_game(name="X"), worst_hours=15.0, rush_hours=-1.0, leisure_100h=20.0, @@ -81,7 +57,7 @@ class TestPrintWorstExample: def test_example_without_leisure(self) -> None: """Test example without leisure.""" entry = _GameTimes( - game=_game(name="Y"), + game=make_game(name="Y"), worst_hours=15.0, rush_hours=5.0, leisure_100h=-1.0, @@ -96,7 +72,7 @@ class TestPrintWorstExample: def test_hltb_search_url_shown_when_lookup_finds_nothing(self) -> None: """Falls back to search URL when hltb_game_id is 0 and lookup finds nothing.""" entry = _GameTimes( - game=_game(name="Portal 2"), + game=make_game(name="Portal 2"), worst_hours=15.0, rush_hours=-1.0, leisure_100h=-1.0, @@ -113,7 +89,7 @@ class TestPrintWorstExample: def test_hltb_direct_link_shown_after_on_demand_lookup(self) -> None: """Direct link shown when on-demand lookup successfully finds the game ID.""" entry = _GameTimes( - game=_game(app_id=111, name="Portal 2"), + game=make_game(app_id=111, name="Portal 2"), worst_hours=15.0, rush_hours=-1.0, leisure_100h=-1.0, @@ -131,7 +107,7 @@ class TestPrintWorstExample: def test_hltb_direct_link_shown_when_game_id_known(self) -> None: """Direct HLTB game link shown when hltb_game_id is populated.""" entry = _GameTimes( - game=_game(name="Devil May Cry 5"), + game=make_game(name="Devil May Cry 5"), worst_hours=186.0, rush_hours=50.0, leisure_100h=186.0, @@ -146,10 +122,13 @@ class TestPrintWorstExample: def test_entries_with_zero_worst_hours_excluded_from_examples(self) -> None: """Games with worst_hours <= 0 are not selected as the example.""" bad = _GameTimes( - game=_game(name="Skip"), worst_hours=0.0, rush_hours=-1.0, leisure_100h=-1.0 + game=make_game(name="Skip"), + worst_hours=0.0, + rush_hours=-1.0, + leisure_100h=-1.0, ) good = _GameTimes( - game=_game(name="Pick"), + game=make_game(name="Pick"), worst_hours=10.0, rush_hours=-1.0, leisure_100h=-1.0, diff --git a/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py b/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py index 1e9e355..741bedc 100644 --- a/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py +++ b/steam_backlog_enforcer/tests/test_stats_refresh_recently_played_completions.py @@ -4,36 +4,13 @@ from __future__ import annotations from unittest.mock import patch +from steam_backlog_enforcer._stats import _refresh_recently_played_completions from steam_backlog_enforcer.config import Config -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError +from steam_backlog_enforcer.steam_api import AchievementInfo, GameInfo, SteamAPIError _PKG = "steam_backlog_enforcer._stats_gathering" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestRefreshRecentlyPlayedCompletions: """Tests for _refresh_recently_played_completions.""" @@ -42,10 +19,6 @@ class TestRefreshRecentlyPlayedCompletions: games = [GameInfo(1, "G", 10, 0, 60)] with patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf: mock_sf.stat.side_effect = OSError("no file") - from steam_backlog_enforcer._stats import ( - _refresh_recently_played_completions, - ) - result = _refresh_recently_played_completions(games, Config()) assert result == games @@ -60,10 +33,6 @@ class TestRefreshRecentlyPlayedCompletions: mock_cls.return_value.get_owned_games.side_effect = SteamAPIError( "api down" ) - from steam_backlog_enforcer._stats import ( - _refresh_recently_played_completions, - ) - result = _refresh_recently_played_completions(games, Config()) assert result == games @@ -78,18 +47,11 @@ class TestRefreshRecentlyPlayedCompletions: mock_cls.return_value.get_owned_games.return_value = [ {"appid": 1, "rtime_last_played": 500_000} ] - from steam_backlog_enforcer._stats import ( - _refresh_recently_played_completions, - ) - result = _refresh_recently_played_completions(games, Config()) assert result == games def test_recently_played_game_is_refreshed(self) -> None: """Test recently played game is refreshed.""" - from steam_backlog_enforcer._stats import _refresh_recently_played_completions - from steam_backlog_enforcer.steam_api import AchievementInfo - game = GameInfo(1, "G", 5, 0, 60) new_achievements = [ AchievementInfo("a1", "A1", achieved=True, unlock_time=1_500_001), @@ -116,8 +78,6 @@ class TestRefreshRecentlyPlayedCompletions: def test_get_achievement_details_empty_keeps_old_game(self) -> None: """Test get achievement details empty keeps old game.""" - from steam_backlog_enforcer._stats import _refresh_recently_played_completions - game = GameInfo(1, "G", 5, 3, 60) with ( patch(f"{_PKG}.SNAPSHOT_FILE") as mock_sf, diff --git a/steam_backlog_enforcer/tests/test_stats_sum_hours.py b/steam_backlog_enforcer/tests/test_stats_sum_hours.py index 1a207a3..648914d 100644 --- a/steam_backlog_enforcer/tests/test_stats_sum_hours.py +++ b/steam_backlog_enforcer/tests/test_stats_sum_hours.py @@ -13,44 +13,20 @@ from steam_backlog_enforcer._stats import ( _sum_hours, ) from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._stats_games import make_game _PKG = "steam_backlog_enforcer._stats_display" # _print_scenario was not moved: it still lives in _stats. _PKG_STATS = "steam_backlog_enforcer._stats" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestSumHours: """Tests for _sum_hours.""" def _make_entry(self, worst: float, rush: float, leisure: float) -> _GameTimes: """Test make entry.""" return _GameTimes( - game=_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure + game=make_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure ) def test_empty_list(self) -> None: diff --git a/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py b/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py index 9326254..2b95b12 100644 --- a/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py +++ b/steam_backlog_enforcer/tests/test_stats_unplayable_rating_counts_linux_skipped.py @@ -2,85 +2,30 @@ from __future__ import annotations -from unittest.mock import patch - from steam_backlog_enforcer._stats import ( _filter_qualifying_games, - _GameTimes, ) from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.protondb import ProtonDBRating -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._stats_games import ( + FilterInputs, + filter_env, + make_game, + run_filter, + unplayable_rating, +) _PKG = "steam_backlog_enforcer._stats_gathering" -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - class TestFilterQualifyingGamesGroup2: """Tests for _filter_qualifying_games.""" - def _run( - self, - games: list[GameInfo], - state: State, - rush_cache: dict[int, float] | None = None, - leisure_cache: dict[int, float] | None = None, - game_id_cache: dict[int, int] | None = None, - ) -> tuple[list[_GameTimes], int, int, int]: - """Test run.""" - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}), - patch( - f"{_PKG}.load_hltb_leisure_100h_cache", - return_value=leisure_cache or {}, - ), - patch( - f"{_PKG}.load_hltb_game_id_cache", - return_value=game_id_cache or {}, - ), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value={}), - ): - return _filter_qualifying_games(games, state) - def test_unplayable_rating_counts_linux_skipped(self) -> None: """Test unplayable rating counts linux skipped.""" state = State() - g = _game(app_id=1) - ratings = {1: _unplayable_rating(1)} - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value=ratings), - ): + g = make_game(app_id=1) + ratings = {1: unplayable_rating(1)} + with filter_env(FilterInputs(ratings=ratings)): qualified, _, linux_skip, _ = _filter_qualifying_games([g], state) assert linux_skip == 1 assert len(qualified) == 0 @@ -88,49 +33,49 @@ class TestFilterQualifyingGamesGroup2: def test_no_data_counts_no_data_skipped(self) -> None: """Game with all -1 hours is counted as no_data_skipped.""" state = State() - g = _game(app_id=1, hours=-1.0) - qualified, _, _, no_data_skip = self._run([g], state) + g = make_game(app_id=1, hours=-1.0) + qualified, _, _, no_data_skip = run_filter([g], state) assert no_data_skip == 1 assert len(qualified) == 0 def test_worst_hours_positive_when_completionist_hours_positive(self) -> None: """Test worst hours positive when completionist hours positive.""" state = State() - g = _game(app_id=1, hours=25.0) - qualified, _, _, _ = self._run([g], state, rush_cache={1: 10.0}) + g = make_game(app_id=1, hours=25.0) + qualified, _, _, _ = run_filter([g], state, FilterInputs(rush_cache={1: 10.0})) assert qualified[0].worst_hours == 25.0 def test_worst_hours_from_leisure_when_completionist_zero(self) -> None: """worst_hours falls back to leisure_100h when completionist_hours is zero.""" state = State() - g = _game(app_id=1, hours=0.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 5.0}, leisure_cache={1: 6.0} + g = make_game(app_id=1, hours=0.0) + qualified, _, _, _ = run_filter( + [g], state, FilterInputs(rush_cache={1: 5.0}, leisure_cache={1: 6.0}) ) assert qualified[0].worst_hours == 6.0 def test_worst_hours_is_max_when_leisure_exceeds_completionist(self) -> None: """worst_hours is max(completionist, leisure_100h) when leisure is higher.""" state = State() - g = _game(app_id=1, hours=25.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 10.0}, leisure_cache={1: 40.0} + g = make_game(app_id=1, hours=25.0) + qualified, _, _, _ = run_filter( + [g], state, FilterInputs(rush_cache={1: 10.0}, leisure_cache={1: 40.0}) ) assert qualified[0].worst_hours == 40.0 def test_worst_hours_negative_when_all_zero(self) -> None: """worst_hours = -1 when both completionist_hours and leisure_100h are zero.""" state = State() - g = _game(app_id=1, hours=0.0) - qualified, _, _, _ = self._run([g], state, rush_cache={1: 5.0}) + g = make_game(app_id=1, hours=0.0) + qualified, _, _, _ = run_filter([g], state, FilterInputs(rush_cache={1: 5.0})) assert qualified[0].worst_hours == -1 def test_rush_and_leisure_from_cache(self) -> None: """Test rush and leisure from cache.""" state = State() - g = _game(app_id=1, hours=30.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 12.0}, leisure_cache={1: 40.0} + g = make_game(app_id=1, hours=30.0) + qualified, _, _, _ = run_filter( + [g], state, FilterInputs(rush_cache={1: 12.0}, leisure_cache={1: 40.0}) ) assert qualified[0].rush_hours == 12.0 assert qualified[0].leisure_100h == 40.0 @@ -138,29 +83,33 @@ class TestFilterQualifyingGamesGroup2: def test_missing_cache_entry_defaults_to_minus_one(self) -> None: """Test missing cache entry defaults to minus one.""" state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state) + g = make_game(app_id=1, hours=20.0) + qualified, _, _, _ = run_filter([g], state) assert qualified[0].rush_hours == -1 assert qualified[0].leisure_100h == -1 def test_only_rush_nonzero_qualifies(self) -> None: """Game qualifies if only rush_hours is positive (worst <= 0, leisure <= 0).""" state = State() - g = _game(app_id=1, hours=-1.0) - qualified, _, _, no_data_skip = self._run([g], state, rush_cache={1: 8.0}) + g = make_game(app_id=1, hours=-1.0) + qualified, _, _, no_data_skip = run_filter( + [g], state, FilterInputs(rush_cache={1: 8.0}) + ) assert no_data_skip == 0 assert len(qualified) == 1 def test_game_id_populated_from_cache(self) -> None: """hltb_game_id is taken from game_id_cache.""" state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state, game_id_cache={1: 57514}) + g = make_game(app_id=1, hours=20.0) + qualified, _, _, _ = run_filter( + [g], state, FilterInputs(game_id_cache={1: 57514}) + ) assert qualified[0].hltb_game_id == 57514 def test_game_id_defaults_to_zero_when_not_in_cache(self) -> None: """hltb_game_id defaults to 0 when not in cache.""" state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state) + g = make_game(app_id=1, hours=20.0) + qualified, _, _, _ = run_filter([g], state) assert qualified[0].hltb_game_id == 0 diff --git a/steam_backlog_enforcer/tests/test_status_api_stamp.py b/steam_backlog_enforcer/tests/test_status_api_stamp.py index e31aa2a..ba96e37 100644 --- a/steam_backlog_enforcer/tests/test_status_api_stamp.py +++ b/steam_backlog_enforcer/tests/test_status_api_stamp.py @@ -13,8 +13,8 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer import _status_api, _workout_budget -from steam_backlog_enforcer._status_api import AnswerCache, file_stamp +from steam_backlog_enforcer import _workout_budget +from steam_backlog_enforcer._status_api import AnswerCache, _stat_errors, file_stamp # Bound at import, before conftest swaps the module attribute for a tmp path. from steam_backlog_enforcer._workout_budget import workout_log_path @@ -35,33 +35,38 @@ class TestFileStamp: """Identity of a file's contents, or None.""" def test_missing_file_is_none(self, tmp_path: Path) -> None: + """An absent file has no stamp.""" assert file_stamp(tmp_path / "absent.json") is None def test_same_contents_same_stamp(self, tmp_path: Path) -> None: + """An untouched file keeps its stamp.""" path = tmp_path / "log.json" - path.write_text("{}") + path.write_text("{}", encoding="utf-8") assert file_stamp(path) == file_stamp(path) def test_atomic_replace_changes_the_stamp(self, tmp_path: Path) -> None: + """An atomic replace with identical bytes still changes the stamp.""" path = tmp_path / "log.json" - path.write_text("{}") + path.write_text("{}", encoding="utf-8") before = file_stamp(path) tmp = tmp_path / "log.json.tmp" - tmp.write_text("{}") + tmp.write_text("{}", encoding="utf-8") tmp.replace(path) # Same size and possibly the same mtime tick: the inode tells them apart. assert file_stamp(path) != before def test_in_place_growth_changes_the_stamp(self, tmp_path: Path) -> None: + """Rewriting the file in place changes the stamp.""" path = tmp_path / "log.json" - path.write_text("{}") + path.write_text("{}", encoding="utf-8") before = file_stamp(path) - path.write_text('{"a": 1}') + path.write_text('{"a": 1}', encoding="utf-8") assert file_stamp(path) != before def test_stat_error_warns_once_per_distinct_error( self, caplog: pytest.LogCaptureFixture ) -> None: + """A stat error warns once per distinct message.""" path = _unstatable(PermissionError("denied")) with caplog.at_level(logging.WARNING, logger=_API): assert file_stamp(path) is None @@ -71,60 +76,68 @@ class TestFileStamp: warnings = [r for r in caplog.records if "Cannot stat" in r.getMessage()] assert len(warnings) == 2 assert "60s TTL" in warnings[0].getMessage() - _status_api._stat_errors.pop(path, None) + _stat_errors.pop(path, None) def test_recovery_re_arms_the_warning( self, tmp_path: Path, caplog: pytest.LogCaptureFixture ) -> None: + """A successful stat re-arms the warning for the next error.""" path = tmp_path / "log.json" - path.write_text("{}") + path.write_text("{}", encoding="utf-8") with caplog.at_level(logging.WARNING, logger=_API): with patch.object(Path, "stat", side_effect=PermissionError("x")): assert file_stamp(path) is None assert file_stamp(path) is not None - assert path not in _status_api._stat_errors + assert path not in _stat_errors with patch.object(Path, "stat", side_effect=PermissionError("x")): assert file_stamp(path) is None assert caplog.text.count("Cannot stat") == 2 - _status_api._stat_errors.pop(path, None) + _stat_errors.pop(path, None) def test_vanishing_clears_a_recorded_error(self, tmp_path: Path) -> None: + """A file that disappears clears its recorded error.""" path = tmp_path / "log.json" - _status_api._stat_errors[path] = "old" + _stat_errors[path] = "old" assert file_stamp(path) is None - assert path not in _status_api._stat_errors + assert path not in _stat_errors class TestAnswerCacheStamp: """A stored stamp must match the caller's for the answer to be reused.""" def test_matching_stamp_hits(self) -> None: + """The same stamp reuses the cached answer.""" cache = AnswerCache() cache.store(_URL, answer=True, stamp=(1, 2, 3)) assert cache.fresh(_URL, stamp=(1, 2, 3)) is True def test_changed_stamp_misses(self) -> None: + """A different stamp drops the cached answer.""" cache = AnswerCache() cache.store(_URL, answer=False, stamp=(1, 2, 3)) assert cache.fresh(_URL, stamp=(1, 2, 4)) is None def test_file_appearing_misses(self) -> None: + """A file appearing after an unstamped store misses.""" cache = AnswerCache() cache.store(_URL, answer=False, stamp=None) assert cache.fresh(_URL, stamp=(1, 2, 3)) is None def test_unstamped_callers_still_hit(self) -> None: + """Callers that never pass a stamp still hit.""" cache = AnswerCache() cache.store(_URL, answer=True) assert cache.fresh(_URL) is True def test_entries_carry_the_stamp(self) -> None: + """A stored answer is tied to the stamp it was stored with.""" cache = AnswerCache() cache.store(_URL, answer=True, stamp=(9, 9, 9)) - _when, answer, stamp = cache._entries[_URL] - assert (answer, stamp) == (True, (9, 9, 9)) + assert cache.fresh(_URL, stamp=(9, 9, 9)) is True + assert cache.fresh(_URL) is None def test_ttl_still_applies_with_a_matching_stamp(self) -> None: + """A matching stamp does not outlive the TTL.""" cache = AnswerCache() with patch(f"{_API}.time.monotonic", return_value=0.0): cache.store(_URL, answer=True, stamp=(1, 2, 3)) @@ -136,11 +149,13 @@ class TestWorkoutLogPath: """Where screen-locker's log.json is read from.""" def test_expands_the_home_directory(self) -> None: + """A ~ in the configured path expands to the home directory.""" config = Config() config.workout_log_path = "~/locker/log.json" assert workout_log_path(config) == Path.home() / "locker" / "log.json" def test_default_points_at_screen_locker(self) -> None: + """The default path is screen-locker's log.json.""" expected = Path.home() / "src/screen-locker/screen_locker/log.json" assert workout_log_path(Config()) == expected @@ -150,14 +165,16 @@ class TestWorkoutAnswerFollowsTheLog: @pytest.fixture(autouse=True) def _clear_cache(self) -> None: + """Each test starts with an empty workout answer cache.""" _workout_budget.reset_cache() def test_credit_is_seen_on_the_next_call(self) -> None: + """Rewriting log.json makes the next call re-ask inside the TTL.""" config = Config() # conftest redirected this to a tmp path; that is the file we rewrite. log = _workout_budget.workout_log_path(config) log.parent.mkdir(parents=True) - log.write_text("[]") + log.write_text("[]", encoding="utf-8") with patch( f"{_workout_budget.__name__}._fetch_workout_today", side_effect=[False, True], @@ -166,7 +183,7 @@ class TestWorkoutAnswerFollowsTheLog: assert _workout_budget.workout_logged_today(config) is False assert fetch.call_count == 1 tmp = log.with_name("log.json.tmp") - tmp.write_text('[{"credited": true}]') + tmp.write_text('[{"credited": true}]', encoding="utf-8") tmp.replace(log) assert _workout_budget.workout_logged_today(config) is True assert fetch.call_count == 2 diff --git a/steam_backlog_enforcer/tests/test_status_kept_picks.py b/steam_backlog_enforcer/tests/test_status_kept_picks.py index 2c20c97..0ef079f 100644 --- a/steam_backlog_enforcer/tests/test_status_kept_picks.py +++ b/steam_backlog_enforcer/tests/test_status_kept_picks.py @@ -39,6 +39,7 @@ def _state() -> State: def test_payload_lists_kept_picks_apart_from_active_ones() -> None: + """The status payload lists kept picks apart from the active ones.""" with patch.multiple( "steam_backlog_enforcer._manual_pick_lifecycle", get_total_block_status=lambda: _NO_BLOCK, @@ -52,6 +53,7 @@ def test_payload_lists_kept_picks_apart_from_active_ones() -> None: def test_status_view_projects_kept_picks() -> None: + """The web status view projects kept picks to id and name.""" raw = {"assigned_game_installed": True, "manual_picks": [], "kept_picks": [_KEPT]} with patch(f"{_web_views.__name__}.status_payload", return_value=raw): body = payload_of(_web_views.status_view(make_request())) @@ -59,6 +61,7 @@ def test_status_view_projects_kept_picks() -> None: def test_cli_status_lists_kept_games() -> None: + """The CLI status lists kept games with the abandon command.""" with ( patch(f"{_STATUS}.is_store_blocked", return_value=False), patch(f"{_STATUS}.get_installed_games", return_value=[]), diff --git a/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py b/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py index 04eee10..75265fa 100644 --- a/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py +++ b/steam_backlog_enforcer/tests/test_steam_api_scan_extras.py @@ -17,7 +17,10 @@ _ACH = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) class TestStrictAchievementDetails: + """Strict achievement lookups raise real failures only.""" + def test_a_real_failure_raises_when_strict(self) -> None: + """A real API failure propagates when strict.""" client = SteamAPIClient("key", "id") with ( patch.object(client, "_get", side_effect=SteamAPIError("timeout")), @@ -26,6 +29,7 @@ class TestStrictAchievementDetails: client.get_achievement_details(440, strict=True) def test_a_no_stats_answer_is_empty_even_when_strict(self) -> None: + """A no-stats answer is an empty list even when strict.""" client = SteamAPIClient("key", "id") with ( patch.object(client, "_get", side_effect=SteamAPIError("400")), @@ -35,7 +39,10 @@ class TestStrictAchievementDetails: class TestBuildGameListSkips: + """build_game_list skips known bare games and records what Steam answered.""" + def test_known_no_achievement_games_are_not_asked_about(self) -> None: + """Games known to have no achievements are not queried.""" client = SteamAPIClient("key", "id") owned = [{"appid": 1, "name": "Bare"}, {"appid": 2, "name": "Rich"}] with ( @@ -50,6 +57,7 @@ class TestBuildGameListSkips: assert [c.args[0] for c in details.call_args_list] == [2] def test_only_answered_games_are_remembered(self) -> None: + """Only games Steam answered for are recorded as scanned.""" client = SteamAPIClient("key", "id") owned = [{"appid": 1}, {"appid": 2}, {"appid": 3}] @@ -74,7 +82,10 @@ class TestBuildGameListSkips: class TestCancelledScan: + """A cancelled scan stops at once.""" + def test_an_interrupt_propagates_and_drops_queued_requests(self) -> None: + """Ctrl-C from the progress callback propagates out of the scan.""" client = SteamAPIClient("key", "id") def cancel(_done: int, _total: int) -> None: diff --git a/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py b/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py index cfdd576..1cd79bd 100644 --- a/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py +++ b/steam_backlog_enforcer/tests/test_steam_api_steam_a_p_i_client.py @@ -13,6 +13,16 @@ from steam_backlog_enforcer.steam_api import ( SteamAPIError, ) +# Generous upper bound on requests needed to exhaust the per-second budget. +_BURST_LIMIT = 100 + + +def _owned_response() -> MagicMock: + """A GetOwnedGames HTTP response listing no games.""" + resp = MagicMock() + resp.json.return_value = {"response": {"games": []}} + return resp + class TestSteamAPIClient: """Tests for SteamAPIClient.""" @@ -24,51 +34,51 @@ class TestSteamAPIClient: assert client.steam_id == "id" def test_rate_limit(self) -> None: - """Test rate limit.""" + """The first request goes out without waiting on the rate limit.""" client = SteamAPIClient("key", "id") - # Should not block on first call - client._rate_limit() + client.session.get = MagicMock(return_value=_owned_response()) + with patch("steam_backlog_enforcer._steam_api_client.time.sleep") as sleep: + client.get_owned_games() + sleep.assert_not_called() def test_rate_limit_throttle(self) -> None: - """Test rate limit throttle.""" + """A burst past the per-second budget makes the client sleep.""" client = SteamAPIClient("key", "id") - # Fill up the rate limit window - client._request_times = [__import__("time").time()] * client._max_rps + client.session.get = MagicMock(return_value=_owned_response()) with patch( "steam_backlog_enforcer._steam_api_client.time.sleep", ) as mock_sleep: - # Next call should trigger sleep then succeed - client._rate_limit() + # Keep requesting until the window fills and the throttle kicks in. + for _ in range(_BURST_LIMIT): + client.get_owned_games() + if mock_sleep.called: + break mock_sleep.assert_called() def test_get_success(self) -> None: - """Test get success.""" + """The decoded JSON body of a GET is what the caller receives.""" client = SteamAPIClient("key", "id") mock_resp = MagicMock() - mock_resp.json.return_value = {"data": "value"} + mock_resp.json.return_value = {"response": {"games": [{"appid": 1}]}} client.session.get = MagicMock(return_value=mock_resp) - result = client._get("https://example.com/api") - assert result == {"data": "value"} + assert client.get_owned_games() == [{"appid": 1}] def test_get_with_params(self) -> None: - """Test get with params.""" + """The API key is added to the request's existing params.""" client = SteamAPIClient("key", "id") - mock_resp = MagicMock() - mock_resp.json.return_value = {"data": "value"} - client.session.get = MagicMock(return_value=mock_resp) - result = client._get("https://example.com/api", params={"foo": "bar"}) - assert result == {"data": "value"} + client.session.get = MagicMock(return_value=_owned_response()) + client.get_owned_games() # Verify key was added to existing params dict call_kwargs = client.session.get.call_args - assert call_kwargs[1]["params"]["foo"] == "bar" + assert call_kwargs[1]["params"]["steamid"] == "id" assert call_kwargs[1]["params"]["key"] == "key" def test_get_failure(self) -> None: - """Test get failure.""" + """A transport error surfaces as SteamAPIError.""" client = SteamAPIClient("key", "id") client.session.get = MagicMock(side_effect=requests.RequestException("fail")) with pytest.raises(SteamAPIError): - client._get("https://example.com/api") + client.get_owned_games() def test_get_owned_games(self) -> None: """Test get owned games.""" @@ -132,22 +142,30 @@ class TestSteamAPIClient: assert result == [] def test_fetch_one_game(self) -> None: - """Test fetch one game.""" + """A game with achievements is built from its owned-games entry.""" client = SteamAPIClient("key", "id") ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with patch.object(client, "get_achievement_details", return_value=[ach]): - result = client._fetch_one_game( - {"appid": 440, "name": "TF2", "playtime_forever": 60}, - ) - assert result is not None - assert result.app_id == 440 + with ( + patch.object( + client, + "get_owned_games", + return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}], + ), + patch.object(client, "get_achievement_details", return_value=[ach]), + ): + result = client.build_game_list() + assert len(result) == 1 + assert result[0].app_id == 440 def test_fetch_one_game_no_achievements(self) -> None: - """Test fetch one game no achievements.""" + """A nameless game without achievements yields no entry.""" client = SteamAPIClient("key", "id") - with patch.object(client, "get_achievement_details", return_value=[]): - result = client._fetch_one_game({"appid": 440}) - assert result is None + with ( + patch.object(client, "get_owned_games", return_value=[{"appid": 440}]), + patch.object(client, "get_achievement_details", return_value=[]), + ): + result = client.build_game_list() + assert result == [] def test_build_game_list(self) -> None: """Test build game list.""" diff --git a/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py b/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py index b533679..2e28a8b 100644 --- a/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py +++ b/steam_backlog_enforcer/tests/test_steam_launch_lifecycle.py @@ -19,6 +19,7 @@ class TestIsSteamRunning: """Tests for _is_steam_running.""" def test_running(self) -> None: + """pgrep exit 0 means Steam is running.""" mock_result = MagicMock(returncode=0) with patch( "steam_backlog_enforcer._steam_launch.subprocess.run", @@ -27,6 +28,7 @@ class TestIsSteamRunning: assert _is_steam_running() is True def test_not_running(self) -> None: + """pgrep exit 1 means Steam is not running.""" mock_result = MagicMock(returncode=1) with patch( "steam_backlog_enforcer._steam_launch.subprocess.run", @@ -39,6 +41,7 @@ class TestSteamHasDebugPort: """Tests for _steam_has_debug_port.""" def test_has_port(self) -> None: + """A shared-JS websocket URL means the CDP port is open.""" with patch( "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", return_value="ws://test", @@ -46,6 +49,7 @@ class TestSteamHasDebugPort: assert _steam_has_debug_port() is True def test_no_port(self) -> None: + """No websocket URL means no CDP port.""" with patch( "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", return_value=None, @@ -57,6 +61,7 @@ class TestWaitForCdpReady: """Tests for _wait_for_cdp_ready.""" def test_ready_immediately(self) -> None: + """An open CDP port is ready at once.""" with patch( "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", return_value="ws://test", @@ -64,6 +69,7 @@ class TestWaitForCdpReady: assert _wait_for_cdp_ready() is True def test_timeout(self) -> None: + """A CDP port that never opens times out as not ready.""" with ( patch( "steam_backlog_enforcer._steam_launch._get_shared_js_ws_url", @@ -84,6 +90,7 @@ class TestWaitForCollectionsReady: """Tests for _wait_for_collections_ready.""" def test_ready(self) -> None: + """Collections that report ok are ready.""" with ( patch( "steam_backlog_enforcer._steam_launch._evaluate_js", @@ -97,6 +104,7 @@ class TestWaitForCollectionsReady: assert _wait_for_collections_ready() is True def test_not_ready_then_ready(self) -> None: + """Collections are polled until they report ok.""" with ( patch( "steam_backlog_enforcer._steam_launch._evaluate_js", @@ -117,6 +125,7 @@ class TestWaitForCollectionsReady: assert _wait_for_collections_ready() is True def test_timeout(self) -> None: + """Evaluation that keeps failing times out as not ready.""" with ( patch( "steam_backlog_enforcer._steam_launch._evaluate_js", diff --git a/steam_backlog_enforcer/tests/test_steam_process_scope.py b/steam_backlog_enforcer/tests/test_steam_process_scope.py index f531c72..f0fa29a 100644 --- a/steam_backlog_enforcer/tests/test_steam_process_scope.py +++ b/steam_backlog_enforcer/tests/test_steam_process_scope.py @@ -29,12 +29,15 @@ class TestSpawnDetachedScope: """Root launches leave the cgroup; anything else is spawned as asked.""" def test_root_with_systemd_run_gets_its_own_scope(self) -> None: + """A root launch is wrapped in a transient systemd scope.""" argv = _argv(euid=0, which="/usr/bin/systemd-run") assert argv == [*SCOPE, "steam", "-silent"] def test_root_without_systemd_run_launches_plainly(self) -> None: + """Root without systemd-run launches the command unwrapped.""" assert _argv(euid=0, which=None) == ["steam", "-silent"] def test_unprivileged_launch_is_never_wrapped(self) -> None: + """A non-root launch is never wrapped in a scope.""" argv = _argv(euid=1000, which="/usr/bin/systemd-run") assert argv == ["steam", "-silent"] diff --git a/steam_backlog_enforcer/tests/test_steam_restart.py b/steam_backlog_enforcer/tests/test_steam_restart.py index 68a7179..893d7c4 100644 --- a/steam_backlog_enforcer/tests/test_steam_restart.py +++ b/steam_backlog_enforcer/tests/test_steam_restart.py @@ -17,6 +17,7 @@ class TestShutdownSteam: """Tests for _shutdown_steam.""" def test_exits_immediately(self) -> None: + """A Steam that is already gone needs no waiting.""" mock_result = MagicMock(returncode=1) # Not running with ( patch( @@ -30,6 +31,7 @@ class TestShutdownSteam: _shutdown_steam() def test_waits_for_exit(self) -> None: + """Shutdown polls until Steam has exited.""" results = [MagicMock(returncode=0), MagicMock(returncode=1)] with ( patch( @@ -46,6 +48,7 @@ class TestShutdownSteam: _shutdown_steam() def test_file_not_found(self) -> None: + """A missing steam binary does not raise.""" with patch( "steam_backlog_enforcer._steam_launch._run_as_user", side_effect=FileNotFoundError, @@ -53,6 +56,7 @@ class TestShutdownSteam: _shutdown_steam() # Should not raise def test_timeout(self) -> None: + """A Steam that never exits ends the wait without raising.""" mock_result = MagicMock(returncode=0) # Still running with ( patch( @@ -73,6 +77,7 @@ class TestLaunchSteamWithDebug: """Tests for _launch_steam_with_debug.""" def test_launches(self) -> None: + """Launching runs Steam once as the desktop user.""" with patch( "steam_backlog_enforcer._steam_launch._run_as_user", ) as mock_run: diff --git a/steam_backlog_enforcer/tests/test_steam_restart_guard.py b/steam_backlog_enforcer/tests/test_steam_restart_guard.py index 80130d4..e8bd9bd 100644 --- a/steam_backlog_enforcer/tests/test_steam_restart_guard.py +++ b/steam_backlog_enforcer/tests/test_steam_restart_guard.py @@ -23,15 +23,20 @@ GUARD = "steam_backlog_enforcer._steam_restart_guard" class TestGameIsRunning: + """Detecting whether a Steam game is running.""" + def test_no_processes_means_no_game(self) -> None: + """No Steam processes means no game is running.""" with patch(f"{GUARD}.get_running_steam_game_pids", return_value={}): assert game_is_running() is False def test_the_steam_client_itself_does_not_count(self) -> None: + """The Steam client itself (app id 0) does not count as a game.""" with patch(f"{GUARD}.get_running_steam_game_pids", return_value={42: 0}): assert game_is_running() is False def test_a_real_app_id_counts(self) -> None: + """A process with a real app id counts as a running game.""" with patch( f"{GUARD}.get_running_steam_game_pids", return_value={42: 0, 43: 3164500} ): @@ -39,7 +44,10 @@ class TestGameIsRunning: class TestAssertSafeToRestart: + """Refusing a Steam restart that would kill a game or an update.""" + def test_refuses_while_a_game_runs(self) -> None: + """A running game blocks the restart.""" with ( patch(f"{GUARD}.get_running_steam_game_pids", return_value={7: 412830}), patch(f"{GUARD}.steam_update_in_progress", return_value=False), @@ -48,6 +56,7 @@ class TestAssertSafeToRestart: assert_safe_to_restart() def test_a_running_game_outranks_an_update(self) -> None: + """A running game is reported ahead of an update in progress.""" with ( patch(f"{GUARD}.get_running_steam_game_pids", return_value={7: 412830}), patch(f"{GUARD}.steam_update_in_progress", return_value=True), @@ -56,6 +65,7 @@ class TestAssertSafeToRestart: assert_safe_to_restart() def test_refuses_during_an_update(self) -> None: + """An update in progress blocks the restart.""" with ( patch(f"{GUARD}.get_running_steam_game_pids", return_value={}), patch(f"{GUARD}.steam_update_in_progress", return_value=True), @@ -64,6 +74,7 @@ class TestAssertSafeToRestart: assert_safe_to_restart() def test_allows_when_idle(self) -> None: + """An idle Steam may be restarted.""" with ( patch(f"{GUARD}.get_running_steam_game_pids", return_value={}), patch(f"{GUARD}.steam_update_in_progress", return_value=False), diff --git a/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py b/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py index 4cb4219..d19edcf 100644 --- a/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py +++ b/steam_backlog_enforcer/tests/test_store_blocker_block_via_hosts_install.py @@ -2,6 +2,7 @@ from __future__ import annotations +import socket from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -193,8 +194,6 @@ class TestBlockStoreIptables: def test_dns_resolution_fails(self) -> None: """Test dns resolution fails.""" - import socket - mock_result = MagicMock(returncode=0) with ( patch( @@ -233,7 +232,7 @@ class TestBlockStoreIptables: ), patch( "steam_backlog_enforcer._store_iptables.socket.getaddrinfo", - side_effect=__import__("socket").gaierror, + side_effect=socket.gaierror, ), ): assert _block_store_iptables() is True diff --git a/steam_backlog_enforcer/tests/test_store_blocker_part2.py b/steam_backlog_enforcer/tests/test_store_blocker_part2.py index 398b6d3..795949c 100644 --- a/steam_backlog_enforcer/tests/test_store_blocker_part2.py +++ b/steam_backlog_enforcer/tests/test_store_blocker_part2.py @@ -27,6 +27,7 @@ class TestSudoWriteHosts: """Tests for _sudo_write_hosts.""" def test_writes_content(self) -> None: + """The hosts content is piped to the privileged writer.""" with patch(f"{PKG}.subprocess.run") as mock_run: _sudo_write_hosts("127.0.0.1 localhost\n") mock_run.assert_called_once() @@ -37,6 +38,7 @@ class TestDisableHostsProtection: """Tests for _disable_hosts_protection.""" def test_calls_guardctl_pacman_unlock(self) -> None: + """Disabling protection runs guardctl file-guard pacman-unlock hosts.""" with patch(f"{PKG}.subprocess.run") as mock_run: _disable_hosts_protection() mock_run.assert_called_once() @@ -48,6 +50,7 @@ class TestEnableHostsProtection: """Tests for _enable_hosts_protection.""" def test_calls_guardctl_sync(self) -> None: + """Enabling protection runs guardctl file-guard sync hosts.""" with patch(f"{PKG}.subprocess.run") as mock_run: _enable_hosts_protection() mock_run.assert_called_once() @@ -59,11 +62,13 @@ class TestUnblockHosts: """Tests for _unblock_hosts.""" def test_not_blocked(self) -> None: + """Unblocking an unblocked store succeeds at once.""" with patch(f"{PKG}.is_store_blocked", return_value=False): result = _unblock_hosts() assert result is True def test_comments_out_entries(self, tmp_path: Path) -> None: + """Unblocking comments out the store entries.""" hosts_file = tmp_path / "hosts" hosts_file.write_text( "127.0.0.1 localhost\n" @@ -84,6 +89,7 @@ class TestUnblockHosts: assert "# 0.0.0.0 store.steampowered.com" in written def test_no_change_needed(self, tmp_path: Path) -> None: + """Already commented entries are not rewritten.""" hosts_file = tmp_path / "hosts" hosts_file.write_text( "# 0.0.0.0 store.steampowered.com\n", @@ -101,6 +107,7 @@ class TestUnblockHosts: mock_write.assert_not_called() def test_os_error(self) -> None: + """An OSError while unblocking reports failure.""" with ( patch(f"{PKG}.is_store_blocked", return_value=True), patch(f"{_HP}._disable_hosts_protection", side_effect=OSError), @@ -113,6 +120,7 @@ class TestReblockHosts: """Tests for _reblock_hosts.""" def test_uncomments_entries(self, tmp_path: Path) -> None: + """Reblocking uncomments the store entries.""" hosts_file = tmp_path / "hosts" hosts_file.write_text( "127.0.0.1 localhost\n" @@ -136,6 +144,7 @@ class TestReblockHosts: assert "# 0.0.0.0 store.steampowered.com" not in written def test_no_change(self, tmp_path: Path) -> None: + """A hosts file without store entries is not rewritten.""" hosts_file = tmp_path / "hosts" hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8") with ( @@ -151,6 +160,7 @@ class TestReblockHosts: mock_write.assert_not_called() def test_os_error(self) -> None: + """An OSError while reblocking reports failure.""" with patch( "steam_backlog_enforcer._hosts_protection._disable_hosts_protection", side_effect=OSError, @@ -163,11 +173,13 @@ class TestFlushDnsCache: """Tests for flush_dns_cache.""" def test_runs_commands(self) -> None: + """The DNS flush runs all three commands.""" with patch(f"{PKG}.subprocess.run") as mock_run: flush_dns_cache() assert mock_run.call_count == 3 def test_file_not_found_suppressed(self) -> None: + """A missing flush tool is ignored.""" with patch( f"{PKG}.subprocess.run", side_effect=FileNotFoundError, @@ -175,5 +187,6 @@ class TestFlushDnsCache: flush_dns_cache() def test_os_error_suppressed(self) -> None: + """An OSError from a flush tool is ignored.""" with patch(f"{PKG}.subprocess.run", side_effect=OSError): flush_dns_cache() diff --git a/steam_backlog_enforcer/tests/test_store_window.py b/steam_backlog_enforcer/tests/test_store_window.py index 64378f1..0fe5e6a 100644 --- a/steam_backlog_enforcer/tests/test_store_window.py +++ b/steam_backlog_enforcer/tests/test_store_window.py @@ -30,12 +30,16 @@ def _fresh_reblock_memory() -> Iterator[None]: def _state_ending_in(delta: timedelta) -> State: + """Return a state whose store window ends delta from now.""" return State(store_unblocked_until=(datetime.now(UTC) + delta).isoformat()) class TestOpenStoreWindow: + """Opening a window validates minutes, saves the deadline and unblocks.""" + @pytest.mark.parametrize("minutes", [0, MAX_WINDOW_MINUTES + 1]) def test_rejects_out_of_range(self, minutes: int) -> None: + """Out-of-range minutes raise before anything is unblocked or saved.""" state = State() with ( patch(f"{PKG}.unblock_store") as mock_unblock, @@ -46,6 +50,7 @@ class TestOpenStoreWindow: assert not state.store_unblocked_until def test_saves_deadline_and_unblocks(self) -> None: + """A valid window unblocks the store and persists its deadline.""" state = State() before = datetime.now(UTC) with patch(f"{PKG}.unblock_store", return_value=True) as mock_unblock: @@ -56,6 +61,7 @@ class TestOpenStoreWindow: assert State.load().store_unblocked_until == until.isoformat() def test_failed_unblock_raises(self) -> None: + """A failed unblock raises instead of claiming a window.""" with ( patch(f"{PKG}.unblock_store", return_value=False), pytest.raises(RuntimeError, match="could not unblock"), @@ -64,7 +70,10 @@ class TestOpenStoreWindow: class TestStoreWindowTick: + """The daemon tick keeps an open window open and closes an expired one.""" + def test_no_window_does_nothing(self) -> None: + """With no window the tick checks and blocks nothing.""" with ( patch(f"{PKG}.hosts_blocks_store") as mock_check, patch(f"{PKG}.block_store") as mock_block, @@ -74,11 +83,13 @@ class TestStoreWindowTick: mock_block.assert_not_called() def test_malformed_deadline_is_ignored(self) -> None: + """An unparsable deadline does not block the store.""" with patch(f"{PKG}.block_store") as mock_block: store_window_tick(Config(), State(store_unblocked_until="garbage")) mock_block.assert_not_called() def test_open_window_undoes_a_reblock(self) -> None: + """An open window unblocks a store that got re-blocked.""" state = _state_ending_in(timedelta(minutes=5)) with ( patch(f"{PKG}.hosts_blocks_store", return_value=True), @@ -88,6 +99,7 @@ class TestStoreWindowTick: mock_unblock.assert_called_once() def test_open_window_leaves_open_store_alone(self) -> None: + """An open window does nothing when the store is already open.""" state = _state_ending_in(timedelta(minutes=5)) with ( patch(f"{PKG}.hosts_blocks_store", return_value=False), @@ -97,6 +109,7 @@ class TestStoreWindowTick: mock_unblock.assert_not_called() def test_expired_window_reblocks_exactly_once(self) -> None: + """An expired window blocks the store on the first tick only.""" state = _state_ending_in(timedelta(seconds=-1)) with patch(f"{PKG}.block_store") as mock_block: store_window_tick(Config(block_store=True), state) @@ -104,6 +117,7 @@ class TestStoreWindowTick: mock_block.assert_called_once() def test_expired_window_respects_block_store_off(self) -> None: + """With block_store off an expired window does not block.""" state = _state_ending_in(timedelta(seconds=-1)) with patch(f"{PKG}.block_store") as mock_block: store_window_tick(Config(block_store=False), state) diff --git a/steam_backlog_enforcer/tests/test_total_block_iptables.py b/steam_backlog_enforcer/tests/test_total_block_iptables.py index 9c1fe10..e2c71f7 100644 --- a/steam_backlog_enforcer/tests/test_total_block_iptables.py +++ b/steam_backlog_enforcer/tests/test_total_block_iptables.py @@ -3,6 +3,7 @@ from __future__ import annotations import json +import socket from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -133,11 +134,9 @@ class TestApplyTotalBlockIptables: def test_dns_failure_skips_that_domain(self) -> None: """Test that dns failure skips that domain.""" - import socket as real_socket - with ( patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)), - patch(f"{PKG}.socket.getaddrinfo", side_effect=real_socket.gaierror), + patch(f"{PKG}.socket.getaddrinfo", side_effect=socket.gaierror), ): assert apply_total_block_iptables() is True assert _load_cached_ips() == set() diff --git a/steam_backlog_enforcer/tests/test_total_block_remnants.py b/steam_backlog_enforcer/tests/test_total_block_remnants.py index e71ad01..8af0043 100644 --- a/steam_backlog_enforcer/tests/test_total_block_remnants.py +++ b/steam_backlog_enforcer/tests/test_total_block_remnants.py @@ -6,6 +6,8 @@ import json from typing import TYPE_CHECKING from unittest.mock import patch +import pytest + from steam_backlog_enforcer._total_block_purge import ( _log_steam_purge, _remove_steam_remnants, @@ -52,7 +54,8 @@ class TestUninstallProtonHelpers: class TestRemoveSteamRemnants: """Tests for remove steam remnants.""" - def test_no_remnants_present(self, total_block_paths: Paths) -> None: + @pytest.mark.usefixtures("total_block_paths") + def test_no_remnants_present(self) -> None: """Test that no remnants present.""" assert _remove_steam_remnants() == [] diff --git a/steam_backlog_enforcer/tests/test_update_restart_guard.py b/steam_backlog_enforcer/tests/test_update_restart_guard.py index a6021d8..a231417 100644 --- a/steam_backlog_enforcer/tests/test_update_restart_guard.py +++ b/steam_backlog_enforcer/tests/test_update_restart_guard.py @@ -55,29 +55,40 @@ def _write(steamapps: Path, app_id: int, body: str) -> Path: class TestManifestTransferActive: + """_manifest_transfer_active reads whether a manifest is mid-transfer.""" + def test_downloading_is_active(self, tmp_path: Path) -> None: + """A downloading manifest is active.""" assert _manifest_transfer_active(_write(tmp_path, 1, _DOWNLOADING)) is True def test_staging_is_active(self, tmp_path: Path) -> None: + """A staging manifest is active.""" assert _manifest_transfer_active(_write(tmp_path, 1, _STAGING)) is True def test_complete_is_inactive(self, tmp_path: Path) -> None: + """A complete manifest is inactive.""" assert _manifest_transfer_active(_write(tmp_path, 1, _COMPLETE)) is False def test_idle_is_inactive(self, tmp_path: Path) -> None: + """An idle manifest is inactive.""" assert _manifest_transfer_active(_write(tmp_path, 1, _IDLE)) is False def test_missing_fields_inactive(self, tmp_path: Path) -> None: + """A manifest without transfer fields is inactive.""" manifest = _write(tmp_path, 1, '"AppState"\n{\n\t"appid"\t\t"1"\n}\n') assert _manifest_transfer_active(manifest) is False def test_unreadable_manifest_inactive(self, tmp_path: Path) -> None: + """An unreadable manifest is inactive.""" # Never created -> read_text raises OSError -> treated as inactive. assert _manifest_transfer_active(tmp_path / "appmanifest_gone.acf") is False class TestSteamUpdateInProgress: + """steam_update_in_progress scans every manifest.""" + def test_true_when_any_app_updating(self, tmp_path: Path) -> None: + """One updating app makes it true.""" steamapps = tmp_path / "steamapps" _write(steamapps, 1, _IDLE) _write(steamapps, 2, _DOWNLOADING) @@ -85,6 +96,7 @@ class TestSteamUpdateInProgress: assert steam_update_in_progress() is True def test_false_when_all_idle(self, tmp_path: Path) -> None: + """All idle or complete apps make it false.""" steamapps = tmp_path / "steamapps" _write(steamapps, 1, _IDLE) _write(steamapps, 2, _COMPLETE) @@ -92,19 +104,24 @@ class TestSteamUpdateInProgress: assert steam_update_in_progress() is False def test_false_when_no_manifests(self, tmp_path: Path) -> None: + """No manifests make it false.""" steamapps = tmp_path / "empty_steamapps" steamapps.mkdir() with patch(f"{SS}.STEAMAPPS_PATH", steamapps): assert steam_update_in_progress() is False def test_glob_oserror_is_false(self) -> None: + """A failing directory scan makes it false.""" with patch(f"{SS}.STEAMAPPS_PATH") as fake_path: fake_path.glob.side_effect = OSError("boom") assert steam_update_in_progress() is False class TestEnsureSteamDebugPortDefersUpdate: + """ensure_steam_debug_port does not restart Steam mid-update.""" + def test_defers_and_does_not_shut_down(self) -> None: + """During an update it raises without shutting Steam down.""" with ( patch(f"{LH}.steam_is_installed", return_value=True), patch(f"{LH}._steam_has_debug_port", return_value=False), @@ -120,7 +137,10 @@ class TestEnsureSteamDebugPortDefersUpdate: class TestRestartSteamGuard: + """restart_steam refuses to bounce Steam during a game or update.""" + def test_skips_while_a_game_is_running(self) -> None: + """A running game skips the restart.""" # The interactive restart path has the same teeth as the daemon's: # bouncing Steam kills the game and any unsaved progress with it. with ( @@ -133,6 +153,7 @@ class TestRestartSteamGuard: launch.assert_not_called() def test_skips_when_update_in_progress(self) -> None: + """An update in progress skips the restart.""" with ( patch(f"{LH}.steam_update_in_progress", return_value=True), patch(f"{LH}._shutdown_steam") as shutdown, @@ -143,6 +164,7 @@ class TestRestartSteamGuard: launch.assert_not_called() def test_restarts_when_no_update(self) -> None: + """Without a game or update Steam is restarted.""" with ( patch(f"{LH}.steam_update_in_progress", return_value=False), patch(f"{LH}._shutdown_steam") as shutdown, diff --git a/steam_backlog_enforcer/tests/test_web_art.py b/steam_backlog_enforcer/tests/test_web_art.py index f873ca6..a3d9c71 100644 --- a/steam_backlog_enforcer/tests/test_web_art.py +++ b/steam_backlog_enforcer/tests/test_web_art.py @@ -9,6 +9,18 @@ import pytest import requests from steam_backlog_enforcer import _web_art +from steam_backlog_enforcer._web_art import ( + _CDN, + _FAILED_RETRY_S, + _MAX_BYTES, + _cache_file, + _fetch, + _local_cover, + _miss_active, + _miss_file, + _record_miss, + _store, +) from steam_backlog_enforcer._web_errors import ApiError from steam_backlog_enforcer.tests._web_request import make_request @@ -26,74 +38,94 @@ def _response( class TestLocalCover: + """Steam's own librarycache is searched for a portrait cover.""" + def test_none_when_steam_cached_nothing(self) -> None: - assert _web_art._local_cover(10) is None + """An app with no librarycache entry has no local cover.""" + assert _local_cover(10) is None def test_top_level_portrait(self) -> None: + """A portrait in the app's top-level cache dir is found.""" base = _web_art.LIBRARYCACHE / "10" base.mkdir(parents=True) (base / "library_600x900.jpg").write_bytes(b"A") - assert _web_art._local_cover(10) == base / "library_600x900.jpg" + assert _local_cover(10) == base / "library_600x900.jpg" def test_hashed_subdirectory_portrait(self) -> None: + """A portrait inside a hashed subdirectory is found.""" sub = _web_art.LIBRARYCACHE / "10" / "abc123" sub.mkdir(parents=True) (sub / "library_capsule.jpg").write_bytes(b"B") - assert _web_art._local_cover(10) == sub / "library_capsule.jpg" + assert _local_cover(10) == sub / "library_capsule.jpg" class TestMissMarker: + """The miss marker suppresses refetches until it lapses.""" + def test_absent_marker_is_not_active(self) -> None: - assert _web_art._miss_active(1) is False + """No marker file means no active miss.""" + assert _miss_active(1) is False def test_garbage_marker_is_not_active(self) -> None: + """An unparsable marker is treated as no miss.""" _web_art.ART_CACHE_DIR.mkdir(parents=True) - _web_art._miss_file(1).write_text("not a number", encoding="utf-8") - assert _web_art._miss_active(1) is False + _miss_file(1).write_text("not a number", encoding="utf-8") + assert _miss_active(1) is False def test_recorded_miss_is_active_until_it_lapses(self) -> None: - _web_art._record_miss(1, 60) - assert _web_art._miss_active(1) is True + """A recorded miss is active, then lapses after its wait.""" + _record_miss(1, 60) + assert _miss_active(1) is True with patch(f"{_PKG}.time.time", return_value=time.time() + 120): - assert _web_art._miss_active(1) is False + assert _miss_active(1) is False class TestStore: + """Storing a cover writes the cache file.""" + def test_store_writes_and_clears_the_miss(self) -> None: - _web_art._record_miss(2, 60) - _web_art._store(2, b"IMG") - assert _web_art._cache_file(2).read_bytes() == b"IMG" - assert not _web_art._miss_file(2).exists() + """Storing writes the bytes and removes any miss marker.""" + _record_miss(2, 60) + _store(2, b"IMG") + assert _cache_file(2).read_bytes() == b"IMG" + assert not _miss_file(2).exists() def test_store_without_a_miss_marker(self) -> None: - _web_art._store(3, b"IMG") - assert _web_art._cache_file(3).read_bytes() == b"IMG" + """Storing works when there was no miss marker.""" + _store(3, b"IMG") + assert _cache_file(3).read_bytes() == b"IMG" class TestFetch: + """The CDN fetch is fenced by the cache and the miss marker.""" + def test_returns_a_cover_stored_while_waiting(self) -> None: - _web_art._store(4, b"EARLY") + """A cover stored meanwhile is returned without a request.""" + _store(4, b"EARLY") with patch(f"{_PKG}.requests.get") as get: - assert _web_art._fetch(4) == b"EARLY" + assert _fetch(4) == b"EARLY" get.assert_not_called() def test_active_miss_skips_the_network(self) -> None: - _web_art._record_miss(4, 60) + """An active miss returns None without a request.""" + _record_miss(4, 60) with patch(f"{_PKG}.requests.get") as get: - assert _web_art._fetch(4) is None + assert _fetch(4) is None get.assert_not_called() def test_network_failure_is_remembered_briefly(self) -> None: + """A connection error records a short miss.""" with patch(f"{_PKG}.requests.get", side_effect=requests.ConnectionError): - assert _web_art._fetch(5) is None - retry_at = float(_web_art._miss_file(5).read_text(encoding="utf-8")) - assert retry_at - time.time() <= _web_art._FAILED_RETRY_S + assert _fetch(5) is None + retry_at = float(_miss_file(5).read_text(encoding="utf-8")) + assert retry_at - time.time() <= _FAILED_RETRY_S def test_success_is_cached(self) -> None: + """A good answer comes from the CDN URL and is cached.""" with patch(f"{_PKG}.requests.get", return_value=_response()) as get: - assert _web_art._fetch(6) == b"JPEG" - assert get.call_args.args == (_web_art._CDN.format(app_id=6),) - assert _web_art._cache_file(6).read_bytes() == b"JPEG" + assert _fetch(6) == b"JPEG" + assert get.call_args.args == (_CDN.format(app_id=6),) + assert _cache_file(6).read_bytes() == b"JPEG" @pytest.mark.parametrize( ("status", "ctype", "long_retry"), @@ -107,43 +139,54 @@ class TestFetch: def test_unusable_answer_is_a_remembered_miss( self, status: int, ctype: str, *, long_retry: bool ) -> None: + """Bad answers record a miss; only 403/404 get the long wait.""" resp = _response(status, ctype) with patch(f"{_PKG}.requests.get", return_value=resp): - assert _web_art._fetch(7) is None - wait = float(_web_art._miss_file(7).read_text(encoding="utf-8")) - time.time() - assert (wait > _web_art._FAILED_RETRY_S) is long_retry + assert _fetch(7) is None + wait = float(_miss_file(7).read_text(encoding="utf-8")) - time.time() + assert (wait > _FAILED_RETRY_S) is long_retry def test_oversized_cover_is_a_long_miss(self) -> None: - big = _response(content=b"x" * (_web_art._MAX_BYTES + 1)) + """A cover over the size cap records a long miss.""" + big = _response(content=b"x" * (_MAX_BYTES + 1)) with patch(f"{_PKG}.requests.get", return_value=big): - assert _web_art._fetch(8) is None - wait = float(_web_art._miss_file(8).read_text(encoding="utf-8")) - time.time() - assert wait > _web_art._FAILED_RETRY_S + assert _fetch(8) is None + wait = float(_miss_file(8).read_text(encoding="utf-8")) - time.time() + assert wait > _FAILED_RETRY_S class TestCoverBytes: + """cover_bytes tries Steam's cache, the server cache, then the CDN.""" + def test_prefers_steams_own_cache(self) -> None: + """Steam's local cover wins over everything else.""" base = _web_art.LIBRARYCACHE / "9" base.mkdir(parents=True) (base / "library_600x900.jpg").write_bytes(b"LOCAL") assert _web_art.cover_bytes(9) == b"LOCAL" def test_then_the_server_cache(self) -> None: - _web_art._store(9, b"CACHED") + """The server cache is used when Steam has nothing.""" + _store(9, b"CACHED") assert _web_art.cover_bytes(9) == b"CACHED" def test_active_miss_means_no_cover(self) -> None: - _web_art._record_miss(9, 60) + """An active miss yields no cover.""" + _record_miss(9, 60) assert _web_art.cover_bytes(9) is None def test_falls_through_to_the_cdn(self) -> None: + """With nothing cached, the CDN fetch is asked once.""" with patch(f"{_PKG}._fetch", return_value=b"NET") as fetch: assert _web_art.cover_bytes(9) == b"NET" fetch.assert_called_once_with(9) class TestArtView: + """The /art endpoint serves covers of owned games only.""" + def test_serves_an_owned_cover(self) -> None: + """An owned game's cover is served as a cacheable JPEG.""" with ( patch(f"{_PKG}.owned_app_ids", return_value={42}), patch(f"{_PKG}.cover_bytes", return_value=b"JPG"), @@ -155,6 +198,7 @@ class TestArtView: @pytest.mark.parametrize("raw", ["abc", "7"]) def test_refuses_what_is_not_owned(self, raw: str) -> None: + """A non-numeric or unowned id is not_found.""" with ( patch(f"{_PKG}.owned_app_ids", return_value={42}), pytest.raises(ApiError) as err, @@ -163,6 +207,7 @@ class TestArtView: assert err.value.code == "not_found" def test_no_cover_is_not_found(self) -> None: + """An owned game without a cover is not_found.""" with ( patch(f"{_PKG}.owned_app_ids", return_value={42}), patch(f"{_PKG}.cover_bytes", return_value=None), diff --git a/steam_backlog_enforcer/tests/test_web_auth.py b/steam_backlog_enforcer/tests/test_web_auth.py index 4245069..82bdeb7 100644 --- a/steam_backlog_enforcer/tests/test_web_auth.py +++ b/steam_backlog_enforcer/tests/test_web_auth.py @@ -20,24 +20,32 @@ def _headers(**fields: str) -> Message: class TestToken: + """Session tokens: random, and compared strictly.""" + def test_tokens_are_fresh_hex(self) -> None: + """Each token is a new 64-char hex string.""" first, second = _web_auth.new_token(), _web_auth.new_token() assert first != second assert len(first) == 64 int(first, 16) def test_matching_token_passes(self) -> None: + """The right token is accepted.""" _web_auth.check_token("abc", "abc") @pytest.mark.parametrize("supplied", [None, "", "abd"]) def test_missing_or_wrong_token_is_refused(self, supplied: str | None) -> None: + """A missing, empty or wrong token is refused as bad_token.""" with pytest.raises(ApiError) as err: _web_auth.check_token(supplied, "abc") assert err.value.code == "bad_token" class TestTokenFile: + """Where the token file lives and how it is written.""" + def test_path_uses_the_runtime_dir(self, monkeypatch: pytest.MonkeyPatch) -> None: + """The token file lives under XDG_RUNTIME_DIR.""" monkeypatch.setenv("XDG_RUNTIME_DIR", "/run/x") path = _web_auth.token_path(8123) assert str(path) == "/run/x/steam-backlog-enforcer/web-8123.token" @@ -45,10 +53,12 @@ class TestTokenFile: def test_path_falls_back_to_the_uid_dir( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """Without XDG_RUNTIME_DIR it falls back to /run/user/.""" monkeypatch.delenv("XDG_RUNTIME_DIR") assert str(_web_auth.token_path(1)).startswith(f"/run/user/{os.getuid()}/") def test_written_owner_only_and_replaced(self) -> None: + """The file and its dir are owner-only and rewrites replace it atomically.""" path = _web_auth.write_token_file("tok1", 9000) assert path.read_text(encoding="ascii") == "tok1\n" assert stat.S_IMODE(path.stat().st_mode) == 0o600 @@ -59,36 +69,47 @@ class TestTokenFile: class TestHostAndOrigin: + """Only loopback Host headers and same-origin requests pass.""" + def test_allowed_hosts(self) -> None: + """127.0.0.1 and localhost on the port are the allowed hosts.""" assert _web_auth.allowed_hosts(80) == {"127.0.0.1:80", "localhost:80"} def test_loopback_host_is_normalised(self) -> None: + """A loopback Host is trimmed and lower-cased.""" got = _web_auth.check_host(_headers(Host=" LocalHost:80 "), 80) assert got == "localhost:80" @pytest.mark.parametrize("fields", [{}, {"Host": "evil.example:80"}]) def test_foreign_or_missing_host_is_refused(self, fields: dict[str, str]) -> None: + """A missing or foreign Host is refused as bad_host.""" with pytest.raises(ApiError) as err: _web_auth.check_host(_headers(**fields), 80) assert err.value.code == "bad_host" def test_same_origin_passes(self) -> None: + """An Origin matching the host is accepted.""" _web_auth.check_origin(_headers(Origin="http://localhost:80"), "localhost:80") @pytest.mark.parametrize("fields", [{}, {"Origin": "http://evil.example"}]) def test_other_origin_is_refused(self, fields: dict[str, str]) -> None: + """A missing or foreign Origin is refused as bad_origin.""" with pytest.raises(ApiError) as err: _web_auth.check_origin(_headers(**fields), "localhost:80") assert err.value.code == "bad_origin" class TestInjectToken: + """The token meta tag is placed into served HTML.""" + def test_goes_right_after_head(self) -> None: + """The tag goes right after the head tag, whatever its case.""" out = _web_auth.inject_token(b"t", "T") assert out == ( b't' ) def test_prepended_without_a_head(self) -> None: + """Without a head tag the meta tag is prepended.""" out = _web_auth.inject_token(b"

x

", "T") assert out == b'

x

' diff --git a/steam_backlog_enforcer/tests/test_web_build.py b/steam_backlog_enforcer/tests/test_web_build.py index 28a6552..ff352e7 100644 --- a/steam_backlog_enforcer/tests/test_web_build.py +++ b/steam_backlog_enforcer/tests/test_web_build.py @@ -8,6 +8,7 @@ working tree, and the tests would silently grade the developer's own bundle. from __future__ import annotations +import os import subprocess from typing import TYPE_CHECKING from unittest.mock import MagicMock, patch @@ -15,6 +16,7 @@ from unittest.mock import MagicMock, patch import pytest from steam_backlog_enforcer import _web_build +from steam_backlog_enforcer._web_build import _SOURCE_DIRS, _SOURCE_FILES, _mtime if TYPE_CHECKING: from collections.abc import Iterator @@ -23,8 +25,8 @@ if TYPE_CHECKING: _PKG = "steam_backlog_enforcer._web_build" -@pytest.fixture -def web(tmp_path: Path) -> Iterator[Path]: +@pytest.fixture(name="web") +def fixture_web(tmp_path: Path) -> Iterator[Path]: """Redirect the whole module's path constants into a fake web/ tree.""" root = tmp_path / "web" (root / "src").mkdir(parents=True) @@ -46,8 +48,6 @@ def _touch(path: Path, mtime: float) -> None: """Create *path* with a fixed mtime.""" path.parent.mkdir(parents=True, exist_ok=True) path.write_text("x", encoding="utf-8") - import os - os.utime(path, (mtime, mtime)) @@ -55,12 +55,14 @@ class TestMtime: """Tests for the _mtime helper.""" def test_missing_path_is_zero(self, tmp_path: Path) -> None: - assert _web_build._mtime(tmp_path / "nope") == 0.0 + """A path that does not exist has mtime 0.""" + assert _mtime(tmp_path / "nope") == 0.0 def test_existing_path_returns_mtime(self, tmp_path: Path) -> None: + """An existing path reports its real mtime.""" target = tmp_path / "f" _touch(target, 1000.0) - assert _web_build._mtime(target) == pytest.approx(1000.0) + assert _mtime(target) == pytest.approx(1000.0) class TestFrontendIsStale: @@ -69,32 +71,38 @@ class TestFrontendIsStale: def test_unbuilt_but_present_web_dir_is_stale(self, web: Path) -> None: # No dist/index.html at all: a fresh clone must build, not serve the # "not built" placeholder. + """Sources with no built bundle are stale.""" assert web.is_dir() assert _web_build.frontend_is_stale() is True def test_missing_web_dir_is_not_stale(self, web: Path) -> None: # Nothing to build from — claiming staleness would make serve exit 1 # on an install that legitimately ships no frontend sources. + """No web/ dir at all is never stale.""" with patch.object(_web_build, "WEB_DIR", web / "absent"): assert _web_build.frontend_is_stale() is False def test_newer_source_file_is_stale(self, web: Path) -> None: + """A flat source file newer than the bundle is stale.""" _touch(web / "dist" / "index.html", 1000.0) _touch(web / "package.json", 2000.0) assert _web_build.frontend_is_stale() is True def test_newer_src_tree_entry_is_stale(self, web: Path) -> None: + """A nested src/ file newer than the bundle is stale.""" _touch(web / "dist" / "index.html", 1000.0) _touch(web / "src" / "nested" / "app.tsx", 2000.0) assert _web_build.frontend_is_stale() is True def test_newer_public_asset_is_stale(self, web: Path) -> None: # public/ is copied verbatim into dist/: a new favicon needs a build. + """A public/ asset newer than the bundle is stale.""" _touch(web / "dist" / "index.html", 1000.0) _touch(web / "public" / "favicon.svg", 2000.0) assert _web_build.frontend_is_stale() is True def test_all_sources_older_is_current(self, web: Path) -> None: + """A bundle newer than every source is current.""" _touch(web / "package.json", 1000.0) _touch(web / "src" / "app.tsx", 1000.0) _touch(web / "dist" / "index.html", 2000.0) @@ -103,12 +111,14 @@ class TestFrontendIsStale: def test_directories_in_src_are_skipped(self, web: Path) -> None: # A directory's own mtime bumps whenever a file inside it is added or # removed, so counting it would report staleness after a build. + """Directory mtimes under src/ do not count.""" _touch(web / "src" / "old.tsx", 1000.0) _touch(web / "dist" / "index.html", 2000.0) (web / "src" / "assets").mkdir() assert _web_build.frontend_is_stale() is False def test_missing_src_dir_still_checks_flat_sources(self, web: Path) -> None: + """A missing source dir still checks the flat files.""" _touch(web / "dist" / "index.html", 1000.0) _touch(web / "index.html", 2000.0) with patch.object(_web_build, "_SOURCE_DIRS", (web / "absent",)): @@ -119,21 +129,24 @@ class TestSourceFiles: """The watched build inputs match the Astro layout.""" def test_astro_inputs_are_watched_and_vite_ones_are_gone(self) -> None: - names = {path.name for path in _web_build._SOURCE_FILES} + """Astro build inputs are watched; Vite-only ones are not.""" + names = {path.name for path in _SOURCE_FILES} assert { "astro.config.mjs", "package.json", "pnpm-lock.yaml", "pnpm-workspace.yaml", } <= names - assert {path.name for path in _web_build._SOURCE_DIRS} == {"src", "public"} + assert {path.name for path in _SOURCE_DIRS} == {"src", "public"} assert not names & {"vite.config.ts", "tsconfig.app.json", "index.html"} class TestBuildFrontend: """Tests for build_frontend. subprocess is never actually executed.""" - def test_missing_pnpm_explains_and_fails(self, web: Path) -> None: + @pytest.mark.usefixtures("web") + def test_missing_pnpm_explains_and_fails(self) -> None: + """No pnpm: fail and tell the user to install pnpm, not npm.""" with ( patch(f"{_PKG}.shutil.which", return_value=None), patch(f"{_PKG}._echo") as echo, @@ -143,6 +156,7 @@ class TestBuildFrontend: assert "npm install" not in echo.call_args[0][0].replace("pnpm install", "") def test_successful_build_returns_true(self, web: Path) -> None: + """A zero exit runs ``pnpm build`` in web/ and returns True.""" run = MagicMock(return_value=MagicMock(returncode=0)) with ( patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"), @@ -153,7 +167,9 @@ class TestBuildFrontend: assert run.call_args[0][0] == ["/usr/bin/pnpm", "build"] assert run.call_args[1]["cwd"] == web - def test_failed_build_prints_output_and_returns_false(self, web: Path) -> None: + @pytest.mark.usefixtures("web") + def test_failed_build_prints_output_and_returns_false(self) -> None: + """A failed build echoes FAILED plus its output, returns False.""" result = MagicMock(returncode=1, stdout=" out \n", stderr=" err \n") with ( patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"), @@ -166,8 +182,10 @@ class TestBuildFrontend: assert "out" in printed assert "err" in printed - def test_timeout_propagates(self, web: Path) -> None: + @pytest.mark.usefixtures("web") + def test_timeout_propagates(self) -> None: # A hung build must not be swallowed into "bundle is fine". + """A build timeout is raised, not swallowed.""" with ( patch(f"{_PKG}.shutil.which", return_value="/usr/bin/pnpm"), patch( diff --git a/steam_backlog_enforcer/tests/test_web_daemon.py b/steam_backlog_enforcer/tests/test_web_daemon.py index 9a9d08c..b6f07c5 100644 --- a/steam_backlog_enforcer/tests/test_web_daemon.py +++ b/steam_backlog_enforcer/tests/test_web_daemon.py @@ -7,7 +7,7 @@ from unittest.mock import patch import pytest -from steam_backlog_enforcer import _web_daemon +from steam_backlog_enforcer import _ctl_client, _web_daemon from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError from steam_backlog_enforcer._ctl_types import ( DaemonInfo, @@ -15,6 +15,7 @@ from steam_backlog_enforcer._ctl_types import ( PendingReset, ResetResult, ) +from steam_backlog_enforcer._web_daemon import _JOURNAL_LINES from steam_backlog_enforcer._web_errors import ApiError from steam_backlog_enforcer.jobs._view import list_jobs from steam_backlog_enforcer.tests._web_request import make_request, payload_of @@ -25,34 +26,43 @@ _INFO = DaemonInfo("running", "2026-01-01T00:00:00Z", 77, None) class TestDaemonCall: + """daemon_call maps control-socket failures to API errors.""" + def test_passes_the_result_through(self) -> None: + """A successful call returns its result unchanged.""" assert _web_daemon.daemon_call(max, 1, 3) == 3 def test_unreachable_becomes_503(self) -> None: + """An unreachable daemon is a 503.""" with ( patch(f"{_CLIENT}.ping", side_effect=DaemonUnreachableError("no socket")), pytest.raises(ApiError) as err, ): - _web_daemon.daemon_call(_web_daemon._ctl_client.ping) + _web_daemon.daemon_call(_ctl_client.ping) assert err.value.status == HTTPStatus.SERVICE_UNAVAILABLE def test_refusal_keeps_its_code(self) -> None: + """A daemon refusal keeps its error code.""" with ( patch(f"{_CLIENT}.ping", side_effect=CtlError("locked", "no")), pytest.raises(ApiError) as err, ): - _web_daemon.daemon_call(_web_daemon._ctl_client.ping) + _web_daemon.daemon_call(_ctl_client.ping) assert err.value.code == "locked" class TestDaemonView: + """The daemon status view.""" + def test_unreachable_is_a_state_not_an_error(self) -> None: + """An unreachable daemon reports state ``unreachable``.""" with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError): reply = _web_daemon.daemon_view(make_request()) assert payload_of(reply)["state"] == "unreachable" assert payload_of(reply)["fast_installs"] == [] def test_status_refusal_is_an_error(self) -> None: + """A refused status call raises with its code.""" with ( patch(f"{_CLIENT}.status", side_effect=CtlError("busy", "m")), pytest.raises(ApiError) as err, @@ -61,12 +71,13 @@ class TestDaemonView: assert err.value.code == "busy" def test_reachable_includes_the_journal(self) -> None: + """A running daemon reports its status plus the journal tail.""" with ( patch(f"{_CLIENT}.status", return_value=_INFO), patch(f"{_CLIENT}.journal_tail", return_value=["a", "b"]) as tail, ): reply = _web_daemon.daemon_view(make_request()) - tail.assert_called_once_with(_web_daemon._JOURNAL_LINES) + tail.assert_called_once_with(_JOURNAL_LINES) assert reply.payload == { "state": "running", "started_at": "2026-01-01T00:00:00Z", @@ -77,6 +88,7 @@ class TestDaemonView: } def test_fast_installs_pass_through(self) -> None: + """Fast-install runs appear in the payload field by field.""" run = FastInstallInfo(7, "G", "downloading depot 3/8", 46.9, 60) info = DaemonInfo("running", "t", 77, None, fast_installs=(run,)) with ( @@ -99,6 +111,7 @@ class TestDaemonView: "failure", [DaemonUnreachableError(), CtlError("busy", "m")] ) def test_status_stands_without_its_journal(self, failure: Exception) -> None: + """A failed journal fetch leaves an empty tail, not an error.""" with ( patch(f"{_CLIENT}.status", return_value=_INFO), patch(f"{_CLIENT}.journal_tail", side_effect=failure), @@ -108,7 +121,10 @@ class TestDaemonView: class TestPendingRoutes: + """The pending gaming-reset routes.""" + def test_pending_action_maps_the_contract_fields(self) -> None: + """A pending reset maps onto the API contract fields.""" assert _web_daemon.pending_action(_PENDING) == { "id": "p1", "command": "gaming-reset", @@ -119,12 +135,14 @@ class TestPendingRoutes: } def test_heartbeat(self) -> None: + """The heartbeat route forwards the id and returns the pending reset.""" with patch(f"{_CLIENT}.gaming_reset_heartbeat", return_value=_PENDING) as hb: reply = _web_daemon.heartbeat_view(make_request("p1")) hb.assert_called_once_with("p1") assert payload_of(reply)["id"] == "p1" def test_cancel(self) -> None: + """The cancel route forwards the id and answers 204.""" with patch(f"{_CLIENT}.gaming_reset_cancel") as cancel: reply = _web_daemon.cancel_pending_view(make_request("p1")) cancel.assert_called_once_with("p1") @@ -132,6 +150,8 @@ class TestPendingRoutes: class TestConfirmPhrase: + """Reading the confirm phrase from the request body.""" + @pytest.mark.parametrize( ("body", "expected"), [ @@ -141,16 +161,21 @@ class TestConfirmPhrase: ], ) def test_reads_either_spelling(self, body: dict[str, str], expected: str) -> None: + """Both snake and camel case keys work; absent is empty.""" assert _web_daemon.confirm_phrase_of(make_request(body=body)) == expected def test_rejects_a_non_string(self) -> None: + """A non-string phrase is invalid_params.""" with pytest.raises(ApiError) as err: _web_daemon.confirm_phrase_of(make_request(body={"confirm_phrase": 3})) assert err.value.code == "invalid_params" class TestCommit: + """Committing a gaming reset records a job.""" + def test_records_a_finished_job(self) -> None: + """A commit answers 201 with a billed summary and records one job.""" result = ResetResult("2026-01-01", 1800.0, was_blocked=True, released=["m"]) with patch(f"{_CLIENT}.gaming_reset_commit", return_value=result) as commit: reply = _web_daemon.commit_view( @@ -162,6 +187,7 @@ class TestCommit: assert [j["id"] for j in list_jobs()] == [payload_of(reply)["id"]] def test_failed_op_is_recorded_and_raised(self) -> None: + """An op failure is raised and recorded as a failed job.""" failure = CtlError("op_failed", "disk full") with ( patch(f"{_CLIENT}.gaming_reset_commit", side_effect=failure), @@ -174,6 +200,7 @@ class TestCommit: assert job["exit_code"] == 1 def test_refusal_leaves_no_job(self) -> None: + """A refused commit records no job.""" with ( patch( f"{_CLIENT}.gaming_reset_commit", diff --git a/steam_backlog_enforcer/tests/test_web_dataset.py b/steam_backlog_enforcer/tests/test_web_dataset.py index 40553fe..d1c0987 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset.py +++ b/steam_backlog_enforcer/tests/test_web_dataset.py @@ -2,10 +2,7 @@ from __future__ import annotations -from dataclasses import replace - from steam_backlog_enforcer._web_dataset import ( - WebGame, _default_qualifying, _default_summary, _has_any_time, @@ -13,63 +10,14 @@ from steam_backlog_enforcer._web_dataset import ( _sum_positive, _worst_hours, ) -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._web_dataset_factories import ( + _gi, + _wg, +) _PKG = "steam_backlog_enforcer._web_dataset" -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, - ) - - class TestWorstHours: """Tests for _worst_hours (mirrors _stats worst-case selection).""" diff --git a/steam_backlog_enforcer/tests/test_web_dataset_build_games.py b/steam_backlog_enforcer/tests/test_web_dataset_build_games.py index 36a78ab..49ad367 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset_build_games.py +++ b/steam_backlog_enforcer/tests/test_web_dataset_build_games.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import replace +from typing import TYPE_CHECKING from unittest.mock import patch from steam_backlog_enforcer._web_dataset import ( @@ -12,65 +12,18 @@ from steam_backlog_enforcer._web_dataset import ( build_web_dataset, ) from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._web_dataset_factories import ( + _gi, +) + +if TYPE_CHECKING: + from steam_backlog_enforcer.steam_api import GameInfo _PKG = "steam_backlog_enforcer._web_games" # build_web_dataset itself did not move. _DATASET_PKG = "steam_backlog_enforcer._web_dataset" -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, - ) - - class TestBuildGames: """Tests for _build_games (patches cache loaders, no file I/O).""" diff --git a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py index 88b590c..b90f05a 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py +++ b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b.py @@ -2,69 +2,18 @@ from __future__ import annotations -from dataclasses import replace - from steam_backlog_enforcer._web_dataset import ( - WebGame, compute_pace_vs_hltb, ) -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._web_dataset_factories import ( + _complete_game, + _gi, + _hltb_cache, +) _PKG = "steam_backlog_enforcer._web_dataset" -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, - ) - - class TestComputePaceVsHLTB: """Tests for compute_pace_vs_hltb — 100 % branch coverage.""" @@ -82,31 +31,13 @@ class TestComputePaceVsHLTB: """Test no rush data in cache returns none.""" game = _complete_game(app_id=1) # cache has hours but no rush_hours - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": -1, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(-1, -1) assert compute_pace_vs_hltb([game], cache) is None def test_rush_only_ratio_computed(self) -> None: """With rush but no leisure, ratio_vs_rush is computed, interpolation_t = -1.""" game = _complete_game(app_id=1, playtime_minutes=600) # 10h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, -1) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.calibration_count == 1 @@ -117,16 +48,7 @@ class TestComputePaceVsHLTB: def test_rush_only_style_faster_than_rush_when_ratio_below_one(self) -> None: """Plays faster than rush (actual < rush) → style = faster_than_rush.""" game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, -1) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.player_style == "faster_than_rush" @@ -134,16 +56,7 @@ class TestComputePaceVsHLTB: def test_rush_only_style_unknown_when_ratio_at_or_above_one(self) -> None: """Without leisure data and ratio >= 1 → style = unknown.""" game = _complete_game(app_id=1, playtime_minutes=600) # 10h - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, -1) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.player_style == "unknown" diff --git a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py index 073592b..b4fb910 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py +++ b/steam_backlog_enforcer/tests/test_web_dataset_compute_pace_vs_h_l_t_b_both_rush_and_leisure_interpolation_comp.py @@ -2,70 +2,18 @@ from __future__ import annotations -from dataclasses import replace - from steam_backlog_enforcer._web_dataset import ( PaceVsHLTB, - WebGame, compute_pace_vs_hltb, ) -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.tests._web_dataset_factories import ( + _complete_game, + _hltb_cache, +) _PKG = "steam_backlog_enforcer._web_dataset" -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, - ) - - class TestComputePaceVsHLTBGroup2: """Tests for compute_pace_vs_hltb — 100 % branch coverage.""" @@ -73,16 +21,7 @@ class TestComputePaceVsHLTBGroup2: """With both rush + leisure, interpolation_t is computed.""" # actual=10h, rush=8h, leisure=20h → t = (10-8)/(20-8) = 2/12 ≈ 0.167 game = _complete_game(app_id=1, playtime_minutes=600) - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, 20.0) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.interpolation_t == round((10.0 - 8.0) / (20.0 - 8.0), 3) @@ -92,16 +31,7 @@ class TestComputePaceVsHLTBGroup2: def test_style_faster_than_rush_when_t_negative(self) -> None: """t < 0 means faster than rush.""" game = _complete_game(app_id=1, playtime_minutes=300) # 5h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, 20.0) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.interpolation_t < 0 @@ -110,16 +40,7 @@ class TestComputePaceVsHLTBGroup2: def test_style_slower_than_leisure_when_t_above_one(self) -> None: """t > 1 means slower than leisure.""" game = _complete_game(app_id=1, playtime_minutes=1500) # 25h actual - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, 20.0) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.interpolation_t > 1.0 @@ -129,16 +50,7 @@ class TestComputePaceVsHLTBGroup2: """Edge case: leisure <= rush, can't divide, interpolation_t = -1.""" game = _complete_game(app_id=1, playtime_minutes=600) # leisure == rush → denominator = 0 - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 8.0, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, 8.0) result = compute_pace_vs_hltb([game], cache) assert result is not None assert result.interpolation_t == -1.0 @@ -146,15 +58,6 @@ class TestComputePaceVsHLTBGroup2: def test_pace_vs_hltb_is_dataclass(self) -> None: """Return type is PaceVsHLTB.""" game = _complete_game(app_id=1) - cache = { - 1: { - "hours": 10.0, - "polls": 5, - "count_comp": 20, - "rush_hours": 8.0, - "leisure_100h": 20.0, - "hltb_game_id": 0, - } - } + cache = _hltb_cache(8.0, 20.0) result = compute_pace_vs_hltb([game], cache) assert isinstance(result, PaceVsHLTB) diff --git a/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py b/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py index 722eab9..5b9549c 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py +++ b/steam_backlog_enforcer/tests/test_web_dataset_count_complete_since_start.py @@ -2,79 +2,26 @@ from __future__ import annotations -from dataclasses import replace from datetime import UTC, datetime, timedelta from steam_backlog_enforcer._web_dataset import ( - WebGame, _state_info, count_complete_since_start, ) from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.steam_api import GameInfo +from steam_backlog_enforcer.steam_api import AchievementInfo, GameInfo +from steam_backlog_enforcer.tests._web_dataset_factories import ( + _gi, +) _PKG = "steam_backlog_enforcer._web_dataset" -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, - ) - - class TestCountCompleteSinceStart: """Tests for count_complete_since_start.""" def _ach(self, ts: int, *, achieved: bool = True) -> object: """Test ach.""" - from steam_backlog_enforcer.steam_api import AchievementInfo - return AchievementInfo( api_name="A", display_name="A", achieved=achieved, unlock_time=ts ) diff --git a/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py b/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py index de8df82..c8dd58f 100644 --- a/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py +++ b/steam_backlog_enforcer/tests/test_web_dataset_dataset_to_payload.py @@ -2,74 +2,19 @@ from __future__ import annotations -from dataclasses import replace from unittest.mock import patch from steam_backlog_enforcer._web_dataset import ( - WebGame, build_web_dataset, dataset_to_payload, ) from steam_backlog_enforcer.config import State -from steam_backlog_enforcer.steam_api import GameInfo _PKG = "steam_backlog_enforcer._web_games" # build_web_dataset itself did not move. _DATASET_PKG = "steam_backlog_enforcer._web_dataset" -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, - ) - - class TestDatasetToPayload: """Tests for dataset_to_payload.""" diff --git a/steam_backlog_enforcer/tests/test_web_errors.py b/steam_backlog_enforcer/tests/test_web_errors.py index 98b27b7..9c82804 100644 --- a/steam_backlog_enforcer/tests/test_web_errors.py +++ b/steam_backlog_enforcer/tests/test_web_errors.py @@ -12,25 +12,32 @@ from steam_backlog_enforcer.jobs._errors import JobError class TestApiError: + """ApiError status lookup, body and Retry-After headers.""" + def test_status_comes_from_the_code_table(self) -> None: + """A known code takes its status from the table; str is code: message.""" err = _web_errors.ApiError("no", code="locked") assert err.status == HTTPStatus.CONFLICT assert str(err) == "locked: no" def test_unknown_code_is_a_bad_gateway(self) -> None: + """A code missing from the table maps to 502.""" err = _web_errors.ApiError("x", code="mystery") assert err.status == HTTPStatus.BAD_GATEWAY def test_explicit_status_wins(self) -> None: + """An explicit status overrides the table.""" err = _web_errors.ApiError("x", code="locked", status=HTTPStatus.GONE) assert err.status == HTTPStatus.GONE def test_body_and_headers_without_a_delay(self) -> None: + """Without a delay the body has code and message and there are no headers.""" err = _web_errors.ApiError("m", code="busy") assert err.body() == {"error": "busy", "message": "m"} assert err.headers() == {} def test_body_and_headers_with_a_delay(self) -> None: + """A delay adds retry_after_seconds and a rounded-up Retry-After.""" err = _web_errors.ApiError("m", code="rate_limited", retry_after=0.2) assert err.body()["retry_after_seconds"] == 0.2 assert err.headers() == {"Retry-After": "1"} @@ -39,29 +46,37 @@ class TestApiError: class TestConverters: + """Helpers that build ApiError from other error types.""" + def test_not_found(self) -> None: + """not_found builds a 404 not_found error.""" err = _web_errors.not_found("gone") assert (err.code, err.status) == ("not_found", HTTPStatus.NOT_FOUND) def test_from_job_error(self) -> None: + """A JobError keeps its code and message.""" err = _web_errors.from_job_error(JobError("busy now", code="busy")) assert (err.code, err.message) == ("busy", "busy now") def test_from_ctl_error_keeps_a_known_code_and_delay(self) -> None: + """A known CtlError code keeps its retry delay.""" exc = CtlError("rate_limited", "slow", {"retry_after_seconds": 30}) err = _web_errors.from_ctl_error(exc) assert (err.code, err.retry_after) == ("rate_limited", 30.0) def test_from_ctl_error_maps_unknown_codes_to_op_failed(self) -> None: + """An unknown CtlError code becomes op_failed with the code as message.""" err = _web_errors.from_ctl_error(CtlError("weird", "")) assert (err.code, err.message, err.retry_after) == ("op_failed", "weird", None) @pytest.mark.parametrize("retry", ["soon", None]) def test_from_ctl_error_ignores_a_non_numeric_delay(self, retry: object) -> None: + """A non-numeric retry delay is dropped.""" exc = CtlError("busy", "m", {"retry_after_seconds": retry}) assert _web_errors.from_ctl_error(exc).retry_after is None def test_daemon_unreachable(self) -> None: + """daemon_unreachable carries its code and the reason in the message.""" err = _web_errors.daemon_unreachable("no socket") assert err.code == "daemon_unreachable" assert "no socket" in err.message diff --git a/steam_backlog_enforcer/tests/test_web_io.py b/steam_backlog_enforcer/tests/test_web_io.py index 1bd4fe8..37489cc 100644 --- a/steam_backlog_enforcer/tests/test_web_io.py +++ b/steam_backlog_enforcer/tests/test_web_io.py @@ -27,15 +27,19 @@ class _Handler: self.status: int | None = None def send_response(self, status: int) -> None: + """Record the status line the code sends.""" self.status = status def send_header(self, name: str, value: str) -> None: + """Record a header the code sends.""" self.sent.append((name, value)) def end_headers(self) -> None: + """Mark the end of headers in the sent log.""" self.sent.append(("--", "end")) def as_handler(self) -> BaseHTTPRequestHandler: + """Expose the fake under the handler type the code expects.""" return cast("BaseHTTPRequestHandler", self) @@ -53,53 +57,69 @@ def _body_of(body: bytes, **headers: str) -> dict[str, Any]: class TestReplies: + """The reply helpers carry the right status codes.""" + def test_ok_and_no_content(self) -> None: + """ok() replies 200 and no_content() replies 204.""" assert _web_io.ok({"a": 1}).status == HTTPStatus.OK assert _web_io.no_content().status == HTTPStatus.NO_CONTENT class TestReadJsonBody: + """read_json_body accepts only a sized JSON object body.""" + def test_absent_body_is_empty(self) -> None: + """A request with no body reads as an empty object.""" assert _read({}) == {} def test_parses_an_object(self) -> None: + """A JSON object parses, whatever the content-type casing and parameters.""" got = _body_of(b'{"a": 1}', **{"Content-Type": "Application/JSON; x=y"}) assert got == {"a": 1} def test_chunked_needs_a_length(self) -> None: + """A chunked body without a length is refused with 411.""" with pytest.raises(ApiError) as err: _body_of(b"", **{"Transfer-Encoding": "chunked"}) assert err.value.status == HTTPStatus.LENGTH_REQUIRED def test_non_numeric_length(self) -> None: + """A non-numeric Content-Length is refused with 400.""" with pytest.raises(ApiError) as err: _read({"Content-Length": "x"}) assert err.value.status == HTTPStatus.BAD_REQUEST def test_oversized_body(self) -> None: + """A body over MAX_BODY_BYTES is refused with 413 before it is read.""" with pytest.raises(ApiError) as err: _read({"Content-Length": str(_web_io.MAX_BODY_BYTES + 1)}) assert err.value.status == HTTPStatus.REQUEST_ENTITY_TOO_LARGE def test_wrong_content_type(self) -> None: + """A non-JSON content type is refused with 415.""" with pytest.raises(ApiError) as err: _body_of(b"{}", **{"Content-Type": "text/plain"}) assert err.value.status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE def test_missing_content_type(self) -> None: + """A body with no content type is refused with 415.""" with pytest.raises(ApiError) as err: _read({"Content-Length": "2"}, b"{}") assert err.value.status == HTTPStatus.UNSUPPORTED_MEDIA_TYPE @pytest.mark.parametrize("raw", [b"{nope", b"[1]"]) def test_malformed_or_non_object(self, raw: bytes) -> None: + """Malformed JSON or a non-object body is refused as invalid_params.""" with pytest.raises(ApiError) as err: _body_of(raw) assert err.value.code == "invalid_params" class TestSending: + """Replies are written with the right status, headers and body.""" + def test_send_bytes_with_and_without_a_body(self) -> None: + """send_bytes writes body, length and extra headers; nothing when empty.""" handler = _Handler({}) _web_io.send_bytes( handler.as_handler(), HTTPStatus.OK, b"hi", "text/plain", {"X-A": "1"} @@ -112,6 +132,7 @@ class TestSending: assert empty.wfile.getvalue() == b"" def test_raw_reply(self) -> None: + """A raw reply is sent as-is with its own content type.""" handler = _Handler({}) reply = _web_io.Reply(HTTPStatus.OK, raw=b"\xff", ctype="image/jpeg") _web_io.send_reply(handler.as_handler(), reply) @@ -119,6 +140,7 @@ class TestSending: assert ("Content-Type", "image/jpeg") in handler.sent def test_payloadless_reply_has_no_content_type(self) -> None: + """A reply with no payload sends a zero length and no content type.""" handler = _Handler({}) _web_io.send_reply(handler.as_handler(), _web_io.no_content()) assert handler.status == HTTPStatus.NO_CONTENT @@ -126,12 +148,14 @@ class TestSending: assert "Content-Type" not in [name for name, _ in handler.sent] def test_json_reply(self) -> None: + """A JSON reply is serialised and marked no-store.""" handler = _Handler({}) _web_io.send_reply(handler.as_handler(), _web_io.ok({"a": 1})) assert json.loads(handler.wfile.getvalue()) == {"a": 1} assert ("Cache-Control", "no-store") in handler.sent def test_error_reply(self) -> None: + """An ApiError is sent with its HTTP status, Retry-After and error code.""" handler = _Handler({}) error = ApiError("m", code="rate_limited", retry_after=3) _web_io.send_error(handler.as_handler(), error) diff --git a/steam_backlog_enforcer/tests/test_web_jobs.py b/steam_backlog_enforcer/tests/test_web_jobs.py index 191fe4f..cb7b1bc 100644 --- a/steam_backlog_enforcer/tests/test_web_jobs.py +++ b/steam_backlog_enforcer/tests/test_web_jobs.py @@ -12,6 +12,7 @@ from steam_backlog_enforcer import _web_jobs from steam_backlog_enforcer._backups import StateBackup from steam_backlog_enforcer._web_errors import ApiError from steam_backlog_enforcer._web_io import Reply +from steam_backlog_enforcer._web_jobs import _enforce_params, _job_request from steam_backlog_enforcer.jobs._errors import JobError from steam_backlog_enforcer.tests._web_request import make_request, payload_of @@ -24,6 +25,8 @@ def _create(body: dict[str, Any]) -> Reply: class TestEnforceParams: + """Translating enforce params from the web form to job params.""" + @pytest.mark.parametrize( ("params", "expected"), [ @@ -33,23 +36,29 @@ class TestEnforceParams: ], ) def test_translation(self, params: dict[str, Any], expected: object) -> None: - assert _web_jobs._enforce_params(params) == expected + """Each accepted demo/mode spelling maps to the demo flag.""" + assert _enforce_params(params) == expected @pytest.mark.parametrize("params", [{}, {"mode": "x"}, {"mode": "demo", "z": 1}]) def test_bad_mode(self, params: dict[str, Any]) -> None: + """Missing, unknown or extra params are refused as invalid_params.""" with pytest.raises(ApiError) as err: - _web_jobs._enforce_params(params) + _enforce_params(params) assert err.value.code == "invalid_params" class TestJobRequest: + """Parsing the create-job request body.""" + def test_defaults_to_empty_params(self) -> None: - got = _web_jobs._job_request(make_request(body={"command": "scan"})) + """A body with only a command yields empty params and no phrase.""" + got = _job_request(make_request(body={"command": "scan"})) assert got == ("scan", {}, None) def test_enforce_params_are_translated(self) -> None: + """Enforce params are translated while parsing the body.""" body = {"command": "enforce", "params": {"mode": "demo"}} - assert _web_jobs._job_request(make_request(body=body))[1] == {"demo": 1} + assert _job_request(make_request(body=body))[1] == {"demo": 1} @pytest.mark.parametrize( "body", @@ -61,39 +70,50 @@ class TestJobRequest: ], ) def test_malformed(self, body: dict[str, Any]) -> None: + """Bodies with a bad command, params or phrase type are invalid_params.""" with pytest.raises(ApiError) as err: - _web_jobs._job_request(make_request(body=body)) + _job_request(make_request(body=body)) assert err.value.code == "invalid_params" class TestReadRoutes: + """The read-only job listing and detail routes.""" + def test_jobs_view(self) -> None: + """The jobs view returns the job list as its payload.""" with patch(f"{_PKG}.list_jobs", return_value=[{"id": "a"}]): assert _web_jobs.jobs_view(make_request()).payload == [{"id": "a"}] def test_job_view(self) -> None: + """The job view returns the job read for the path id.""" with patch(f"{_PKG}.read_job", return_value={"id": "a"}) as read: assert _web_jobs.job_view(make_request("a")).payload == {"id": "a"} read.assert_called_once_with("a") def test_unknown_job(self) -> None: + """An unknown job id is not_found.""" with pytest.raises(ApiError) as err: _web_jobs.job_view(make_request("nope")) assert err.value.code == "not_found" class TestCreateJob: + """Creating jobs through the web route.""" + def test_unknown_command(self) -> None: + """A command that is not registered is unknown_command.""" with pytest.raises(ApiError) as err: _create({"command": "rm-rf"}) assert err.value.code == "unknown_command" def test_invalid_params(self) -> None: + """Params the command does not accept are invalid_params.""" with pytest.raises(ApiError) as err: _create({"command": "scan", "params": {"bogus": 1}}) assert err.value.code == "invalid_params" def test_privileged_goes_to_the_daemon(self) -> None: + """A privileged command is forwarded to the daemon unchanged.""" sentinel = Reply(HTTPStatus.ACCEPTED) with patch(f"{_PKG}.run_privileged", return_value=sentinel) as run: got = _create({"command": "gaming-reset", "confirm_phrase": "p"}) @@ -101,6 +121,7 @@ class TestCreateJob: run.assert_called_once_with("gaming-reset", {}, "p") def test_plain_job_is_created(self) -> None: + """A plain command creates a job and answers 201 with it.""" with ( patch(f"{_PKG}.create_job", return_value="j1") as create, patch(f"{_PKG}.read_job", return_value={"id": "j1"}), @@ -110,6 +131,7 @@ class TestCreateJob: assert (got.status, got.payload) == (HTTPStatus.CREATED, {"id": "j1"}) def test_store_refusal(self) -> None: + """A job store refusal surfaces its error code.""" with ( patch(f"{_PKG}.create_job", side_effect=JobError("m", code="busy")), pytest.raises(ApiError) as err, @@ -119,7 +141,10 @@ class TestCreateJob: class TestAnswerAndCancel: + """Answering a job prompt and cancelling a job.""" + def test_answer(self) -> None: + """A valid answer is written and answers 204.""" body = {"prompt_id": "q", "value": "v"} with patch(f"{_PKG}.write_answer") as write: reply = _web_jobs.answer_view(make_request("j", body=body)) @@ -128,29 +153,35 @@ class TestAnswerAndCancel: @pytest.mark.parametrize("body", [{}, {"prompt_id": "q", "value": 1}]) def test_answer_body_is_validated(self, body: dict[str, Any]) -> None: + """A missing prompt id or non-string value is invalid_params.""" with pytest.raises(ApiError) as err: _web_jobs.answer_view(make_request("j", body=body)) assert err.value.code == "invalid_params" def test_answer_refusal(self) -> None: + """Answering a missing job is not_found.""" body = {"prompt_id": "q", "value": "v"} with pytest.raises(ApiError) as err: _web_jobs.answer_view(make_request("missing", body=body)) assert err.value.code == "not_found" def test_cancel(self) -> None: + """Cancelling a job calls cancel and answers 204.""" with patch(f"{_PKG}.cancel") as cancel: reply = _web_jobs.cancel_view(make_request("j")) cancel.assert_called_once_with("j") assert reply.status == HTTPStatus.NO_CONTENT def test_cancel_refusal(self) -> None: + """Cancelling a missing job is not_found.""" with pytest.raises(ApiError) as err: _web_jobs.cancel_view(make_request("missing")) assert err.value.code == "not_found" class TestRestoreBackup: + """Restoring a state backup through a confirmed job.""" + def _restore(self, phrase: str, backup_id: str = _BACKUP.id) -> Reply: with ( patch(f"{_PKG}.list_backups", return_value=[_BACKUP]), @@ -166,10 +197,12 @@ class TestRestoreBackup: return reply def test_restores_with_the_exact_phrase(self) -> None: + """The exact restore phrase creates the restore job.""" reply = self._restore(f"restore backup {_BACKUP.id}") assert payload_of(reply) == {"id": "j1"} def test_unknown_backup(self) -> None: + """An unknown backup id is not_found.""" with ( patch(f"{_PKG}.list_backups", return_value=[]), pytest.raises(ApiError) as err, @@ -178,6 +211,7 @@ class TestRestoreBackup: assert err.value.code == "not_found" def test_wrong_phrase(self) -> None: + """A missing or wrong phrase is wrong_phrase.""" with ( patch(f"{_PKG}.list_backups", return_value=[_BACKUP]), pytest.raises(ApiError) as err, @@ -186,6 +220,7 @@ class TestRestoreBackup: assert err.value.code == "wrong_phrase" def test_store_refusal(self) -> None: + """A job store refusal on restore surfaces its error code.""" phrase = f"restore backup {_BACKUP.id}" with ( patch(f"{_PKG}.list_backups", return_value=[_BACKUP]), diff --git a/steam_backlog_enforcer/tests/test_web_library.py b/steam_backlog_enforcer/tests/test_web_library.py index da806f8..0bbda4f 100644 --- a/steam_backlog_enforcer/tests/test_web_library.py +++ b/steam_backlog_enforcer/tests/test_web_library.py @@ -10,6 +10,16 @@ import pytest from steam_backlog_enforcer import _web_library from steam_backlog_enforcer import config as _config +from steam_backlog_enforcer._web_library import ( + _hltb_hours, + _Memos, + _owned_records, + _reason, + _snapshot_games, + library_rows, + library_view, + owned_app_ids, +) from steam_backlog_enforcer.config import Config, State from steam_backlog_enforcer.steam_api import GameInfo from steam_backlog_enforcer.tests._web_request import make_request, payload_of @@ -20,7 +30,7 @@ _PKG = "steam_backlog_enforcer._web_library" @pytest.fixture(autouse=True) def _fresh_memos(monkeypatch: pytest.MonkeyPatch) -> None: """The module keeps process-wide memos; every test starts without them.""" - monkeypatch.setattr(_web_library, "_MEMOS", _web_library._Memos()) + monkeypatch.setattr(_web_library, "_MEMOS", _Memos()) def _write_snapshot(rows: list[dict[str, Any]]) -> None: @@ -32,10 +42,14 @@ def _row(app_id: int, **extra: object) -> dict[str, Any]: class TestSnapshotGames: + """Snapshot rows become GameInfo by app id, memoised per file.""" + def test_no_snapshot_file(self) -> None: - assert _web_library._snapshot_games() == {} + """No snapshot file gives no games.""" + assert _snapshot_games() == {} def test_rows_become_games_and_are_memoised(self) -> None: + """Rows with an id become games and a second call reuses them.""" _write_snapshot( [ _row(1, total_achievements=10, unlocked_achievements=4), @@ -43,106 +57,128 @@ class TestSnapshotGames: _row(2, completionist_hours=7.5, playtime_minutes=30), ] ) - games = _web_library._snapshot_games() + games = _snapshot_games() assert sorted(games) == [1, 2] assert games[1].total_achievements == 10 assert games[2].completionist_hours == 7.5 assert games[1].completionist_hours == -1 with patch(f"{_PKG}.load_snapshot") as load: - assert _web_library._snapshot_games() is games + assert _snapshot_games() is games load.assert_not_called() def test_a_changed_file_is_reparsed(self) -> None: + """A rewritten snapshot is parsed again.""" _write_snapshot([_row(1)]) - assert sorted(_web_library._snapshot_games()) == [1] + assert sorted(_snapshot_games()) == [1] _write_snapshot([_row(1), _row(2, name=None)]) - got = _web_library._snapshot_games() + got = _snapshot_games() assert sorted(got) == [1, 2] assert got[2].name == "" def test_missing_snapshot_content_is_empty(self) -> None: + """An unloadable snapshot gives no games.""" _write_snapshot([_row(1)]) with patch(f"{_PKG}.load_snapshot", return_value=None): - assert _web_library._snapshot_games() == {} + assert _snapshot_games() == {} class TestOwnedRecords: + """Owned-game records come from the cache, else one Steam fetch.""" + def test_cached_records(self) -> None: + """Cached records are keyed by app id, id-less ones dropped.""" records = [{"app_id": 1}, {"name": "no id"}] with patch(f"{_PKG}.load_owned_records", return_value=records): - got = _web_library._owned_records(Config(steam_id="s")) + got = _owned_records(Config(steam_id="s")) assert got == {1: {"app_id": 1}} def test_fetched_once_when_nothing_is_cached(self) -> None: + """With no cache the records are fetched from Steam.""" with ( patch(f"{_PKG}.load_owned_records", return_value=None), patch(f"{_PKG}.refresh_owned_records", return_value=[{"app_id": 2}]), ): - assert list(_web_library._owned_records(Config())) == [2] + assert list(_owned_records(Config())) == [2] def test_steam_unreachable_is_empty(self) -> None: + """No cache and no Steam gives no records.""" with ( patch(f"{_PKG}.load_owned_records", return_value=None), patch(f"{_PKG}.refresh_owned_records", return_value=None), ): - assert _web_library._owned_records(Config()) == {} + assert _owned_records(Config()) == {} class TestHltbHours: + """HLTB hours come from the HLTB cache, then the snapshot.""" + def test_prefers_the_hltb_cache(self) -> None: + """The HLTB cache wins and is rounded to one decimal.""" game = GameInfo(1, "g", 1, 0, 0, completionist_hours=9.0) - assert _web_library._hltb_hours(1, game, {1: {"hours": 12.34}}) == 12.3 + assert _hltb_hours(1, game, {1: {"hours": 12.34}}) == 12.3 def test_falls_back_to_the_snapshot(self) -> None: + """Without a cache entry the snapshot hours are used.""" game = GameInfo(1, "g", 1, 0, 0, completionist_hours=9.0) - assert _web_library._hltb_hours(1, game, {}) == 9.0 + assert _hltb_hours(1, game, {}) == 9.0 def test_unknown_without_either(self) -> None: - assert _web_library._hltb_hours(1, None, {}) is None + """No cache entry and no known snapshot hours give None.""" + assert _hltb_hours(1, None, {}) is None game = GameInfo(1, "g", 1, 0, 0) - assert _web_library._hltb_hours(1, game, {}) is None + assert _hltb_hours(1, game, {}) is None class TestOwnedAppIds: + """Owned ids are the snapshot and record ids, memoised by cache stamp.""" + def test_union_of_snapshot_and_records_memoised_by_stamp(self) -> None: + """An unchanged stamp reuses the union without reloading.""" _write_snapshot([_row(1)]) records = [{"app_id": 2}, {"x": 1}] with ( patch(f"{_PKG}.owned_cache_stamp", return_value=(1, 1)), patch(f"{_PKG}.load_owned_records", return_value=records) as load, ): - assert _web_library.owned_app_ids() == {1, 2} - assert _web_library.owned_app_ids() == {1, 2} + assert owned_app_ids() == {1, 2} + assert owned_app_ids() == {1, 2} load.assert_called_once() def test_a_new_stamp_reloads_and_none_means_empty(self) -> None: + """A new stamp reloads, and no records means no ids.""" with ( patch(f"{_PKG}.owned_cache_stamp", side_effect=[(1, 1), (2, 2)]), patch(f"{_PKG}.load_owned_records", side_effect=[[{"app_id": 5}], None]), ): - assert _web_library.owned_app_ids() == {5} - assert _web_library.owned_app_ids() == set() + assert owned_app_ids() == {5} + assert owned_app_ids() == set() class TestReason: + """Why a game cannot be picked, if anything.""" + def test_no_achievements_flag(self) -> None: - got = _web_library._reason(1, None, {"has_stats": False}, State(), set()) + """A game without stats has no achievements.""" + got = _reason(1, None, {"has_stats": False}, State(), set()) assert got == "No achievements" def test_not_scanned_yet(self) -> None: - got = _web_library._reason(1, None, {"has_stats": True}, State(), set()) + """A game with stats but no snapshot row is not scanned yet.""" + got = _reason(1, None, {"has_stats": True}, State(), set()) assert got == "Not scanned yet" def test_otherwise_the_pick_rule(self) -> None: - assert _web_library._reason(1, None, {}, State(), set()) == ( - "No achievement data" - ) + """Otherwise the pick eligibility rule decides.""" + assert _reason(1, None, {}, State(), set()) == ("No achievement data") game = GameInfo(1, "g", 2, 0, 0) - assert _web_library._reason(1, game, {}, State(), set()) is None + assert _reason(1, game, {}, State(), set()) is None class TestLibraryRows: + """Library rows join snapshot, records, HLTB, installs and allowed games.""" + def test_joins_every_source(self) -> None: + """Every field of a row comes from its own source.""" _write_snapshot( [_row(1, total_achievements=4, unlocked_achievements=1, playtime_minutes=5)] ) @@ -156,9 +192,7 @@ class TestLibraryRows: patch(f"{_PKG}.get_installed_games", return_value=[(1, "One")]), patch(f"{_PKG}.allowed_app_ids", return_value={1}), ): - rows = { - r["app_id"]: r for r in _web_library.library_rows(State(), Config()) - } + rows = {r["app_id"]: r for r in library_rows(State(), Config())} assert rows[1] == { "app_id": 1, "name": "One", @@ -176,16 +210,20 @@ class TestLibraryRows: assert rows[2]["ineligible_reason"] == "No achievements" def test_snapshot_only_game_uses_its_own_name_and_playtime(self) -> None: + """A game known only to the snapshot uses its name and playtime.""" _write_snapshot([_row(3, playtime_minutes=8)]) with ( patch(f"{_PKG}.load_owned_records", return_value=[]), patch(f"{_PKG}._read_raw_cache", return_value={}), patch(f"{_PKG}.get_installed_games", return_value=[]), ): - (row,) = _web_library.library_rows(State(), Config()) + rows = list(library_rows(State(), Config())) + assert len(rows) == 1 + row = rows[0] assert (row["name"], row["playtime_minutes"]) == ("G3", 8) def test_view_wraps_the_rows(self) -> None: + """The view returns the rows under a games key.""" with patch(f"{_PKG}.library_rows", return_value=[{"app_id": 1}]): - reply = _web_library.library_view(make_request()) + reply = library_view(make_request()) assert payload_of(reply) == {"games": [{"app_id": 1}]} diff --git a/steam_backlog_enforcer/tests/test_web_privileged.py b/steam_backlog_enforcer/tests/test_web_privileged.py index 120ae8a..6ce71eb 100644 --- a/steam_backlog_enforcer/tests/test_web_privileged.py +++ b/steam_backlog_enforcer/tests/test_web_privileged.py @@ -11,6 +11,7 @@ from steam_backlog_enforcer import _web_privileged from steam_backlog_enforcer._ctl_protocol import CtlError from steam_backlog_enforcer._ctl_types import BlockResult, PendingReset, StoreWindow from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_privileged import _local_hhmm from steam_backlog_enforcer.jobs._view import list_jobs from steam_backlog_enforcer.tests._web_request import payload_of @@ -18,21 +19,29 @@ _CLIENT = "steam_backlog_enforcer._web_privileged._ctl_client" class TestLocalHhmm: + """Formatting an ISO time as local HH:MM.""" + @pytest.mark.parametrize("iso", [None, ""]) def test_missing_is_a_question_mark(self, iso: str | None) -> None: - assert _web_privileged._local_hhmm(iso) == "?" + """No timestamp renders as ``?``.""" + assert _local_hhmm(iso) == "?" def test_unparsable_is_returned_as_is(self) -> None: - assert _web_privileged._local_hhmm("soon") == "soon" + """Text that is not ISO comes back unchanged.""" + assert _local_hhmm("soon") == "soon" def test_parsable_is_hh_mm(self) -> None: - got = _web_privileged._local_hhmm("2026-01-01T10:30:00+00:00") + """A valid ISO time renders as five-char HH:MM.""" + got = _local_hhmm("2026-01-01T10:30:00+00:00") assert len(got) == 5 assert got[2] == ":" class TestRunPrivileged: + """Dispatching privileged commands to the root daemon.""" + def test_gaming_reset_arms_and_answers_202(self) -> None: + """gaming-reset arms a pending reset, answers 202, records no job.""" pending = PendingReset("p1", "phrase", "t0", "t1", 5) with patch(f"{_CLIENT}.gaming_reset_arm", return_value=pending) as arm: reply = _web_privileged.run_privileged("gaming-reset", {}, None) @@ -42,6 +51,7 @@ class TestRunPrivileged: assert list_jobs() == [] def test_unblock_records_a_job(self) -> None: + """unblock forwards minutes and phrase, answers 201, records a job.""" window = StoreWindow(10, "2026-01-01T10:30:00+00:00") with patch(f"{_CLIENT}.store_unblock", return_value=window) as unblock: reply = _web_privileged.run_privileged("unblock", {"minutes": 10}, "p") @@ -51,33 +61,39 @@ class TestRunPrivileged: assert [j["id"] for j in list_jobs()] == [payload_of(reply)["id"]] def test_buy_dlc_uses_the_default_window(self) -> None: + """buy-dlc unblocks the store for the default DLC window.""" window = StoreWindow(_web_privileged.BUY_DLC_MINUTES, "x") with patch(f"{_CLIENT}.store_unblock", return_value=window) as unblock: _web_privileged.run_privileged("buy-dlc", {}, None) unblock.assert_called_once_with(_web_privileged.BUY_DLC_MINUTES, "") def test_block_gaming(self) -> None: + """block-gaming summarises the blocked days.""" with patch(f"{_CLIENT}.block_gaming", return_value=BlockResult(3, None)): reply = _web_privileged.run_privileged("block-gaming", {"days": 3}, "p") assert "3 day(s)" in payload_of(reply)["summary"] def test_gaming_unblock(self) -> None: + """gaming-unblock summarises the released mounts.""" with patch(f"{_CLIENT}.gaming_unblock", return_value=["a", "b"]): reply = _web_privileged.run_privileged("gaming-unblock", {}, "p") assert "2 playtime mount(s)" in payload_of(reply)["summary"] def test_enforce_restart(self) -> None: + """enforce restarts the daemon.""" with patch(f"{_CLIENT}.restart", return_value="t") as restart: reply = _web_privileged.run_privileged("enforce", {"demo": 0}, None) restart.assert_called_once_with() assert "restarting" in payload_of(reply)["summary"] def test_unknown_command(self) -> None: + """A command with no privileged route is unknown_command.""" with pytest.raises(ApiError) as err: _web_privileged.run_privileged("scan", {}, None) assert err.value.code == "unknown_command" def test_failed_op_is_recorded_then_raised(self) -> None: + """An op failure is recorded as a failed job, then raised.""" with ( patch(f"{_CLIENT}.restart", side_effect=CtlError("op_failed", "boom")), pytest.raises(ApiError) as err, @@ -88,6 +104,7 @@ class TestRunPrivileged: assert (job["summary"], job["exit_code"]) == ("boom", 1) def test_refusal_leaves_no_job(self) -> None: + """A daemon refusal is raised and records no job.""" with ( patch(f"{_CLIENT}.gaming_unblock", side_effect=CtlError("locked", "no")), pytest.raises(ApiError) as err, diff --git a/steam_backlog_enforcer/tests/test_web_process.py b/steam_backlog_enforcer/tests/test_web_process.py index 340629a..281cd7e 100644 --- a/steam_backlog_enforcer/tests/test_web_process.py +++ b/steam_backlog_enforcer/tests/test_web_process.py @@ -12,6 +12,7 @@ import pytest from steam_backlog_enforcer import _web_process from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_process import _RESTART_DELAY_SECONDS, _server from steam_backlog_enforcer.tests._web_request import make_request, payload_of if TYPE_CHECKING: @@ -25,20 +26,24 @@ _PKG = "steam_backlog_enforcer._web_process" def _clean_process_state() -> Iterator[None]: """Leave neither a retiring flag, a registered server nor a cached sha.""" _web_process.RETIRING.clear() - _web_process._server.clear() + _server.clear() _web_process.version.cache_clear() yield _web_process.RETIRING.clear() - _web_process._server.clear() + _server.clear() _web_process.version.cache_clear() class TestRetire: + """Retiring shuts the registered server down exactly once.""" + def test_without_a_server_it_does_nothing(self) -> None: + """With no server registered, retire is a no-op.""" _web_process.retire() assert not _web_process.RETIRING.is_set() def test_shuts_the_server_down_once(self) -> None: + """Retire sets RETIRING and a second retire does not shut down again.""" stopped = threading.Event() server = MagicMock() server.shutdown.side_effect = stopped.set @@ -50,14 +55,18 @@ class TestRetire: server.shutdown.assert_called_once_with() def test_register_replaces_the_previous_server(self) -> None: + """Only the last registered server is kept.""" first, second = MagicMock(), MagicMock() _web_process.register(first) _web_process.register(second) - assert _web_process._server == [second] + assert _server == [second] class TestVersion: + """The served version: git sha, else newest-source time, else unknown.""" + def test_git_short_sha(self) -> None: + """Inside git the version is the short sha.""" done = subprocess.CompletedProcess([], 0, stdout="abc1234\n") with ( patch(f"{_PKG}.shutil.which", return_value="/usr/bin/git"), @@ -71,6 +80,7 @@ class TestVersion: def test_git_failure_falls_back_to_the_newest_source( self, failure: Exception, tmp_path: Path ) -> None: + """A git failure falls back to the newest source file's UTC time.""" source = tmp_path / "a.py" source.write_text("", encoding="utf-8") with ( @@ -82,6 +92,7 @@ class TestVersion: assert got.endswith("Z") def test_no_git_and_no_sources(self) -> None: + """Without git or sources the version is unknown.""" with ( patch(f"{_PKG}.shutil.which", return_value=None), patch(f"{_PKG}.newest_py_after", return_value=None), @@ -90,8 +101,11 @@ class TestVersion: class TestHealth: + """The health payload reports staleness, version and start time.""" + @pytest.mark.parametrize(("outdated", "stale"), [(None, False), ("x.py", True)]) def test_health_payload(self, outdated: str | None, *, stale: bool) -> None: + """An outdated source marks the server stale.""" with ( patch(f"{_PKG}.outdated_source", return_value=outdated), patch(f"{_PKG}.version", return_value="v1"), @@ -103,9 +117,12 @@ class TestHealth: class TestRestartView: + """Restart is only offered under a supervisor.""" + def test_refused_without_a_supervisor( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """Without systemd the restart is refused as unsupported.""" monkeypatch.delenv("INVOCATION_ID", raising=False) with pytest.raises(ApiError) as err: _web_process.restart_view(make_request(method="POST")) @@ -114,8 +131,9 @@ class TestRestartView: def test_under_systemd_it_retires_after_the_reply( self, monkeypatch: pytest.MonkeyPatch ) -> None: + """Under systemd the server accepts and retires after a delay.""" monkeypatch.setenv("INVOCATION_ID", "abc") with patch(f"{_PKG}.retire") as retire: reply = _web_process.restart_view(make_request(method="POST")) - retire.assert_called_once_with(_web_process._RESTART_DELAY_SECONDS) + retire.assert_called_once_with(_RESTART_DELAY_SECONDS) assert reply.status == HTTPStatus.ACCEPTED diff --git a/steam_backlog_enforcer/tests/test_web_routes.py b/steam_backlog_enforcer/tests/test_web_routes.py index d4ee767..4f6d854 100644 --- a/steam_backlog_enforcer/tests/test_web_routes.py +++ b/steam_backlog_enforcer/tests/test_web_routes.py @@ -8,17 +8,22 @@ from steam_backlog_enforcer import _web_jobs, _web_routes, _web_views class TestResolve: + """Resolving a method and path to a view and its captures.""" + def test_static_route(self) -> None: + """A static path resolves to its view with no captures.""" assert _web_routes.resolve("GET", "/api/budget") == ( _web_views.budget_view, (), ) def test_captures_the_path_id(self) -> None: + """A path with an id resolves to its view with the id captured.""" found = _web_routes.resolve("POST", "/api/jobs/j-1_A/cancel") assert found == (_web_jobs.cancel_view, ("j-1_A",)) def test_same_path_resolves_per_method(self) -> None: + """One path resolves to a different view per method.""" assert _web_routes.resolve("GET", "/api/jobs") == (_web_jobs.jobs_view, ()) assert _web_routes.resolve("POST", "/api/jobs") == ( _web_jobs.create_job_view, @@ -26,15 +31,20 @@ class TestResolve: ) def test_known_path_under_another_method(self) -> None: + """A known path under an unsupported method resolves to "method".""" assert _web_routes.resolve("DELETE", "/api/budget") == "method" @pytest.mark.parametrize("path", ["/api/nope", "/api/jobs/a b", "/api/jobs/"]) def test_unknown_path(self, path: str) -> None: + """Unknown or malformed paths resolve to None.""" assert _web_routes.resolve("GET", path) is None class TestEventsPath: + """The job event-stream path pattern.""" + def test_matches_only_the_stream(self) -> None: + """The pattern matches only the events path and captures the job id.""" match = _web_routes.EVENTS_PATH.fullmatch("/api/jobs/abc/events") assert match is not None assert match.groups() == ("abc",) diff --git a/steam_backlog_enforcer/tests/test_web_server.py b/steam_backlog_enforcer/tests/test_web_server.py index 5accd94..e554785 100644 --- a/steam_backlog_enforcer/tests/test_web_server.py +++ b/steam_backlog_enforcer/tests/test_web_server.py @@ -67,6 +67,7 @@ class TestBudgetEndpoint: """Tests for the /api/budget route.""" def test_budget_ok(self) -> None: + """/api/budget returns the production snapshot as JSON.""" with ( patch( f"{_VIEWS_PKG}.build_budget_snapshot", return_value={"ok": True} @@ -81,6 +82,7 @@ class TestBudgetEndpoint: assert build.call_args.kwargs == {"demo": False} def test_demo_query_reads_the_demo_run(self) -> None: + """?demo=1 builds the budget snapshot from the demo run.""" with ( patch( f"{_VIEWS_PKG}.build_budget_snapshot", return_value={"ok": True} @@ -91,6 +93,7 @@ class TestBudgetEndpoint: assert build.call_args.kwargs == {"demo": True} def test_budget_error(self) -> None: + """A failure building the snapshot returns 500 op_failed.""" with ( patch(f"{_VIEWS_PKG}.build_budget_snapshot", side_effect=OSError("boom")), _running() as port, @@ -104,6 +107,7 @@ class TestDatasetEndpoint: """Tests for the /api/dataset route.""" def test_dataset_ok(self) -> None: + """/api/dataset returns the games dataset as UTF-8 JSON.""" with ( patch(f"{_DATASET_PKG}.load_snapshot", return_value=None), patch(f"{_DATA_PKG}._read_raw_cache", return_value={}), @@ -117,6 +121,7 @@ class TestDatasetEndpoint: assert "games" in json.loads(body) def test_dataset_error_returns_500(self) -> None: + """A failure building the dataset returns 500 op_failed.""" with ( patch(f"{_VIEWS_PKG}.build_web_dataset", side_effect=OSError("boom")), _running() as port, @@ -130,6 +135,7 @@ class TestStaticServing: """Tests for static-file serving, the 404 page and the traversal guard.""" def test_serves_index(self, tmp_path: Path) -> None: + """/ serves index.html as HTML.""" dist = _make_dist(tmp_path) with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: status, body, ctype = _get(port, "/") @@ -138,6 +144,7 @@ class TestStaticServing: assert "text/html" in ctype def test_serves_js_with_charset(self, tmp_path: Path) -> None: + """JavaScript is served with a UTF-8 charset.""" dist = _make_dist(tmp_path, files={"app.js": b"console.log(1)"}) with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: status, _, ctype = _get(port, "/app.js") @@ -145,6 +152,7 @@ class TestStaticServing: assert "charset=utf-8" in ctype def test_serves_binary_without_charset(self, tmp_path: Path) -> None: + """Binary files are served without a charset.""" dist = _make_dist(tmp_path, files={"pic.png": b"\x89PNG\r\n"}) with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: status, _, ctype = _get(port, "/pic.png") @@ -153,6 +161,7 @@ class TestStaticServing: assert "charset" not in ctype def test_unknown_route_gets_the_404_page(self, tmp_path: Path) -> None: + """An unknown route gets the built 404 page.""" dist = _make_dist(tmp_path, files={"404.html": b"MISSING"}) with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: status, body, _ = _get(port, "/some/unknown/route") @@ -160,6 +169,7 @@ class TestStaticServing: assert b"MISSING" in body def test_path_traversal_blocked(self, tmp_path: Path) -> None: + """A traversal path gets the 404 page, never a file outside dist.""" dist = _make_dist(tmp_path, files={"404.html": b"MISSING"}) with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: status, body, _ = _get(port, "/../../../../../../etc/passwd") @@ -168,6 +178,7 @@ class TestStaticServing: assert b"root:" not in body def test_not_built_returns_404(self, tmp_path: Path) -> None: + """Without a built index the server answers 404 saying it is not built.""" dist = _make_dist(tmp_path, with_index=False) with patch(f"{_PKG}.WEB_DIST", dist), _running() as port: status, body, _ = _get(port, "/") @@ -179,6 +190,7 @@ class TestCreateServer: """Tests for create_server.""" def test_binds_localhost(self) -> None: + """create_server binds the requested localhost address.""" server = create_server("127.0.0.1", 0) try: assert server.server_address[0] == "127.0.0.1" @@ -190,6 +202,7 @@ class TestServe: """Tests for the blocking serve() entry point.""" def test_keyboard_interrupt_shuts_down(self) -> None: + """Ctrl-C stops serve() and the server is still closed.""" fake = MagicMock() fake.serve_forever.side_effect = KeyboardInterrupt with ( @@ -201,6 +214,7 @@ class TestServe: fake.server_close.assert_called_once() def test_normal_return_closes_server(self) -> None: + """A normal return from serve_forever closes the server.""" fake = MagicMock() with ( patch(f"{_PKG}.create_server", return_value=fake), diff --git a/steam_backlog_enforcer/tests/test_web_server_routing.py b/steam_backlog_enforcer/tests/test_web_server_routing.py index f6dac33..69c43a7 100644 --- a/steam_backlog_enforcer/tests/test_web_server_routing.py +++ b/steam_backlog_enforcer/tests/test_web_server_routing.py @@ -11,10 +11,12 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer import _web_process, _web_server +from steam_backlog_enforcer import _web_server from steam_backlog_enforcer._web_io import Reply, Request, ok +from steam_backlog_enforcer._web_server import _STALE_MSG from steam_backlog_enforcer.jobs._daemon_jobs import DaemonOutcome, record_daemon_job from steam_backlog_enforcer.jobs._events import now_iso +from steam_backlog_enforcer.tests._web_process_state import clean_process_state if TYPE_CHECKING: from collections.abc import Iterator @@ -26,11 +28,7 @@ _PKG = "steam_backlog_enforcer._web_server" @pytest.fixture(autouse=True) def _clean_process_state() -> Iterator[None]: """No retiring flag, and never "stale": other edits to the tree must not leak in.""" - _web_process.RETIRING.clear() - with patch(f"{_PKG}.outdated_source", return_value=None): - yield - _web_process.RETIRING.clear() - _web_process._server.clear() + yield from clean_process_state() @contextmanager @@ -85,33 +83,42 @@ def _error_of(raw: bytes) -> str: class TestAuthOrder: + """Host, then Origin, then token are checked before anything is served.""" + def test_foreign_host_is_refused_even_for_static(self) -> None: + """A foreign Host header is refused even for static files.""" with _running() as server: status, raw, _ = _call(server, "GET", "/", headers={"Host": "evil:1"}) assert (status, _error_of(raw)) == (403, "bad_host") def test_a_post_needs_the_origin(self) -> None: + """A POST without the same-origin header is refused.""" with _running() as server: status, raw, _ = _call(server, "POST", "/api/jobs", body={}) assert (status, _error_of(raw)) == (403, "bad_origin") def test_a_post_needs_the_token(self) -> None: + """A POST without the launch token is refused.""" with _running() as server: origin = {"Origin": f"http://127.0.0.1:{server.port}"} status, raw, _ = _call(server, "POST", "/api/jobs", headers=origin) assert (status, _error_of(raw)) == (403, "bad_token") def test_events_need_the_token_in_the_query(self) -> None: + """The event stream needs the token in its query.""" with _running() as server: status, raw, _ = _call(server, "GET", "/api/jobs/abc/events") assert (status, _error_of(raw)) == (403, "bad_token") class TestStale: + """A stale server refuses requests and stands down.""" + def _stale(self) -> AbstractContextManager[MagicMock]: return patch(f"{_PKG}.outdated_source", return_value="x.py") def test_api_routes_refuse_and_stand_down(self) -> None: + """API routes answer server_stale and retire the server.""" with self._stale(), patch(f"{_PKG}._web_process.retire") as retire: with _running() as server: status, raw, _ = _call(server, "GET", "/api/budget") @@ -119,6 +126,7 @@ class TestStale: retire.assert_called_once_with() def test_static_routes_refuse_in_plain_text(self) -> None: + """Static routes answer the stale message in plain text.""" with ( self._stale(), patch(f"{_PKG}._web_process.retire"), @@ -126,10 +134,11 @@ class TestStale: ): status, raw, headers = _call(server, "GET", "/") assert status == 503 - assert raw.decode() == _web_server._STALE_MSG + assert raw.decode() == _STALE_MSG assert headers["Content-Type"].startswith("text/plain") def test_server_health_still_answers(self) -> None: + """/api/server still answers with the version.""" with ( self._stale(), patch(f"{_PKG}._web_process.retire") as retire, @@ -142,22 +151,28 @@ class TestStale: class TestDispatch: + """Requests reach the right route or a clean error.""" + def test_unknown_endpoint(self) -> None: + """An unknown API path is a 404.""" with _running() as server: status, raw, _ = _call(server, "GET", "/api/nope") assert (status, _error_of(raw)) == (404, "not_found") def test_known_path_under_the_wrong_method(self) -> None: + """A known path under the wrong method is a 405.""" with _running() as server: status, _, _ = _call(server, "POST", "/api/budget", headers=_auth(server)) assert status == 405 def test_non_get_on_a_static_path(self) -> None: + """A non-GET on a static path is a 405.""" with _running() as server: status, _, _ = _call(server, "POST", "/index.html", headers=_auth(server)) assert status == 405 def test_post_body_reaches_the_route(self) -> None: + """The JSON body, path args and first query values reach the route.""" seen: list[Request] = [] def route(request: Request) -> Reply: @@ -183,6 +198,7 @@ class TestDispatch: ) def test_delete_is_routed(self) -> None: + """DELETE requests are routed.""" with ( patch(f"{_PKG}.resolve", return_value=(lambda _r: ok(1), ())), _running() as server, @@ -193,6 +209,8 @@ class TestDispatch: assert status == 200 def test_a_crashing_route_is_a_500_without_details(self) -> None: + """A crashing route is a 500 that leaks no details.""" + def route(_request: Request) -> Reply: msg = "secret detail" raise RuntimeError(msg) @@ -206,13 +224,17 @@ class TestDispatch: assert b"secret" not in raw def test_real_route_through_the_table(self) -> None: + """A real route resolves through the route table.""" with patch(f"{_PKG}.resolve", wraps=_web_server.resolve), _running() as server: status, raw, _ = _call(server, "GET", "/api/jobs") assert (status, json.loads(raw)) == (200, []) class TestEvents: + """The job event stream serves server-sent events.""" + def test_streams_a_finished_job(self) -> None: + """A finished job streams its result event.""" done = DaemonOutcome(ok=True, summary="s", exit_code=0) job_id = record_daemon_job("unblock", {}, created_at=now_iso(), outcome=done) with _running() as server: diff --git a/steam_backlog_enforcer/tests/test_web_server_static.py b/steam_backlog_enforcer/tests/test_web_server_static.py index 8b6df9c..8ecdf3f 100644 --- a/steam_backlog_enforcer/tests/test_web_server_static.py +++ b/steam_backlog_enforcer/tests/test_web_server_static.py @@ -9,7 +9,10 @@ from unittest.mock import MagicMock, patch import pytest -from steam_backlog_enforcer import _web_auth, _web_process, _web_server +from steam_backlog_enforcer import _web_auth, _web_server +from steam_backlog_enforcer._web_process import _server +from steam_backlog_enforcer._web_server import _NOT_BUILT_MSG, _query +from steam_backlog_enforcer.tests._web_process_state import clean_process_state if TYPE_CHECKING: from collections.abc import Iterator @@ -22,15 +25,11 @@ _PKG = "steam_backlog_enforcer._web_server" @pytest.fixture(autouse=True) def _clean_process_state() -> Iterator[None]: """No retiring flag, and never "stale": other edits to the tree must not leak in.""" - _web_process.RETIRING.clear() - with patch(f"{_PKG}.outdated_source", return_value=None): - yield - _web_process.RETIRING.clear() - _web_process._server.clear() + yield from clean_process_state() -@pytest.fixture -def dist(tmp_path: Path) -> Iterator[Path]: +@pytest.fixture(name="dist") +def fixture_dist(tmp_path: Path) -> Iterator[Path]: """A fake built bundle, served instead of ``web/dist``.""" root = (tmp_path / "dist").resolve() root.mkdir() @@ -75,7 +74,10 @@ def _get(path: str) -> tuple[int, bytes, str, str, str]: @pytest.mark.usefixtures("dist") class TestStatic: + """Static files: content types, caching, token injection and 404s.""" + def test_index_carries_this_launchs_token_and_is_not_cached(self) -> None: + """The index carries this launch's token and is never cached.""" status, body, ctype, cache, token = _get("/") assert status == 200 assert f'content="{token}"'.encode() in body @@ -83,69 +85,85 @@ class TestStatic: assert cache == "no-store" def test_javascript_declares_its_charset(self) -> None: + """JavaScript is served with a utf-8 charset and may be cached.""" _, body, ctype, cache, _ = _get("/app.js") assert body == b"console.log(1)" assert ctype.endswith("; charset=utf-8") assert cache == "" def test_binary_has_no_charset(self) -> None: + """A PNG is served as image/png without a charset.""" _, body, ctype, _, _ = _get("/logo.png") assert (body, ctype) == (b"\x89PNG", "image/png") def test_unknown_extension_is_plain_text(self) -> None: + """A file without a known extension is plain text.""" assert _get("/noext")[2] == "text/plain; charset=utf-8" @pytest.mark.parametrize("path", ["/sub", "/sub/", "/sub/index.html", "/sub/?id=7"]) def test_a_directory_serves_its_own_index_with_a_token(self, path: str) -> None: + """Every spelling of a directory serves its index with a token.""" status, body, ctype, cache, token = _get(path) assert (status, b"SUB" in body) == (200, True) assert f'content="{token}"'.encode() in body assert (ctype, cache) == ("text/html; charset=utf-8", "no-store") def test_every_html_file_carries_the_token_and_is_not_cached(self) -> None: + """Any HTML file gets the token and no-store.""" status, body, _, cache, token = _get("/sub/page.html") assert (status, cache) == (200, "no-store") assert f'content="{token}"'.encode() in body def test_unknown_route_is_the_404_page_with_a_token(self) -> None: + """An unknown route serves the 404 page with a token.""" status, body, _, cache, token = _get("/library/42") assert (status, b"MISSING" in body, cache) == (404, True, "no-store") assert f'content="{token}"'.encode() in body def test_path_traversal_gets_the_404_page_not_the_file(self) -> None: + """An encoded ../ never reaches a file outside the bundle.""" status, body, _, _, _ = _get("/..%2Fsecret.txt") assert (status, b"MISSING" in body) == (404, True) assert b"top secret" not in body def test_unknown_route_without_a_404_page_is_not_built(self, dist: Path) -> None: + """Without a 404 page an unknown route gets the not-built message.""" (dist / "404.html").unlink() status, body, _, _, _ = _get("/nope") - assert (status, body) == (404, _web_server._NOT_BUILT_MSG) + assert (status, body) == (404, _NOT_BUILT_MSG) def test_not_built_message_names_pnpm(self) -> None: - assert b"pnpm install && pnpm build" in _web_server._NOT_BUILT_MSG + """The not-built message tells how to build the frontend.""" + assert b"pnpm install && pnpm build" in _NOT_BUILT_MSG def test_unbuilt_frontend_is_a_404(self, tmp_path: Path) -> None: + """A missing bundle answers 404 with the not-built message.""" with patch(f"{_PKG}.WEB_DIST", (tmp_path / "missing").resolve()): status, body, _, _, _ = _get("/") - assert (status, body) == (404, _web_server._NOT_BUILT_MSG) + assert (status, body) == (404, _NOT_BUILT_MSG) class TestServer: + """create_server binds, mints a token and registers itself.""" + def test_port_is_the_bound_one(self) -> None: + """The server reports its bound port and a 64-char token, and is registered.""" server = _web_server.create_server("127.0.0.1", 0) try: assert server.port > 0 assert len(server.token) == 64 - assert _web_process._server == [server] + assert _server == [server] finally: server.server_close() def test_query_keeps_the_first_value(self) -> None: - assert _web_server._query("a=1&a=2&b=3") == {"a": "1", "b": "3"} + """Repeated query keys keep their first value.""" + assert _query("a=1&a=2&b=3") == {"a": "1", "b": "3"} class TestServe: + """serve writes the token file and always closes the server.""" + def _serve(self, serve_forever: object) -> MagicMock: server = MagicMock() server.token = _STUB @@ -160,10 +178,12 @@ class TestServe: return echo def test_writes_the_token_file_and_runs_until_it_returns(self) -> None: + """The token file is written and the user told how to stop.""" echo = self._serve(MagicMock()) assert _web_auth.token_path(4321).read_text("ascii") == "tok\n" assert "Press Ctrl-C" in echo.call_args_list[2].args[0] def test_ctrl_c_shuts_down_cleanly(self) -> None: + """Ctrl-C ends serve with a shutdown message.""" echo = self._serve(MagicMock(side_effect=KeyboardInterrupt)) assert "Shutting down" in echo.call_args.args[0] diff --git a/steam_backlog_enforcer/tests/test_web_setup.py b/steam_backlog_enforcer/tests/test_web_setup.py index 12f1c6d..0eb7c68 100644 --- a/steam_backlog_enforcer/tests/test_web_setup.py +++ b/steam_backlog_enforcer/tests/test_web_setup.py @@ -11,6 +11,7 @@ import requests from steam_backlog_enforcer import _web_setup from steam_backlog_enforcer import config as _config from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_setup import _STEAM_ID, _field from steam_backlog_enforcer.tests._web_request import make_request, payload_of if TYPE_CHECKING: @@ -36,25 +37,33 @@ def _players(*names: str) -> dict[str, Any]: class TestField: + """_field validates and strips one string field of the request body.""" + def test_returns_the_stripped_value(self) -> None: + """A valid field comes back with surrounding whitespace stripped.""" request = make_request(body={"steam_id": f" {_ID} "}) - got = _web_setup._field(request, "steam_id", _web_setup._STEAM_ID, "an id") + got = _field(request, "steam_id", _STEAM_ID, "an id") assert got == _ID @pytest.mark.parametrize("raw", [None, 5, "", "abc"]) def test_rejects_missing_or_malformed(self, raw: object) -> None: + """A missing, non-string, empty or malformed field is invalid_params.""" request = make_request(body={"steam_id": raw}) with pytest.raises(ApiError) as err: - _web_setup._field(request, "steam_id", _web_setup._STEAM_ID, "an id") + _field(request, "steam_id", _STEAM_ID, "an id") assert err.value.code == "invalid_params" class TestVerifyWithSteam: + """verify_with_steam accepts only a key and id Steam recognises.""" + def test_accepts_a_known_account(self) -> None: + """A key and id that resolve to a player pass without raising.""" with patch(f"{_PKG}.requests.get", return_value=_steam(200, _players("x"))): _web_setup.verify_with_steam(_KEY, _ID) def test_network_failure_is_op_failed(self) -> None: + """A network failure is reported as op_failed naming the exception.""" with ( patch(f"{_PKG}.requests.get", side_effect=requests.Timeout), pytest.raises(ApiError) as err, @@ -65,6 +74,7 @@ class TestVerifyWithSteam: @pytest.mark.parametrize("status", [401, 403]) def test_rejected_key(self, status: int) -> None: + """A 401 or 403 means Steam rejected the API key.""" with ( patch(f"{_PKG}.requests.get", return_value=_steam(status)), pytest.raises(ApiError) as err, @@ -76,6 +86,7 @@ class TestVerifyWithSteam: ) def test_other_http_status(self) -> None: + """Any other HTTP error status is reported as op_failed.""" with ( patch(f"{_PKG}.requests.get", return_value=_steam(500)), pytest.raises(ApiError) as err, @@ -88,6 +99,7 @@ class TestVerifyWithSteam: [_steam(200, ValueError()), _steam(200, {}), _steam(200, {"response": None})], ) def test_unexpected_reply_shape(self, reply: MagicMock) -> None: + """A reply without a players list is reported as op_failed.""" with ( patch(f"{_PKG}.requests.get", return_value=reply), pytest.raises(ApiError) as err, @@ -96,6 +108,7 @@ class TestVerifyWithSteam: assert err.value.code == "op_failed" def test_unknown_account(self) -> None: + """A key that works but finds no player means the Steam id is wrong.""" with ( patch(f"{_PKG}.requests.get", return_value=_steam(200, _players())), pytest.raises(ApiError) as err, @@ -105,6 +118,8 @@ class TestVerifyWithSteam: class TestSaveSetup: + """save_setup validates, checks the lock, asks Steam, then saves.""" + _LOCK = "steam_backlog_enforcer._web_setup.lock_reason" def _request(self, **body: str) -> Request: @@ -113,16 +128,19 @@ class TestSaveSetup: ) def test_bad_key_is_refused_first(self) -> None: + """A malformed API key is refused before anything else runs.""" with pytest.raises(ApiError) as err: _web_setup.save_setup(self._request(steam_api_key="short")) assert "steam_api_key" in err.value.message def test_bad_steam_id_is_refused(self) -> None: + """A malformed Steam id is refused.""" with pytest.raises(ApiError) as err: _web_setup.save_setup(self._request(steam_id="1")) assert "steam_id" in err.value.message def test_a_locked_setup_is_refused_before_asking_steam(self) -> None: + """A locked setup is refused without calling Steam.""" with ( patch(self._LOCK, return_value="Total block"), patch(f"{_PKG}.verify_with_steam") as verify, @@ -133,6 +151,7 @@ class TestSaveSetup: verify.assert_not_called() def test_steam_refusal_saves_nothing(self) -> None: + """When Steam refuses the credentials nothing is written.""" with ( patch(self._LOCK, return_value=None), patch( @@ -146,6 +165,7 @@ class TestSaveSetup: save.assert_not_called() def test_saves_and_never_echoes_the_key(self) -> None: + """Accepted credentials are saved and the reply never contains the key.""" with ( patch(self._LOCK, return_value=None), patch(f"{_PKG}.verify_with_steam"), diff --git a/steam_backlog_enforcer/tests/test_web_sse.py b/steam_backlog_enforcer/tests/test_web_sse.py index 77b108e..5fa0f1d 100644 --- a/steam_backlog_enforcer/tests/test_web_sse.py +++ b/steam_backlog_enforcer/tests/test_web_sse.py @@ -11,6 +11,7 @@ import pytest from steam_backlog_enforcer import _web_process, _web_sse from steam_backlog_enforcer._web_errors import ApiError +from steam_backlog_enforcer._web_sse import _frame, _is_terminal_state, _pump from steam_backlog_enforcer.jobs._daemon_jobs import DaemonOutcome, record_daemon_job from steam_backlog_enforcer.jobs._events import now_iso from steam_backlog_enforcer.tests._web_request import make_request @@ -32,18 +33,23 @@ class _Handler: self.close_connection = False def send_response(self, status: int) -> None: + """Record the status line the stream sends.""" self.status = status def send_header(self, name: str, value: str) -> None: + """Record a header the stream sends.""" self.headers_sent.append((name, value)) def end_headers(self) -> None: + """Accept the end of headers; nothing is buffered.""" return def as_handler(self) -> BaseHTTPRequestHandler: + """Expose the fake under the handler type the code expects.""" return cast("BaseHTTPRequestHandler", self) def written(self) -> str: + """Return everything the stream wrote, decoded.""" return self.wfile.getvalue().decode() @@ -56,9 +62,11 @@ class _Clock: self.left = sleeps_before_retire def monotonic(self) -> float: + """Return the fake current time.""" return self.now def sleep(self, _seconds: float) -> None: + """Advance the clock one step and retire the server once out of sleeps.""" self.now += self.step self.left -= 1 if self.left <= 0: @@ -72,7 +80,7 @@ def _not_retiring() -> Iterator[None]: _web_process.RETIRING.clear() -def _pump(clock: _Clock, events: list[list[dict[str, Any]]], state: str) -> str: +def _run_pump(clock: _Clock, events: list[list[dict[str, Any]]], state: str) -> str: """Run ``_pump`` over scripted event batches (then empty ones).""" handler = _Handler() batches = iter(events) @@ -82,11 +90,13 @@ def _pump(clock: _Clock, events: list[list[dict[str, Any]]], state: str) -> str: patch(f"{_PKG}.read_job_events", side_effect=lambda *_: next(batches, [])), patch(f"{_PKG}.read_job", return_value={"state": state}), ): - _web_sse._pump(handler.as_handler(), "j", 0) + _pump(handler.as_handler(), "j", 0) return handler.written() class TestResumeAfter: + """The resume point comes from Last-Event-ID, then ?after, never negative.""" + @pytest.mark.parametrize( ("headers", "query", "expected"), [ @@ -99,20 +109,23 @@ class TestResumeAfter: def test_header_wins_over_query( self, headers: dict[str, str], query: dict[str, str], expected: int ) -> None: + """A Last-Event-ID header beats ?after, and negatives clamp to zero.""" request = make_request(headers=headers, query=query) assert _web_sse.resume_after(request) == expected def test_non_numeric(self) -> None: + """A non-numeric ?after is refused as invalid_params.""" with pytest.raises(ApiError) as err: _web_sse.resume_after(make_request(query={"after": "x"})) assert err.value.code == "invalid_params" class TestFrames: + """Each event becomes one SSE frame; only state events can end a stream.""" + def test_frame_layout(self) -> None: - assert _web_sse._frame({"seq": 4, "a": 1}) == ( - b'id: 4\ndata: {"seq": 4, "a": 1}\n\n' - ) + """A frame carries the seq as its id and the event as JSON data.""" + assert _frame({"seq": 4, "a": 1}) == (b'id: 4\ndata: {"seq": 4, "a": 1}\n\n') @pytest.mark.parametrize( ("event", "terminal"), @@ -123,45 +136,59 @@ class TestFrames: ], ) def test_terminal_state(self, event: dict[str, str], *, terminal: bool) -> None: - assert _web_sse._is_terminal_state(event) is terminal + """Only a state event in a final state counts as terminal.""" + assert _is_terminal_state(event) is terminal class TestPump: + """The pump streams until the job ends, a grace lapses or the server retires.""" + def test_stops_at_once_when_retiring(self) -> None: + """A retiring server writes nothing at all.""" _web_process.RETIRING.set() - assert _pump(_Clock(1, 99), [[_RESULT]], "running") == "" + assert _run_pump(_Clock(1, 99), [[_RESULT]], "running") == "" def test_ends_after_the_final_state(self) -> None: - out = _pump(_Clock(1, 99), [[{"seq": 0, "type": "log"}, _RESULT, _DONE]], "x") + """Every event up to the final state is sent, then the pump ends.""" + out = _run_pump( + _Clock(1, 99), [[{"seq": 0, "type": "log"}, _RESULT, _DONE]], "x" + ) assert out.count("data:") == 3 assert out.endswith("\n\n") def test_ends_a_grace_after_a_result_without_a_state(self) -> None: + """A result with no state event ends the stream once the grace period lapses.""" clock = _Clock(3, 99) - out = _pump(clock, [[_RESULT]], "running") + out = _run_pump(clock, [[_RESULT]], "running") assert out.count("data:") == 1 assert clock.left == 98 # one sleep, then the grace lapsed def test_ends_for_a_finished_job_with_nothing_new(self) -> None: - assert _pump(_Clock(1, 99), [], "failed") == "" + """A job already finished with no new events ends at once with no output.""" + assert _run_pump(_Clock(1, 99), [], "failed") == "" def test_keeps_alive_while_waiting(self) -> None: - out = _pump(_Clock(10, 3), [], "running") + """Idle waiting writes a keep-alive comment so the browser keeps the stream.""" + out = _run_pump(_Clock(10, 3), [], "running") assert out == ": keep-alive\n\n" def test_keeps_waiting_inside_the_grace_period(self) -> None: + """Inside the grace period after a result the pump keeps waiting.""" clock = _Clock(1, 3) - out = _pump(clock, [[_RESULT]], "running") + out = _run_pump(clock, [[_RESULT]], "running") assert out.count("data:") == 1 assert _web_process.RETIRING.is_set() class TestStream: + """stream_job_events sends headers, replays the job log and closes.""" + def _job(self) -> str: done = DaemonOutcome(ok=True, summary="done", exit_code=0) return record_daemon_job("unblock", {}, created_at=now_iso(), outcome=done) def test_streams_a_finished_job_and_closes(self) -> None: + """A finished job streams its result and final state, then closes.""" job_id = self._job() handler = _Handler() _web_sse.stream_job_events(handler.as_handler(), make_request(job_id)) @@ -176,6 +203,7 @@ class TestStream: assert '"state": "succeeded"' in out def test_resumes_past_the_end(self) -> None: + """Resuming past the last event sends only the retry hint.""" job_id = self._job() handler = _Handler() request = make_request(job_id, query={"after": "999"}) @@ -183,6 +211,7 @@ class TestStream: assert handler.written() == "retry: 2000\n\n" def test_unknown_job_sends_nothing(self) -> None: + """An unknown job raises not_found before any response is started.""" handler = _Handler() with pytest.raises(ApiError) as err: _web_sse.stream_job_events(handler.as_handler(), make_request("nope")) @@ -190,6 +219,7 @@ class TestStream: assert handler.status is None def test_bad_after_is_refused_before_any_byte(self) -> None: + """A bad ?after is refused before the response status is sent.""" handler = _Handler() with pytest.raises(ApiError): _web_sse.stream_job_events( @@ -199,6 +229,8 @@ class TestStream: @pytest.mark.parametrize("gone", [BrokenPipeError, ConnectionResetError]) def test_a_vanished_browser_is_not_an_error(self, gone: type[OSError]) -> None: + """A browser that disconnects mid-stream ends the stream without an error.""" + class _Dead(io.BytesIO): def write(self, _data: Buffer) -> int: raise gone diff --git a/steam_backlog_enforcer/tests/test_web_views.py b/steam_backlog_enforcer/tests/test_web_views.py index fa0cd55..fea9ddb 100644 --- a/steam_backlog_enforcer/tests/test_web_views.py +++ b/steam_backlog_enforcer/tests/test_web_views.py @@ -8,6 +8,7 @@ import pytest from steam_backlog_enforcer import _steam_state, _web_views from steam_backlog_enforcer._backups import StateBackup +from steam_backlog_enforcer._web_views import _manual_pick, _size_on_disk from steam_backlog_enforcer.config import Config from steam_backlog_enforcer.tests._web_request import make_request, payload_of @@ -15,17 +16,20 @@ _PKG = "steam_backlog_enforcer._web_views" class TestStatus: + """The status view reshapes manual picks for the UI.""" + def test_manual_pick_fields(self) -> None: - full = _web_views._manual_pick( - {"app_id": 1, "game_name": "One", "age_days": 2.345} - ) + """A manual pick maps to id, name and age rounded to a tenth.""" + full = _manual_pick({"app_id": 1, "game_name": "One", "age_days": 2.345}) assert full == {"app_id": 1, "name": "One", "age_days": 2.3} def test_unknown_name_and_age(self) -> None: - bare = _web_views._manual_pick({"app_id": 2, "age_days": None}) + """A pick without name or age falls back to the id and 0.""" + bare = _manual_pick({"app_id": 2, "age_days": None}) assert bare == {"app_id": 2, "name": "2", "age_days": 0.0} def test_status_view_reshapes_the_payload(self) -> None: + """status_view fills install state and reshapes picks, keeping the rest.""" raw = { "assigned_game_installed": None, "manual_picks": [{"app_id": 3, "game_name": "G", "age_days": 1.0}], @@ -39,7 +43,10 @@ class TestStatus: class TestDatasetViews: + """The stats, dataset and budget views project their sources.""" + def test_stats_projects_two_keys(self) -> None: + """stats_view keeps only the summary and pace keys.""" full = {"default_summary": {"a": 1}, "pace_vs_hltb": [2], "games": []} with ( patch(f"{_PKG}.build_web_dataset"), @@ -49,6 +56,7 @@ class TestDatasetViews: assert reply.payload == {"default_summary": {"a": 1}, "pace_vs_hltb": [2]} def test_dataset_view(self) -> None: + """dataset_view returns the whole dataset payload.""" with ( patch(f"{_PKG}.build_web_dataset"), patch(f"{_PKG}.dataset_to_payload", return_value={"games": []}), @@ -57,6 +65,7 @@ class TestDatasetViews: @pytest.mark.parametrize(("query", "demo"), [({}, False), ({"demo": "1"}, True)]) def test_budget_view(self, query: dict[str, str], *, demo: bool) -> None: + """budget_view builds the snapshot with the demo flag from the query.""" with patch(f"{_PKG}.build_budget_snapshot", return_value={"x": 1}) as build: reply = _web_views.budget_view(make_request(query=query)) build.assert_called_once_with(demo=demo) @@ -64,18 +73,23 @@ class TestDatasetViews: class TestInstalled: + """The installed view lists games with size and flags.""" + def test_size_read_from_the_manifest(self) -> None: + """The size comes from SizeOnDisk in the manifest.""" manifest = _steam_state.STEAMAPPS_PATH / "appmanifest_5.acf" manifest.write_text('"AppState"\n{\n\t"SizeOnDisk"\t\t"1234"\n}', "utf-8") - assert _web_views._size_on_disk(5) == 1234 + assert _size_on_disk(5) == 1234 def test_size_unknown_without_a_match_or_a_file(self) -> None: + """A manifest without the field, or no manifest, gives size 0.""" manifest = _steam_state.STEAMAPPS_PATH / "appmanifest_6.acf" manifest.write_text('"AppState" {}', encoding="utf-8") - assert _web_views._size_on_disk(6) == 0 - assert _web_views._size_on_disk(7) == 0 + assert _size_on_disk(6) == 0 + assert _size_on_disk(7) == 0 def test_installed_view(self) -> None: + """installed_view lists each game with size, assigned and protected flags.""" with ( patch(f"{_PKG}.get_installed_games", return_value=[(5, "Five")]), patch(f"{_PKG}.allowed_app_ids", return_value={5}), @@ -97,23 +111,29 @@ class TestInstalled: class TestSimpleViews: + """Views that pass a single source through.""" + def test_commands(self) -> None: + """commands_view returns the command specs.""" with patch(f"{_PKG}.command_specs", return_value=[{"name": "scan"}]): assert _web_views.commands_view(make_request()).payload == [ {"name": "scan"} ] def test_setup_status_never_carries_the_key(self) -> None: + """setup_status reports whether a key is set, never the key.""" unset = _web_views.setup_status(Config()) assert unset == {"configured": False, "has_api_key": False, "steam_id": None} done = _web_views.setup_status(Config(steam_api_key="k", steam_id="7")) assert done == {"configured": True, "has_api_key": True, "steam_id": "7"} def test_setup_view(self) -> None: + """setup_view reports the setup status.""" body = payload_of(_web_views.setup_view(make_request())) assert body["configured"] is False def test_backups(self) -> None: + """backups_view lists the backups as JSON.""" backup = StateBackup("id1", "t", "r", 4) with patch(f"{_PKG}.list_backups", return_value=[backup]): reply = _web_views.backups_view(make_request()) diff --git a/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py index aec8f58..0c20811 100644 --- a/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py +++ b/steam_backlog_enforcer/tests/test_whitelist_immutable_flag_is.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess from typing import TYPE_CHECKING from unittest.mock import patch @@ -122,8 +123,6 @@ class TestImmutableFlagIs: def test_timeout_swallowed(self, tmp_path: Path) -> None: """Test timeout swallowed.""" - import subprocess - target = tmp_path / "file.txt" target.write_text("data", encoding="utf-8") with ( diff --git a/steam_backlog_enforcer/tests/test_workout_budget.py b/steam_backlog_enforcer/tests/test_workout_budget.py index fa64a2c..6729603 100644 --- a/steam_backlog_enforcer/tests/test_workout_budget.py +++ b/steam_backlog_enforcer/tests/test_workout_budget.py @@ -7,15 +7,17 @@ arithmetic that consumes it lives in test_budget_resolve.py. from __future__ import annotations +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer import json import logging +import threading from typing import TYPE_CHECKING from unittest.mock import patch import pytest -from steam_backlog_enforcer import _workout_budget from steam_backlog_enforcer._workout_budget import ( + _fetch_workout_today, reset_cache, workout_logged_today, ) @@ -135,16 +137,16 @@ class TestFetchWorkoutToday: def test_reads_the_flag(self, http_stub: str) -> None: """A well-formed payload yields the boolean.""" - assert _workout_budget._fetch_workout_today(http_stub) is True + assert _fetch_workout_today(http_stub) is True def test_a_non_200_is_an_error(self, http_stub_500: str) -> None: """An error status must not be parsed as "no workout".""" with pytest.raises(ValueError, match="status 500"): - _workout_budget._fetch_workout_today(http_stub_500) + _fetch_workout_today(http_stub_500) -@pytest.fixture -def http_stub() -> Iterator[str]: +@pytest.fixture(name="http_stub") +def fixture_http_stub() -> Iterator[str]: """Serve a status payload saying a workout was logged today. Yields: @@ -153,8 +155,8 @@ def http_stub() -> Iterator[str]: yield from _serve(200, json.dumps({"gaming": {"workout_today": True}}).encode()) -@pytest.fixture -def http_stub_500() -> Iterator[str]: +@pytest.fixture(name="http_stub_500") +def fixture_http_stub_500() -> Iterator[str]: """Serve a 500, as a broken payload builder would. Yields: @@ -173,8 +175,6 @@ def _serve(status: int, body: bytes) -> Iterator[str]: Yields: The URL of the endpoint. """ - from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer - import threading class _Handler(BaseHTTPRequestHandler): def log_message(self, *args: object) -> None: diff --git a/steam_backlog_enforcer/tests/test_zero_achievement_allowed.py b/steam_backlog_enforcer/tests/test_zero_achievement_allowed.py index 37c5a75..b08fa94 100644 --- a/steam_backlog_enforcer/tests/test_zero_achievement_allowed.py +++ b/steam_backlog_enforcer/tests/test_zero_achievement_allowed.py @@ -19,6 +19,7 @@ from steam_backlog_enforcer.game_uninstall import ( uninstall_other_games, ) from steam_backlog_enforcer.library_hider import hide_other_games +from steam_backlog_enforcer.tests._no_approved_exceptions import no_approved_exceptions from steam_backlog_enforcer.tests._zero_ach import ( ASSIGNED, SIMS3, @@ -35,15 +36,14 @@ if TYPE_CHECKING: @pytest.fixture(autouse=True) def _no_exceptions() -> Iterator[None]: """No approved exceptions, so only the zero-achievement rule can protect.""" - with patch( - "steam_backlog_enforcer.game_uninstall.get_approved_exception_ids", - return_value=frozenset(), - ): - yield + yield from no_approved_exceptions() class TestUninstall: + """Uninstall sweeps never remove a zero-achievement game.""" + def test_zero_achievement_game_is_kept(self) -> None: + """uninstall_other_games keeps it and removes the game with achievements.""" confirm_zero(SIMS3) removed: list[int] = [] with ( @@ -60,6 +60,7 @@ class TestUninstall: assert removed == [WITH_ACH] def test_guard_keeps_it_and_removes_game_with_achievements(self) -> None: + """The install guard keeps it and removes only the other game.""" confirm_zero(SIMS3) removed: list[int] = [] with ( @@ -78,7 +79,10 @@ class TestUninstall: class TestHiding: + """Library hiding leaves zero-achievement games visible.""" + def test_zero_achievement_game_stays_visible(self) -> None: + """The game is in the visible set and not in the hide list.""" confirm_zero(SIMS3) pkg = "steam_backlog_enforcer.library_hider" captured: dict[str, str] = {} @@ -101,7 +105,10 @@ class TestHiding: class TestTampering: + """Tampering checks treat zero-achievement play as legitimate.""" + def test_zero_achievement_game_is_exempt(self) -> None: + """The game is in the legitimately-played set.""" confirm_zero(SIMS3) with patch( "steam_backlog_enforcer._scanning_tampering.get_approved_exception_ids", @@ -112,7 +119,10 @@ class TestTampering: class TestUnauthorizedKill: + """The unauthorized-game killer spares zero-achievement games.""" + def test_not_killed_but_other_games_are(self) -> None: + """Only the game with achievements is killed.""" confirm_zero(SIMS3) with ( patch( @@ -129,6 +139,7 @@ class TestBudgetStillApplies: """The playtime budget never asks is_protected_app, so it still bites.""" def test_running_zero_game_is_counted_and_in_the_kill_set(self) -> None: + """A running zero-achievement game is billed and killed at the cutoff.""" confirm_zero(SIMS3) rules = MagicMock(count_launchers=False) with ( diff --git a/steam_backlog_enforcer/tests/test_zero_achievement_fail_closed.py b/steam_backlog_enforcer/tests/test_zero_achievement_fail_closed.py index eaae8bf..fba50ec 100644 --- a/steam_backlog_enforcer/tests/test_zero_achievement_fail_closed.py +++ b/steam_backlog_enforcer/tests/test_zero_achievement_fail_closed.py @@ -9,11 +9,13 @@ 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, _derive_zero_ids from steam_backlog_enforcer.enforcer import enforce_allowed_game from steam_backlog_enforcer.game_uninstall import ( is_protected_app, uninstall_other_games, ) +from steam_backlog_enforcer.tests._no_approved_exceptions import no_approved_exceptions from steam_backlog_enforcer.tests._zero_ach import ( ASSIGNED, NOW, @@ -33,58 +35,66 @@ if TYPE_CHECKING: @pytest.fixture(autouse=True) def _no_exceptions() -> Iterator[None]: """No approved exceptions, so only the zero-achievement rule can protect.""" - with patch( - "steam_backlog_enforcer.game_uninstall.get_approved_exception_ids", - return_value=frozenset(), - ): - yield + yield from no_approved_exceptions() class TestFailClosed: + """Anything short of a confirmed zero-achievement record stays enforced.""" + def test_no_cache_file(self) -> None: + """With no cache file the game is not protected.""" assert not is_protected_app(SIMS3) def test_corrupt_cache_file(self) -> None: + """A corrupt cache file protects nothing.""" write(config.CONFIG_FILE, {"steam_id": STEAM_ID}) - skip._cache_file().write_text("{nope", encoding="utf-8") + _cache_file().write_text("{nope", encoding="utf-8") assert not is_protected_app(SIMS3) def test_cache_of_another_account(self) -> None: + """A cache recorded for another account protects nothing.""" confirm_zero(SIMS3, steam_id="someone-else") assert not is_protected_app(SIMS3) def test_unknown_account(self) -> None: + """Without a known account id the cache protects nothing.""" confirm_zero(SIMS3) config.CONFIG_FILE.unlink() assert not is_protected_app(SIMS3) def test_account_id_of_the_wrong_type(self) -> None: + """A non-string account id protects nothing.""" confirm_zero(SIMS3) write(config.CONFIG_FILE, {"steam_id": 12345}) assert not is_protected_app(SIMS3) def test_unreadable_snapshot(self) -> None: + """An unreadable snapshot protects nothing.""" confirm_zero(SIMS3) config.SNAPSHOT_FILE.write_text("{nope", encoding="utf-8") assert not is_protected_app(SIMS3) def test_answer_is_reused_until_a_file_changes(self) -> None: + """The protected set is derived at most once while no file changes.""" confirm_zero(SIMS3) - with patch.object(skip, "_derive_zero_ids", wraps=skip._derive_zero_ids) as d: + with patch.object(skip, "_derive_zero_ids", wraps=_derive_zero_ids) as d: assert is_protected_app(SIMS3) assert is_protected_app(SIMS3) assert d.call_count <= 1 def test_unconfirmed_game_is_enforced(self) -> None: + """A game the cache never confirmed stays enforced.""" confirm_zero(SIMS3) assert not is_protected_app(WITH_ACH) def test_game_that_gained_achievements_is_enforced(self) -> None: + """A confirmed game that now has achievements is enforced again.""" confirm_zero(SIMS3) write_snapshot(SIMS3) assert not is_protected_app(SIMS3) def test_unknown_status_game_is_uninstalled(self) -> None: + """An installed game of unknown status is uninstalled.""" removed: list[int] = [] with ( patch( @@ -100,6 +110,7 @@ class TestFailClosed: assert removed == [SIMS3] def test_unknown_status_game_is_killed(self) -> None: + """A running game of unknown status is killed.""" with ( patch( "steam_backlog_enforcer.enforcer.get_running_steam_game_pids", @@ -111,13 +122,17 @@ class TestFailClosed: class TestRecordedByScan: + """The scan's cache record is what grants and revokes protection.""" + def test_a_scan_that_finds_no_achievements_allows_the_game(self) -> None: + """A scan that finds no achievements makes the game allowed.""" write(config.CONFIG_FILE, {"steam_id": STEAM_ID}) game = {"appid": SIMS3, "has_community_visible_stats": False} skip.record_scan(STEAM_ID, [game], set(), NOW) assert is_protected_app(SIMS3) def test_a_later_scan_that_finds_achievements_enforces_it_again(self) -> None: + """A later scan that finds achievements revokes the protection.""" write(config.CONFIG_FILE, {"steam_id": STEAM_ID}) game = {"appid": SIMS3, "has_community_visible_stats": False} skip.record_scan(STEAM_ID, [game], set(), NOW) @@ -128,5 +143,5 @@ class TestRecordedByScan: def test_old_confirmation_still_counts(self) -> None: """Presence is enough: only a scan seeing achievements revokes it.""" confirm_zero(SIMS3) - write(skip._cache_file(), {"steam_id": STEAM_ID, "checked": {str(SIMS3): 1.0}}) + write(_cache_file(), {"steam_id": STEAM_ID, "checked": {str(SIMS3): 1.0}}) assert is_protected_app(SIMS3)