From f1c8f042879ee7e418335b00a0159206c1c82d7f Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Fri, 25 Sep 2026 18:45:27 +0200 Subject: [PATCH] Install games with steam-game-installer (depotdownloader) for speed, falling back to steam://install Co-Authored-By: Claude Opus 5 (1M context) --- steam_backlog_enforcer/_cmd_done_finalize.py | 6 +- steam_backlog_enforcer/_enforce_steps.py | 11 +- steam_backlog_enforcer/_fast_install.py | 237 ++++++++++++++++++ steam_backlog_enforcer/_scanning_assign.py | 4 +- steam_backlog_enforcer/_steam_client.py | 4 + steam_backlog_enforcer/_steam_errors.py | 10 + steam_backlog_enforcer/_steam_launch.py | 13 +- .../_steam_restart_guard.py | 39 +++ steam_backlog_enforcer/_steam_state.py | 33 ++- steam_backlog_enforcer/game_install.py | 100 +++++--- steam_backlog_enforcer/game_uninstall.py | 4 + steam_backlog_enforcer/main/install.py | 4 +- steam_backlog_enforcer/main/picks.py | 4 +- .../tests/_no_fast_install.py | 56 +++++ .../tests/_no_subprocess.py | 9 + steam_backlog_enforcer/tests/conftest.py | 10 +- .../tests/test_enforce_steps.py | 6 +- .../tests/test_fast_install_guards.py | 215 ++++++++++++++++ .../tests/test_fast_install_helpers.py | 136 ++++++++++ .../tests/test_fast_install_runs.py | 179 +++++++++++++ .../tests/test_game_install_part3.py | 9 +- .../tests/test_main_install.py | 6 +- .../tests/test_main_part2.py | 2 +- .../tests/test_main_part2b.py | 8 +- .../tests/test_main_part2c.py | 4 +- .../tests/test_main_pick_cap.py | 8 +- .../tests/test_main_pick_manual.py | 8 +- .../tests/test_main_pick_manual_cascade.py | 12 +- ...ached_confidence_overlay_avoids_refetch.py | 10 +- .../tests/test_scanning_part3.py | 4 +- .../tests/test_scanning_pick_next_game.py | 10 +- ...test_scanning_pick_next_game_sequential.py | 2 +- ...t_scanning_pick_next_game_unknown_hours.py | 6 +- 33 files changed, 1062 insertions(+), 107 deletions(-) create mode 100644 steam_backlog_enforcer/_fast_install.py create mode 100644 steam_backlog_enforcer/tests/_no_fast_install.py create mode 100644 steam_backlog_enforcer/tests/test_fast_install_guards.py create mode 100644 steam_backlog_enforcer/tests/test_fast_install_helpers.py create mode 100644 steam_backlog_enforcer/tests/test_fast_install_runs.py diff --git a/steam_backlog_enforcer/_cmd_done_finalize.py b/steam_backlog_enforcer/_cmd_done_finalize.py index 5119c92..0afbd63 100644 --- a/steam_backlog_enforcer/_cmd_done_finalize.py +++ b/steam_backlog_enforcer/_cmd_done_finalize.py @@ -20,6 +20,7 @@ from steam_backlog_enforcer._hltb_cached import fetch_hltb_times_cached from steam_backlog_enforcer._hltb_types import load_hltb_cache from steam_backlog_enforcer._pick_completion import mark_finished from steam_backlog_enforcer._snapshot import load_snapshot +from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.enforcer import ( enforce_allowed_game, send_notification, @@ -27,7 +28,6 @@ from steam_backlog_enforcer.enforcer import ( from steam_backlog_enforcer.game_install import ( _echo, install_game, - is_game_installed, uninstall_other_games, ) from steam_backlog_enforcer.library_hider import hide_others_and_report @@ -74,7 +74,7 @@ def _finalize_completion( hide_others_and_report(config, state) - if not is_game_installed(state.current_app_id): + if not is_game_fully_installed(state.current_app_id): logger.info( "Assigned game still missing after library reconciliation; " "re-triggering install" @@ -120,7 +120,7 @@ def _enforce_on_done(config: Config, state: State) -> None: if count: _echo(f" Uninstalled {count} unauthorized game(s)") - if not is_game_installed(state.current_app_id): + if not is_game_fully_installed(state.current_app_id): _echo(f" Re-installing {state.current_game_name}...") install_game( state.current_app_id, diff --git a/steam_backlog_enforcer/_enforce_steps.py b/steam_backlog_enforcer/_enforce_steps.py index 5b60457..688f61a 100644 --- a/steam_backlog_enforcer/_enforce_steps.py +++ b/steam_backlog_enforcer/_enforce_steps.py @@ -12,9 +12,13 @@ from typing import TYPE_CHECKING from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._fast_install import poll_fast_installs from steam_backlog_enforcer._owned_apps_cache import get_all_owned_app_ids from steam_backlog_enforcer._steam_client import is_game_installed -from steam_backlog_enforcer._steam_state import steam_library_ready +from steam_backlog_enforcer._steam_state import ( + is_game_fully_installed, + steam_library_ready, +) from steam_backlog_enforcer.enforcer import send_notification from steam_backlog_enforcer.game_install import install_game from steam_backlog_enforcer.game_uninstall import uninstall_other_games @@ -41,6 +45,9 @@ def _reinstall_missing_allowed(config: Config, state: State) -> None: if not steam_library_ready(): return + # Reap/stall-check a detached fast install every pass: the game it is + # installing may already have a manifest, so install_game never runs. + poll_fast_installs() for app_id, name in allowed_games(state): if is_game_installed(app_id): continue @@ -83,7 +90,7 @@ def _enforce_auto_install(config: Config, state: State) -> None: state: Current enforcer state. """ for app_id, name in allowed_games(state): - if is_game_installed(app_id): + if is_game_fully_installed(app_id): _echo(f" Allowed game already installed: {name}") continue _echo(f" Auto-installing {name}...") diff --git a/steam_backlog_enforcer/_fast_install.py b/steam_backlog_enforcer/_fast_install.py new file mode 100644 index 0000000..36d3ed5 --- /dev/null +++ b/steam_backlog_enforcer/_fast_install.py @@ -0,0 +1,237 @@ +"""Installing games through steam-game-installer, for download speed. + +depotdownloader saturates the link far better than the Steam client, and +steam-game-installer (``~/src/steam-game-installer``) wraps it so Steam accepts +the result as a real install. It also adopts whatever Steam already staged, so +taking over a slow client download loses nothing. + +Two modes, picked by whether anyone can answer a prompt: + +* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run to + completion with the terminal attached, so depotdownloader's progress shows + and a password / Steam Guard prompt can be answered. +* **Detached** (the daemon): spawn in its own session and return. Every later + pass reaps it through :func:`poll_fast_installs`, which records a failure (so + the caller falls back to ``steam://install``) and interrupts a run whose + output has stalled - most likely a login prompt nobody will ever answer, + sitting there with Steam shut down. + +The installer closes Steam to do its work, so it is refused outright while a +game is running or another app is mid-update (the AoE2 corruption case). +""" + +from __future__ import annotations + +from dataclasses import dataclass +import logging +import os +from pathlib import Path +import pwd +import re +import shutil +import signal +import subprocess +import sys +import time + +from steam_backlog_enforcer import config as _config +from steam_backlog_enforcer._desktop_env import desktop_user_cmd, resolve_desktop_user +from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._steam_restart_guard import game_is_running +from steam_backlog_enforcer._steam_state import ( + STEAMAPPS_PATH, + steam_update_in_progress, +) + +logger = logging.getLogger(__name__) + +_INSTALLER = Path("src", "steam-game-installer", "run.sh") +_INSTALLER_LOG = "~/.local/state/steam-game-installer/latest.log" +# Depot content only: the app itself is ours to choose, the language is not +# worth a prompt nobody is there to answer. +_LANGUAGE = "english" +# depotdownloader prints a line per finished chunk, so a healthy download is +# never silent this long; a login prompt waiting on /dev/null is. +STALL_SECONDS = 15 * 60 + + +@dataclass +class _DetachedRun: + """A daemon-spawned installer run, kept so it can be reaped.""" + + app_id: int + proc: subprocess.Popen[bytes] + log_path: Path + + +_RUNS: list[_DetachedRun] = [] +# Apps whose installer run failed in this process. They go straight to the +# steam:// fallback from then on, so a broken login cannot close Steam on +# every 3-second pass. +_FAILED: set[int] = set() + + +def _installer_path(user: str | None) -> Path | None: + """steam-game-installer's ``run.sh`` in the desktop user's home, if present. + + Resolved from the passwd entry rather than ``Path.home()``: the daemon runs + as root, and the installer, its venv and its credentials are per-user. + """ + try: + home = Path(pwd.getpwnam(user).pw_dir) if user else Path.home() + except KeyError: + return None + path = home / _INSTALLER + return path if path.is_file() else None + + +def _steam_account_name() -> str | None: + """The account Steam itself last signed in with, from ``loginusers.vdf``. + + Read from Steam's own state rather than a copy in the enforcer config, so + it cannot drift from the account that actually owns the games. + """ + path = STEAMAPPS_PATH.parent / "config" / "loginusers.vdf" + try: + content = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return None + match = re.search(r'"AccountName"\s+"([^"]+)"', content) + return match.group(1) if match else None + + +def _refusal(app_id: int) -> str | None: + """Why closing Steam right now would do damage, or None if it is safe.""" + if game_is_running(): + return "a game is running and closing Steam would kill it" + if steam_update_in_progress(exclude_app_id=app_id): + return ( + "another Steam download is in progress and closing Steam could corrupt it" + ) + return None + + +def _build_cmd(installer: Path, app_id: int, user: str | None) -> list[str]: + """The installer invocation, dropped to the desktop user when root.""" + cmd = [ + str(installer), + # install.sh reaches for yay; dependencies are installed once, by hand. + "--no-deps", + "--appid", + str(app_id), + "--yes", + "--language", + _LANGUAGE, + # Finishes the manifest from depotdownloader's own data, under the same + # fail-closed checks: no second login, no 70 GB re-hash by steamcmd. + "--skip-steamcmd", + ] + account = _steam_account_name() + if account: + cmd += ["--username", account] + return desktop_user_cmd(cmd, user) + + +def is_fast_installing(app_id: int) -> bool: + """Whether this process has a live detached installer run for *app_id*.""" + return any(run.app_id == app_id for run in _RUNS) + + +def _stalled(run: _DetachedRun) -> bool: + """Whether a live run has printed nothing for :data:`STALL_SECONDS`.""" + try: + return time.time() - run.log_path.stat().st_mtime > STALL_SECONDS + except OSError: + return False + + +def poll_fast_installs() -> None: + """Reap finished detached runs and interrupt stalled ones. + + SIGINT, not SIGTERM: the installer turns it into KeyboardInterrupt, and its + ``finally`` restarts the Steam client it shut down. SIGTERM would leave + Steam closed. + """ + for run in list(_RUNS): + returncode = run.proc.poll() + if returncode is None: + if _stalled(run): + logger.warning( + "steam-game-installer for AppID=%d silent for %ds — " + "interrupting (see %s).", + run.app_id, + STALL_SECONDS, + run.log_path, + ) + os.killpg(run.proc.pid, signal.SIGINT) + continue + _RUNS.remove(run) + if returncode == 0: + logger.info("steam-game-installer finished AppID=%d.", run.app_id) + continue + _FAILED.add(run.app_id) + logger.warning( + "steam-game-installer failed for AppID=%d (exit %d); falling back " + "to Steam's own download. Log: %s", + run.app_id, + returncode, + run.log_path, + ) + + +def _spawn_detached(cmd: list[str], app_id: int) -> None: + """Start the installer in its own session, output to a log file.""" + log_path = _config.CONFIG_DIR / "fast_install.log" + log_path.parent.mkdir(parents=True, exist_ok=True) + # The unit is KillMode=control-group: restarting the daemon would SIGTERM + # an installer left in its cgroup, skipping the `finally` that restarts + # Steam. A transient scope moves it out; --scope execs in place, so the + # Popen pid below is still the session leader killpg needs. + if os.geteuid() == 0 and shutil.which("systemd-run"): + cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd] + with log_path.open("ab") as log: + proc = subprocess.Popen( + cmd, + stdin=subprocess.DEVNULL, + stdout=log, + stderr=subprocess.STDOUT, + start_new_session=True, + ) + _RUNS.append(_DetachedRun(app_id, proc, log_path)) + logger.info("Started steam-game-installer for AppID=%d (log: %s)", app_id, log_path) + + +def fast_install(app_id: int, label: str) -> bool: + """Install *app_id* with steam-game-installer. + + Returns: + True if the game was installed (foreground) or the run was started + (detached); False if the caller should fall back to Steam's download. + """ + if app_id in _FAILED: + return False + user = resolve_desktop_user() + installer = _installer_path(user) + if installer is None: + logger.warning("steam-game-installer not found; using Steam's download.") + return False + reason = _refusal(app_id) + if reason is not None: + _echo(f" Fast install of {label} skipped: {reason}.") + return False + + cmd = _build_cmd(installer, app_id, user) + if not sys.stdin.isatty(): + _spawn_detached(cmd, app_id) + return True + + _echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...") + returncode = subprocess.run(cmd, check=False).returncode + if returncode == 0: + return True + _FAILED.add(app_id) + _echo( + f" steam-game-installer failed (exit {returncode}); falling back to " + f"Steam's own download. Log: {_INSTALLER_LOG}" + ) + return False diff --git a/steam_backlog_enforcer/_scanning_assign.py b/steam_backlog_enforcer/_scanning_assign.py index 742ac98..1801d5e 100644 --- a/steam_backlog_enforcer/_scanning_assign.py +++ b/steam_backlog_enforcer/_scanning_assign.py @@ -19,10 +19,10 @@ from steam_backlog_enforcer._scanning_confidence import ( _apply_cached_confidence_to_candidates, _report_poll_confidence, ) +from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.game_install import ( _echo, install_game, - is_game_installed, uninstall_other_games, ) @@ -93,7 +93,7 @@ def _assign_chosen_game( count = uninstall_other_games(allowed_app_ids(state)) if count: _echo(f"\n Uninstalled {count} non-assigned games") - if not is_game_installed(chosen.app_id): + if not is_game_fully_installed(chosen.app_id): _echo(f"\n Auto-installing {chosen.name}...") install_game( chosen.app_id, chosen.name, config.steam_id, use_steam_protocol=True diff --git a/steam_backlog_enforcer/_steam_client.py b/steam_backlog_enforcer/_steam_client.py index c1cb206..b3cd7a0 100644 --- a/steam_backlog_enforcer/_steam_client.py +++ b/steam_backlog_enforcer/_steam_client.py @@ -21,6 +21,7 @@ from steam_backlog_enforcer._desktop_env import ( ) from steam_backlog_enforcer._steam_launch import steam_is_installed from steam_backlog_enforcer._steam_process import spawn_detached +from steam_backlog_enforcer._steam_restart_guard import fast_install_running from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH logger = logging.getLogger(__name__) @@ -65,6 +66,9 @@ def _ensure_steam_running() -> None: if not steam_is_installed(): logger.info("Steam is not installed — skipping client start.") return + if fast_install_running(): + logger.info("steam-game-installer is running — leaving Steam closed.") + return # Check if any steam process is running (main client, not just helpers). try: diff --git a/steam_backlog_enforcer/_steam_errors.py b/steam_backlog_enforcer/_steam_errors.py index a6b314f..1d0ad29 100644 --- a/steam_backlog_enforcer/_steam_errors.py +++ b/steam_backlog_enforcer/_steam_errors.py @@ -50,3 +50,13 @@ class GameInProgressError(SteamUnavailableError): unrelated change killed a live session. Library hiding can wait; a player's afternoon cannot be given back. """ + + +class FastInstallInProgressError(SteamUnavailableError): + """Raised to keep Steam closed while steam-game-installer is running. + + The installer shuts Steam down because a live client owns + ``appmanifest_.acf`` and rewrites it from memory. A Steam started + underneath it discards the manifest the installer is about to write and + resumes the slow client download - so every launch path defers instead. + """ diff --git a/steam_backlog_enforcer/_steam_launch.py b/steam_backlog_enforcer/_steam_launch.py index 10eb1ad..efce2f6 100644 --- a/steam_backlog_enforcer/_steam_launch.py +++ b/steam_backlog_enforcer/_steam_launch.py @@ -31,7 +31,9 @@ from steam_backlog_enforcer._steam_errors import ( ) from steam_backlog_enforcer._steam_process import _run_as_user from steam_backlog_enforcer._steam_restart_guard import ( + assert_no_fast_install, assert_safe_to_restart, + fast_install_running, game_is_running, ) from steam_backlog_enforcer._steam_state import steam_update_in_progress @@ -55,10 +57,6 @@ def steam_is_installed() -> bool: return Path(_STEAM_BINARY).exists() -# Handles for fire-and-forget launches, kept only so they can be reaped. -_SPAWNED: list[subprocess.Popen[bytes]] = [] - - # ────────────────────────────────────────────────────────────── # Ensure Steam is running with devtools port # ────────────────────────────────────────────────────────────── @@ -170,6 +168,7 @@ def ensure_steam_debug_port() -> None: SteamUnavailableError: If Steam is not installed, or is installed but never opens its debug port. """ + assert_no_fast_install() if _steam_has_debug_port(): logger.debug("Steam CDP port already available.") return @@ -231,10 +230,10 @@ def restart_steam() -> None: ) return - if steam_update_in_progress(): + if steam_update_in_progress() or fast_install_running(): logger.warning( - "Skipping Steam restart — a game update is in progress; " - "restarting now could corrupt it.", + "Skipping Steam restart — a game update or fast install is in " + "progress; restarting now could corrupt it.", ) return diff --git a/steam_backlog_enforcer/_steam_restart_guard.py b/steam_backlog_enforcer/_steam_restart_guard.py index d192f14..df919e4 100644 --- a/steam_backlog_enforcer/_steam_restart_guard.py +++ b/steam_backlog_enforcer/_steam_restart_guard.py @@ -12,8 +12,10 @@ Split out of :mod:`_steam_launch` to keep it under the 250-line cap, and because from __future__ import annotations import logging +from pathlib import Path from steam_backlog_enforcer._steam_errors import ( + FastInstallInProgressError, GameInProgressError, SteamUpdateInProgressError, ) @@ -64,3 +66,40 @@ def assert_safe_to_restart() -> None: ) logger.info(msg) raise SteamUpdateInProgressError(msg) + + +# Matched against /proc cmdlines, so a run started by hand counts exactly like +# one the enforcer spawned. run.sh as well as the module it execs: the sudo +# and bash wrappers exist the moment Popen returns, the Python process only a +# moment later, and a Steam launch in that gap would race the shutdown. +_INSTALLER_MARKERS = (b"steam-game-installer/run.sh", b"steam_installer.cli") + + +def fast_install_running() -> bool: + """Whether a steam-game-installer run is live (and so owns Steam's state). + + Returns: + True if any process is running the installer's CLI module. + """ + for entry in Path("/proc").iterdir(): + if not entry.name.isdigit(): + continue + try: + cmdline = (entry / "cmdline").read_bytes() + except OSError: + continue + if any(marker in cmdline for marker in _INSTALLER_MARKERS): + return True + return False + + +def assert_no_fast_install() -> None: + """Refuse to launch Steam while steam-game-installer holds it closed. + + Raises: + FastInstallInProgressError: If an installer run is live. + """ + if fast_install_running(): + msg = "Deferring Steam launch: steam-game-installer is running." + logger.info(msg) + raise FastInstallInProgressError(msg) diff --git a/steam_backlog_enforcer/_steam_state.py b/steam_backlog_enforcer/_steam_state.py index 3964ff2..db38729 100644 --- a/steam_backlog_enforcer/_steam_state.py +++ b/steam_backlog_enforcer/_steam_state.py @@ -79,7 +79,7 @@ def _manifest_transfer_active(manifest: Path) -> bool: ) > _field("BytesStaged") -def steam_update_in_progress() -> bool: +def steam_update_in_progress(exclude_app_id: int | None = None) -> bool: """Return True if any installed Steam app is mid-update. Steam records live transfer progress in each ``appmanifest_*.acf``: a game @@ -90,11 +90,40 @@ def steam_update_in_progress() -> bool: failure that corrupted Age of Empires II DE into a deterministic launch crash). + Args: + exclude_app_id: An app whose own transfer does not count - the one a + fast install is about to take over. + Returns: True if at least one appmanifest shows an unfinished download/stage. """ + skip = f"appmanifest_{exclude_app_id}.acf" try: manifests = list(STEAMAPPS_PATH.glob("appmanifest_*.acf")) except OSError: return False - return any(_manifest_transfer_active(m) for m in manifests) + return any(_manifest_transfer_active(m) for m in manifests if m.name != skip) + + +def is_game_fully_installed(app_id: int) -> bool: + """Whether *app_id* is completely installed, not merely queued. + + :func:`_steam_client.is_game_installed` counts any manifest, which is right + for "has an install been triggered". This is the stricter question a fast + install needs: a manifest Steam wrote for its own slow download (no + FullyInstalled bit, or bytes still in flight) is work to take over. + + Args: + app_id: Steam application ID. + + Returns: + True if the manifest has StateFlags bit 4 and no transfer in flight. + """ + manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" + try: + content = manifest.read_text(encoding="utf-8", errors="replace") + except OSError: + return False + match = re.search(r'"StateFlags"\s+"(\d+)"', content) + flags = int(match.group(1)) if match else 0 + return bool(flags & 4) and not _manifest_transfer_active(manifest) diff --git a/steam_backlog_enforcer/game_install.py b/steam_backlog_enforcer/game_install.py index 667f9b1..4df6f18 100644 --- a/steam_backlog_enforcer/game_install.py +++ b/steam_backlog_enforcer/game_install.py @@ -13,14 +13,21 @@ from steam_backlog_enforcer._desktop_env import ( desktop_user_cmd, ) from steam_backlog_enforcer._echo import _echo +from steam_backlog_enforcer._fast_install import ( + fast_install, + is_fast_installing, + poll_fast_installs, +) from steam_backlog_enforcer._steam_client import ( _ensure_steam_running, _get_real_user, _get_uid_gid_for_user, is_game_installed, ) +from steam_backlog_enforcer._steam_restart_guard import fast_install_running from steam_backlog_enforcer._steam_state import ( STEAMAPPS_PATH, + is_game_fully_installed, steam_library_ready, ) from steam_backlog_enforcer.game_uninstall import ( @@ -49,22 +56,6 @@ __all__ = [ # rather than once per game per pass. _LIBRARY_WARNED: set[str] = set() -_UNINSTALL_EXPORTS = frozenset( - { - "get_installed_games", - "is_protected_app", - "uninstall_game", - "uninstall_other_games", - } -) - -# Folder-name safety net for _remove_game_dirs. Independent of the app-id -# gating callers already do (uninstall_other_games skips allowed app ids) -- -# this protects against deleting the *wrong* directory for an allowed game -# when its name has been written inconsistently (e.g. "KingdomComeDeliverance2" -# vs "Kingdom Come: Deliverance II" vs a typo'd variant), which is exactly the -# kind of multi-name confusion that caused real data loss once already. - def _trigger_steam_install(app_id: int, label: str) -> bool: """Ask Steam to install a game via the ``steam://install`` URI. @@ -98,6 +89,48 @@ def _trigger_steam_install(app_id: int, label: str) -> bool: # ────────────────────────────────────────────────────────────── +def _install_precheck(app_id: int, label: str) -> bool | None: + """Settle an install without starting one, where that is possible. + + Returns: + True if the game is installed or already being fast-installed, False + if no install may start right now, None to go ahead and install. + """ + # Re-arm the one-shot warning before any early return, so a library that + # comes back (Steam finally signed in) is reported again if it vanishes. + if steam_library_ready(): + _LIBRARY_WARNED.discard("missing") + + poll_fast_installs() + if is_game_fully_installed(app_id): + logger.info("Game already installed: %s", label) + return True + + # A live installer holds Steam closed; steam:// would reopen it underneath + # the manifest the installer is about to write. Wait for it to finish. + if fast_install_running(): + if is_fast_installing(app_id): + return True + logger.info("Deferring install of %s: steam-game-installer busy.", label) + return False + + # No library means no install can succeed — neither the steam:// handler + # nor the appmanifest write below. Without this the enforce loop retried + # every allowed game every pass (measured: 1656 times in 30 minutes). + if not steam_library_ready(): + if "missing" not in _LIBRARY_WARNED: + logger.warning( + "Steam library not initialised (%s missing) — skipping installs " + "until Steam has been signed in to at least once.", + STEAMAPPS_PATH, + ) + _LIBRARY_WARNED.add("missing") + else: + logger.debug("Skipping install of %s: no Steam library.", label) + return False + return None + + def install_game( app_id: int, game_name: str, @@ -105,7 +138,10 @@ def install_game( *, use_steam_protocol: bool = False, ) -> bool: - """Install a game by triggering a Steam download. + """Install a game, through steam-game-installer when it can be used. + + The fast path (:mod:`_fast_install`) is always tried first; everything + below it is the fallback for when it is refused or fails. When *use_steam_protocol* is True the ``steam://install`` URI handler is used, which lets Steam determine the correct install directory from @@ -128,30 +164,16 @@ def install_game( Returns True if the install was triggered successfully. """ label = game_name or f"AppID={app_id}" + ready = _install_precheck(app_id, label) + if ready is not None: + return ready - # Re-arm the one-shot warning before any early return, so a library that - # comes back (Steam finally signed in) is reported again if it vanishes. - if steam_library_ready(): - _LIBRARY_WARNED.discard("missing") - - if is_game_installed(app_id): - logger.info("Game already installed: %s", label) + if fast_install(app_id, label): + return True + # Steam already has a download of its own going: let it carry on, rather + # than overwrite its manifest with the fabricated one below. + if is_game_installed(app_id): return True - - # No library means no install can succeed — neither the steam:// handler - # nor the appmanifest write below. Without this the enforce loop retried - # every allowed game every pass (measured: 1656 times in 30 minutes). - if not steam_library_ready(): - if "missing" not in _LIBRARY_WARNED: - logger.warning( - "Steam library not initialised (%s missing) — skipping installs " - "until Steam has been signed in to at least once.", - STEAMAPPS_PATH, - ) - _LIBRARY_WARNED.add("missing") - else: - logger.debug("Skipping install of %s: no Steam library.", label) - return False if use_steam_protocol: _ensure_steam_running() diff --git a/steam_backlog_enforcer/game_uninstall.py b/steam_backlog_enforcer/game_uninstall.py index 3379cee..83c84b6 100644 --- a/steam_backlog_enforcer/game_uninstall.py +++ b/steam_backlog_enforcer/game_uninstall.py @@ -95,6 +95,10 @@ def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: success = True if install_dir and install_dir.is_dir(): _assert_not_real_steam(install_dir) + # Folder-name safety net, independent of the app-id gating callers + # already do: it stops deleting the *wrong* directory for an allowed + # game whose name was written inconsistently ("KingdomComeDeliverance2" + # vs "Kingdom Come: Deliverance II"), which caused real data loss once. if _is_protected_name(install_dir.name): logger.warning( "Refusing to remove %s: name matches an allowed game", install_dir diff --git a/steam_backlog_enforcer/main/install.py b/steam_backlog_enforcer/main/install.py index b834dbd..7fb3ab2 100644 --- a/steam_backlog_enforcer/main/install.py +++ b/steam_backlog_enforcer/main/install.py @@ -6,11 +6,11 @@ from typing import TYPE_CHECKING from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids +from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.game_install import ( _echo, get_installed_games, install_game, - is_game_installed, is_protected_app, uninstall_other_games, ) @@ -75,7 +75,7 @@ def cmd_install(config: Config, state: State) -> None: _echo("No game currently assigned. Run 'scan' first.") return - if is_game_installed(state.current_app_id): + if is_game_fully_installed(state.current_app_id): _echo(f"{state.current_game_name} is already installed.") return diff --git a/steam_backlog_enforcer/main/picks.py b/steam_backlog_enforcer/main/picks.py index 6b50c3b..d77570d 100644 --- a/steam_backlog_enforcer/main/picks.py +++ b/steam_backlog_enforcer/main/picks.py @@ -21,10 +21,10 @@ from steam_backlog_enforcer._pick_completion import ( warn_stale_assignment, ) from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name +from steam_backlog_enforcer._steam_state import is_game_fully_installed from steam_backlog_enforcer.game_install import ( _echo, install_game, - is_game_installed, uninstall_other_games, ) from steam_backlog_enforcer.library_hider import hide_others_and_report @@ -130,7 +130,7 @@ def _apply_allowed_set(config: Config, state: State) -> None: _echo(f" Uninstalled {count} non-allowed game(s)") for app_id, name in allowed_games(state): - if is_game_installed(app_id): + if is_game_fully_installed(app_id): _echo(f" {name} is already installed.") continue _echo(f" Installing {name}...") diff --git a/steam_backlog_enforcer/tests/_no_fast_install.py b/steam_backlog_enforcer/tests/_no_fast_install.py new file mode 100644 index 0000000..f695448 --- /dev/null +++ b/steam_backlog_enforcer/tests/_no_fast_install.py @@ -0,0 +1,56 @@ +"""Autouse guard that keeps tests away from steam-game-installer and /proc. + +Split out of ``conftest.py`` to keep it inside the 250-line cap; ``conftest`` +imports the fixture by name, which is what registers it. + +The real installer shuts the user's Steam down, so no test may find it: the +installer path is pointed at a file that does not exist. The "is an installer +running" scan reads the real /proc, so it answers False unless a test says +otherwise - and it is imported by name into several modules, so every binding +is patched. Tests that exercise the real scan import the function directly, +which captures it before this fixture runs. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import patch + +import pytest + +from steam_backlog_enforcer import _fast_install + +if TYPE_CHECKING: + from collections.abc import Iterator + from pathlib import Path + +_RUNNING_BINDINGS = ( + "steam_backlog_enforcer._steam_restart_guard.fast_install_running", + "steam_backlog_enforcer.game_install.fast_install_running", + "steam_backlog_enforcer._steam_client.fast_install_running", + "steam_backlog_enforcer._steam_launch.fast_install_running", +) + + +@pytest.fixture(autouse=True) +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() + patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS] + patches += [ + # Absolute, so `home / _INSTALLER` resolves here and never under ~. + patch.object(_fast_install, "_INSTALLER", tmp_path / "no-installer" / "run.sh"), + patch.object(_fast_install, "STEAMAPPS_PATH", fake_steamapps), + ] + for p in patches: + p.start() + try: + yield + finally: + for p in reversed(patches): + p.stop() + _fast_install._RUNS.clear() + _fast_install._FAILED.clear() diff --git a/steam_backlog_enforcer/tests/_no_subprocess.py b/steam_backlog_enforcer/tests/_no_subprocess.py index 858eb26..3d26e12 100644 --- a/steam_backlog_enforcer/tests/_no_subprocess.py +++ b/steam_backlog_enforcer/tests/_no_subprocess.py @@ -91,6 +91,15 @@ def _block_real_subprocesses() -> Iterator[None]: "steam_backlog_enforcer._playtime_run.subprocess.run", noop_run, ), + # steam-game-installer closes the user's Steam; never reach it. + patch( + "steam_backlog_enforcer._fast_install.subprocess.run", + noop_run, + ), + patch( + "steam_backlog_enforcer._fast_install.subprocess.Popen", + noop_popen, + ), # A real `npm run build` here would rewrite web/dist/index.html, which # then perturbs frontend_is_stale() for every later test in the run. patch( diff --git a/steam_backlog_enforcer/tests/conftest.py b/steam_backlog_enforcer/tests/conftest.py index 3d3c222..247fee3 100644 --- a/steam_backlog_enforcer/tests/conftest.py +++ b/steam_backlog_enforcer/tests/conftest.py @@ -23,6 +23,7 @@ import pytest # Imported for its autouse side effect: naming it here registers it. from steam_backlog_enforcer.tests._isolate_incidents import _isolate_incidents from steam_backlog_enforcer.tests._isolate_playtime import _isolate_playtime +from steam_backlog_enforcer.tests._no_fast_install import _no_fast_install from steam_backlog_enforcer.tests._no_subprocess import _block_real_subprocesses from steam_backlog_enforcer.tests._no_workout_http import _no_workout_http @@ -32,6 +33,7 @@ __all__ = [ "_block_real_subprocesses", "_isolate_incidents", "_isolate_playtime", + "_no_fast_install", "_no_workout_http", ] @@ -81,8 +83,8 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: fake_config / "snapshot.json", ), # Steam game manifests / install dirs. STEAMAPPS_PATH is canonical in - # _steam_state; game_install imports it, so both bindings need the - # patch (same gotcha as HOSTS_FILE below). + # _steam_state; game_install and _steam_client import it by name, so + # every binding needs the patch (same gotcha as HOSTS_FILE below). patch( "steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", fake_steamapps, @@ -91,6 +93,10 @@ def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: "steam_backlog_enforcer.game_install.STEAMAPPS_PATH", fake_steamapps, ), + patch( + "steam_backlog_enforcer._steam_client.STEAMAPPS_PATH", + fake_steamapps, + ), # HLTB cache file (computed at import time from CONFIG_DIR, so # patching CONFIG_DIR alone does not redirect it) patch( diff --git a/steam_backlog_enforcer/tests/test_enforce_steps.py b/steam_backlog_enforcer/tests/test_enforce_steps.py index a7f1d5a..b799146 100644 --- a/steam_backlog_enforcer/tests/test_enforce_steps.py +++ b/steam_backlog_enforcer/tests/test_enforce_steps.py @@ -124,7 +124,7 @@ class TestEnforceAutoInstall: def test_already_installed(self) -> None: state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=True), + patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=True), patch(f"{ENFORCE_STEPS_PKG}._echo"), ): _enforce_auto_install(Config(), state) @@ -132,7 +132,7 @@ class TestEnforceAutoInstall: def test_installs_successfully(self) -> None: state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=False), patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=True), patch(f"{ENFORCE_STEPS_PKG}.send_notification"), patch(f"{ENFORCE_STEPS_PKG}._echo"), @@ -142,7 +142,7 @@ class TestEnforceAutoInstall: def test_install_fails(self) -> None: state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{ENFORCE_STEPS_PKG}.is_game_installed", return_value=False), + patch(f"{ENFORCE_STEPS_PKG}.is_game_fully_installed", return_value=False), patch(f"{ENFORCE_STEPS_PKG}.install_game", return_value=False), patch(f"{ENFORCE_STEPS_PKG}._echo") as mock_echo, ): diff --git a/steam_backlog_enforcer/tests/test_fast_install_guards.py b/steam_backlog_enforcer/tests/test_fast_install_guards.py new file mode 100644 index 0000000..1c7f5cd --- /dev/null +++ b/steam_backlog_enforcer/tests/test_fast_install_guards.py @@ -0,0 +1,215 @@ +"""Tests for the guards around a fast install. + +Covers the /proc scan and launch guard in _steam_restart_guard, the +fully-installed predicate and target-app exclusion in _steam_state, and every +place that must keep Steam closed or defer while steam-game-installer runs. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +from steam_backlog_enforcer import _steam_state +from steam_backlog_enforcer._enforce_steps import _reinstall_missing_allowed +from steam_backlog_enforcer._steam_client import _ensure_steam_running +from steam_backlog_enforcer._steam_errors import ( + FastInstallInProgressError, + SteamUnavailableError, +) +from steam_backlog_enforcer._steam_launch import ensure_steam_debug_port, restart_steam + +# Bound at import, before the autouse guard replaces the module attribute, so +# the real /proc scan is what gets tested here. +from steam_backlog_enforcer._steam_restart_guard import ( + assert_no_fast_install, + fast_install_running, +) +from steam_backlog_enforcer.config import Config, State +from steam_backlog_enforcer.game_install import install_game + +if TYPE_CHECKING: + from pathlib import Path + +GUARD = "steam_backlog_enforcer._steam_restart_guard" +GI = "steam_backlog_enforcer.game_install" +LAUNCH = "steam_backlog_enforcer._steam_launch" + + +def _manifest(app_id: int, flags: int, *, to_download: int = 0) -> None: + path = _steam_state.STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" + path.write_text( + f'"AppState"\n{{\n\t"StateFlags"\t\t"{flags}"\n' + f'\t"BytesToDownload"\t\t"{to_download}"\n\t"BytesDownloaded"\t\t"0"\n}}\n' + ) + + +class TestFastInstallRunning: + """The scan matches the wrapper and the module, nothing else.""" + + def _proc(self, tmp_path: Path, cmdlines: dict[str, bytes | None]) -> Path: + proc = tmp_path / "proc" + for pid, cmdline in cmdlines.items(): + (proc / pid).mkdir(parents=True) + if cmdline is not None: + (proc / pid / "cmdline").write_bytes(cmdline) + return proc + + @pytest.mark.parametrize( + "cmdline", + [ + b"bash\0/home/u/src/steam-game-installer/run.sh\0--appid\0001", + b"python\0-m\0steam_installer.cli\0--appid\0001", + ], + ) + def test_detects_installer(self, tmp_path: Path, cmdline: bytes) -> None: + 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: + 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: + with ( + patch(f"{GUARD}.fast_install_running", return_value=True), + pytest.raises(FastInstallInProgressError), + ): + assert_no_fast_install() + + def test_assert_passes_when_idle(self) -> None: + assert_no_fast_install() + + def test_error_degrades_like_other_steam_errors(self) -> None: + assert issubclass(FastInstallInProgressError, SteamUnavailableError) + + +class TestFullyInstalled: + """Only a finished, settled manifest counts as fully installed.""" + + def test_missing(self) -> None: + assert _steam_state.is_game_fully_installed(1) is False + + def test_installed(self) -> None: + _manifest(1, 4) + assert _steam_state.is_game_fully_installed(1) is True + + def test_steam_still_downloading(self) -> None: + _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: + _manifest(1, 1044, to_download=10) + assert _steam_state.is_game_fully_installed(1) is False + + def test_no_state_flags(self) -> None: + 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_can_exclude_the_target(self) -> None: + _manifest(7, 1026, to_download=10) + assert _steam_state.steam_update_in_progress() is True + assert _steam_state.steam_update_in_progress(exclude_app_id=7) is False + + +class TestInstallGameFastPath: + """install_game prefers the installer and never reopens Steam under it.""" + + def test_own_run_in_progress(self) -> None: + with ( + patch(f"{GI}.fast_install_running", return_value=True), + patch(f"{GI}.is_fast_installing", return_value=True), + patch(f"{GI}.fast_install") as fast, + ): + assert install_game(1, "G", "sid", use_steam_protocol=True) is True + fast.assert_not_called() + + def test_other_run_defers(self) -> None: + with ( + patch(f"{GI}.fast_install_running", return_value=True), + patch(f"{GI}.is_fast_installing", return_value=False), + patch(f"{GI}._trigger_steam_install") as trigger, + ): + assert install_game(1, "G", "sid", use_steam_protocol=True) is False + trigger.assert_not_called() + + def test_fast_install_wins(self) -> None: + with ( + patch(f"{GI}.fast_install", return_value=True), + patch(f"{GI}._trigger_steam_install") as trigger, + ): + assert install_game(1, "G", "sid", use_steam_protocol=True) is True + trigger.assert_not_called() + + def test_steams_own_download_is_left_running(self) -> None: + _manifest(1, 1026, to_download=10) + before = (_steam_state.STEAMAPPS_PATH / "appmanifest_1.acf").read_text() + with ( + patch(f"{GI}.fast_install", return_value=False), + patch(f"{GI}._trigger_steam_install") as trigger, + ): + assert install_game(1, "G", "sid", use_steam_protocol=True) is True + trigger.assert_not_called() + after = (_steam_state.STEAMAPPS_PATH / "appmanifest_1.acf").read_text() + assert after == before + + +class TestSteamStaysClosed: + """Every Steam start path defers to a live installer.""" + + def test_ensure_steam_running(self) -> None: + with ( + patch( + "steam_backlog_enforcer._steam_client.steam_is_installed", + return_value=True, + ), + patch( + "steam_backlog_enforcer._steam_client.fast_install_running", + return_value=True, + ), + patch("steam_backlog_enforcer._steam_client.spawn_detached") as spawn, + ): + _ensure_steam_running() + spawn.assert_not_called() + + def test_debug_port(self) -> None: + with ( + patch(f"{GUARD}.fast_install_running", return_value=True), + patch(f"{LAUNCH}._launch_steam_with_debug") as launch, + pytest.raises(FastInstallInProgressError), + ): + ensure_steam_debug_port() + launch.assert_not_called() + + def test_restart(self) -> None: + with ( + patch(f"{LAUNCH}.game_is_running", return_value=False), + patch(f"{LAUNCH}.steam_update_in_progress", return_value=False), + patch(f"{LAUNCH}.fast_install_running", return_value=True), + patch(f"{LAUNCH}._shutdown_steam") as shutdown, + ): + restart_steam() + shutdown.assert_not_called() + + +class TestReinstallPollsRuns: + """The loop reaps detached runs even when no game is missing.""" + + def test_polls_every_pass(self) -> None: + with ( + patch( + "steam_backlog_enforcer._enforce_steps.steam_library_ready", + return_value=True, + ), + patch( + "steam_backlog_enforcer._enforce_steps.allowed_games", return_value=[] + ), + patch("steam_backlog_enforcer._enforce_steps.poll_fast_installs") as poll, + ): + _reinstall_missing_allowed(Config(), MagicMock(spec=State)) + poll.assert_called_once() diff --git a/steam_backlog_enforcer/tests/test_fast_install_helpers.py b/steam_backlog_enforcer/tests/test_fast_install_helpers.py new file mode 100644 index 0000000..ca31691 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_fast_install_helpers.py @@ -0,0 +1,136 @@ +"""Tests for _fast_install's pure helpers: paths, account, refusal, argv. + +The run lifecycle (spawn, poll, fast_install) lives in test_fast_install_runs.py +to keep both files under the 250-line cap. +""" + +from __future__ import annotations + +from pathlib import Path +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer import _fast_install + +PKG = "steam_backlog_enforcer._fast_install" + + +class TestInstallerPath: + """run.sh is resolved from the desktop user's passwd home.""" + + def test_found_in_user_home(self, tmp_path: Path) -> None: + installer = tmp_path / "src" / "steam-game-installer" / "run.sh" + installer.parent.mkdir(parents=True) + installer.write_text("#!/bin/bash\n") + # The autouse guard points _INSTALLER at a dead absolute path; put the + # real relative one back so the home join itself is what is tested. + relative = Path("src", "steam-game-installer", "run.sh") + with ( + patch.object(_fast_install, "_INSTALLER", relative), + patch(f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path)), + ): + assert _fast_install._installer_path("kuhy") == installer + + def test_missing_file_is_none(self, tmp_path: Path) -> None: + with patch( + f"{PKG}.pwd.getpwnam", return_value=SimpleNamespace(pw_dir=tmp_path) + ): + assert _fast_install._installer_path("kuhy") is None + + def test_unknown_user_is_none(self) -> None: + with patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("nobody")): + assert _fast_install._installer_path("nobody") is None + + def test_no_user_uses_own_home(self, tmp_path: Path) -> None: + with patch(f"{PKG}.Path.home", return_value=tmp_path): + assert _fast_install._installer_path(None) is None + + +class TestSteamAccountName: + """The username comes from Steam's own loginusers.vdf.""" + + def _write(self, content: str) -> None: + config = _fast_install.STEAMAPPS_PATH.parent / "config" + config.mkdir(parents=True, exist_ok=True) + (config / "loginusers.vdf").write_text(content) + + def test_reads_account_name(self) -> None: + self._write('"users"\n{\n\t"7656"\n\t{\n\t\t"AccountName"\t\t"krzys"\n') + assert _fast_install._steam_account_name() == "krzys" + + def test_no_account_field(self) -> None: + self._write('"users"\n{\n}\n') + assert _fast_install._steam_account_name() is None + + def test_missing_file(self) -> None: + assert _fast_install._steam_account_name() is None + + +class TestRefusal: + """Closing Steam is refused while it would destroy something.""" + + def test_game_running(self) -> None: + with patch(f"{PKG}.game_is_running", return_value=True): + assert "game is running" in (_fast_install._refusal(1) or "") + + def test_other_update_in_progress(self) -> None: + with ( + patch(f"{PKG}.game_is_running", return_value=False), + patch(f"{PKG}.steam_update_in_progress", return_value=True) as update, + ): + assert "download is in progress" in (_fast_install._refusal(7) or "") + update.assert_called_once_with(exclude_app_id=7) + + def test_safe(self) -> None: + with ( + patch(f"{PKG}.game_is_running", return_value=False), + patch(f"{PKG}.steam_update_in_progress", return_value=False), + ): + assert _fast_install._refusal(7) is None + + +class TestBuildCmd: + """The argv pins every choice the installer would otherwise prompt for.""" + + def test_with_account(self) -> None: + with ( + patch(f"{PKG}._steam_account_name", return_value="krzys"), + patch(f"{PKG}.desktop_user_cmd", side_effect=lambda cmd, _u: cmd), + ): + cmd = _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy") + assert cmd == [ + "/i/run.sh", + "--no-deps", + "--appid", + "42", + "--yes", + "--language", + "english", + "--skip-steamcmd", + "--username", + "krzys", + ] + + def test_without_account_and_drops_privileges(self) -> None: + with ( + patch(f"{PKG}._steam_account_name", return_value=None), + patch(f"{PKG}.desktop_user_cmd", return_value=["wrapped"]) as wrap, + ): + assert _fast_install._build_cmd(Path("/i/run.sh"), 42, "kuhy") == [ + "wrapped" + ] + argv, user = wrap.call_args.args + assert "--username" not in argv + assert user == "kuhy" + + +class TestIsFastInstalling: + """Only this process's own live runs count.""" + + def test_tracks_runs(self) -> None: + assert _fast_install.is_fast_installing(5) is False + _fast_install._RUNS.append( + _fast_install._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_runs.py b/steam_backlog_enforcer/tests/test_fast_install_runs.py new file mode 100644 index 0000000..f122f5a --- /dev/null +++ b/steam_backlog_enforcer/tests/test_fast_install_runs.py @@ -0,0 +1,179 @@ +"""Tests for _fast_install's run lifecycle: spawn, poll, and fast_install. + +The pure helpers live in test_fast_install_helpers.py to keep both files under +the 250-line cap. Every subprocess here is a mock (see _no_subprocess.py). +""" + +from __future__ import annotations + +import os +import signal +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +from steam_backlog_enforcer import _fast_install, config + +if TYPE_CHECKING: + from pathlib import Path + +PKG = "steam_backlog_enforcer._fast_install" + + +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)) + return proc + + +class TestPollFastInstalls: + """Detached runs are reaped, failures remembered, stalls interrupted.""" + + def test_success_is_reaped(self, tmp_path: Path) -> None: + _run(1, 0, tmp_path / "log") + _fast_install.poll_fast_installs() + assert _fast_install._RUNS == [] + assert set() == _fast_install._FAILED + + def test_failure_is_remembered(self, tmp_path: Path) -> None: + _run(2, 3, tmp_path / "log") + _fast_install.poll_fast_installs() + assert _fast_install._RUNS == [] + assert {2} == _fast_install._FAILED + + def test_live_and_chatty_is_left_alone(self, tmp_path: Path) -> None: + log = tmp_path / "log" + log.write_text("progress\n") + _run(3, None, log) + with patch(f"{PKG}.os.killpg") as killpg: + _fast_install.poll_fast_installs() + killpg.assert_not_called() + assert _fast_install.is_fast_installing(3) + + def test_live_without_log_is_left_alone(self, tmp_path: Path) -> None: + _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: + log = tmp_path / "log" + log.write_text("Enter account password: ") + old = log.stat().st_mtime - _fast_install.STALL_SECONDS - 60 + os.utime(log, (old, old)) + _run(5, None, log) + with patch(f"{PKG}.os.killpg") as killpg: + _fast_install.poll_fast_installs() + # SIGINT so the installer's `finally` restarts Steam; kept tracked + # until it actually exits. + killpg.assert_called_once_with(4321, signal.SIGINT) + assert _fast_install.is_fast_installing(5) + + +class TestSpawnDetached: + """The daemon's spawn: own session, no stdin, output to the log.""" + + def test_as_user_spawns_directly(self) -> None: + with ( + patch(f"{PKG}.os.geteuid", return_value=1000), + patch(f"{PKG}.subprocess.Popen") as popen, + ): + _fast_install._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] + assert run.app_id == 9 + assert run.log_path == config.CONFIG_DIR / "fast_install.log" + assert run.log_path.exists() + + def test_as_root_escapes_the_service_cgroup(self) -> None: + 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) + assert popen.call_args.args[0] == [ + "systemd-run", + "--scope", + "--quiet", + "--collect", + "run.sh", + ] + + def test_as_root_without_systemd_run(self) -> None: + 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) + assert popen.call_args.args[0] == ["run.sh"] + + +class TestFastInstall: + """fast_install picks a mode, or tells the caller to fall back.""" + + def _ready(self, tmp_path: Path) -> 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.add(7) + 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: + # 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: + with ( + patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)), + patch(f"{PKG}._refusal", return_value="a game is running"), + patch(f"{PKG}._echo") as echo, + patch(f"{PKG}.subprocess.run") as run, + ): + assert _fast_install.fast_install(7, "G") is False + run.assert_not_called() + assert "a game is running" in echo.call_args.args[0] + + def test_no_terminal_spawns_detached(self, tmp_path: Path) -> None: + with ( + patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)), + patch(f"{PKG}._refusal", return_value=None), + patch(f"{PKG}.sys.stdin.isatty", return_value=False), + patch(f"{PKG}._spawn_detached") as spawn, + patch(f"{PKG}.subprocess.run") as run, + ): + assert _fast_install.fast_install(7, "G") is True + spawn.assert_called_once() + run.assert_not_called() + + def test_terminal_runs_in_foreground(self, tmp_path: Path) -> None: + with ( + patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)), + patch(f"{PKG}._refusal", return_value=None), + patch(f"{PKG}.sys.stdin.isatty", return_value=True), + patch(f"{PKG}._echo"), + patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=0)), + ): + assert _fast_install.fast_install(7, "G") is True + assert set() == _fast_install._FAILED + + def test_foreground_failure_falls_back(self, tmp_path: Path) -> None: + with ( + patch(f"{PKG}._installer_path", return_value=self._ready(tmp_path)), + patch(f"{PKG}._refusal", return_value=None), + patch(f"{PKG}.sys.stdin.isatty", return_value=True), + patch(f"{PKG}._echo") as echo, + patch(f"{PKG}.subprocess.run", return_value=MagicMock(returncode=2)), + ): + assert _fast_install.fast_install(7, "G") is False + assert {7} == _fast_install._FAILED + assert "exit 2" in echo.call_args.args[0] diff --git a/steam_backlog_enforcer/tests/test_game_install_part3.py b/steam_backlog_enforcer/tests/test_game_install_part3.py index ed05d20..62ddc42 100644 --- a/steam_backlog_enforcer/tests/test_game_install_part3.py +++ b/steam_backlog_enforcer/tests/test_game_install_part3.py @@ -23,10 +23,13 @@ class TestInstallGame: """Tests for install_game.""" def test_already_installed(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.touch() - with patch("steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path): + (tmp_path / "appmanifest_440.acf").write_text('"StateFlags"\t\t"4"\n') + with ( + patch("steam_backlog_enforcer._steam_state.STEAMAPPS_PATH", tmp_path), + patch(f"{PKG}.fast_install") as fast, + ): assert install_game(440, "TF2", "steam123") is True + fast.assert_not_called() def test_use_steam_protocol_success(self, tmp_path: Path) -> None: with ( diff --git a/steam_backlog_enforcer/tests/test_main_install.py b/steam_backlog_enforcer/tests/test_main_install.py index 10e1a57..1f3de80 100644 --- a/steam_backlog_enforcer/tests/test_main_install.py +++ b/steam_backlog_enforcer/tests/test_main_install.py @@ -85,7 +85,7 @@ class TestCmdInstall: def test_already_installed(self) -> None: state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch(f"{PKG}._echo"), ): cmd_install(Config(), state) @@ -93,7 +93,7 @@ class TestCmdInstall: def test_installs_ok(self) -> None: state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.is_game_fully_installed", return_value=False), patch(f"{PKG}.install_game", return_value=True), patch(f"{PKG}._echo"), ): @@ -102,7 +102,7 @@ class TestCmdInstall: def test_install_fails(self) -> None: state = State(current_app_id=1, current_game_name="G") with ( - patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.is_game_fully_installed", return_value=False), patch(f"{PKG}.install_game", return_value=False), patch(f"{PKG}._echo"), ): diff --git a/steam_backlog_enforcer/tests/test_main_part2.py b/steam_backlog_enforcer/tests/test_main_part2.py index 5ca7a59..3867728 100644 --- a/steam_backlog_enforcer/tests/test_main_part2.py +++ b/steam_backlog_enforcer/tests/test_main_part2.py @@ -83,7 +83,7 @@ class TestFinalizeCompletion: "steam_backlog_enforcer.library_hider.try_hide_other_games", return_value=(0, "update in progress"), ), - patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True), + patch(f"{CMD_DONE_PKG}.is_game_fully_installed", return_value=True), patch(f"{CMD_DONE_PKG}.send_notification"), patch.object(State, "save"), ): diff --git a/steam_backlog_enforcer/tests/test_main_part2b.py b/steam_backlog_enforcer/tests/test_main_part2b.py index fb34747..92b1d44 100644 --- a/steam_backlog_enforcer/tests/test_main_part2b.py +++ b/steam_backlog_enforcer/tests/test_main_part2b.py @@ -50,7 +50,7 @@ class TestEnforceOnDone: return_value=[(1234, 999)], ), patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2), - patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True), + 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], @@ -72,7 +72,7 @@ class TestEnforceOnDone: 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_installed", return_value=True), + 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=[], @@ -91,7 +91,7 @@ class TestEnforceOnDone: 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}.is_game_installed", return_value=True), + 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], @@ -115,7 +115,7 @@ class TestEnforceOnDone: state = State(current_app_id=1, current_game_name="G") with ( patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False), + 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", diff --git a/steam_backlog_enforcer/tests/test_main_part2c.py b/steam_backlog_enforcer/tests/test_main_part2c.py index ec7774c..da5a0ed 100644 --- a/steam_backlog_enforcer/tests/test_main_part2c.py +++ b/steam_backlog_enforcer/tests/test_main_part2c.py @@ -101,7 +101,7 @@ class TestFinalizeCompletionRetries: "steam_backlog_enforcer.library_hider.try_hide_other_games", return_value=(1, None), ), - patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False), + 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"), patch.object(State, "save"), @@ -137,7 +137,7 @@ class TestFinalizeCompletionRetries: "steam_backlog_enforcer.library_hider.try_hide_other_games", return_value=(1, None), ), - patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True), + 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"), patch.object(State, "save"), diff --git a/steam_backlog_enforcer/tests/test_main_pick_cap.py b/steam_backlog_enforcer/tests/test_main_pick_cap.py index a94c985..c064568 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_cap.py +++ b/steam_backlog_enforcer/tests/test_main_pick_cap.py @@ -27,7 +27,7 @@ class TestPickManualCap: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -61,7 +61,7 @@ class TestPickManualCap: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -84,7 +84,7 @@ class TestPickManualCap: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0) as mock_uninstall, - patch(f"{PKG}.is_game_installed", return_value=True), + 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], @@ -107,7 +107,7 @@ class TestPickManualCap: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.is_game_fully_installed", return_value=False), patch(f"{PKG}.install_game") as mock_install, patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", diff --git a/steam_backlog_enforcer/tests/test_main_pick_manual.py b/steam_backlog_enforcer/tests/test_main_pick_manual.py index 0aa42b6..5e0cfa8 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_manual.py +++ b/steam_backlog_enforcer/tests/test_main_pick_manual.py @@ -44,7 +44,7 @@ class TestCmdPickManual: patch("builtins.input", side_effect=["489830", "YES"]), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -62,7 +62,7 @@ class TestCmdPickManual: patch("builtins.input", return_value="YES"), patch.object(State, "save") as mock_save, patch(f"{PKG}.uninstall_other_games", return_value=2) as mock_uninstall, - patch(f"{PKG}.is_game_installed", return_value=False), + patch(f"{PKG}.is_game_fully_installed", return_value=False), patch(f"{PKG}.install_game") as mock_install, patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", @@ -92,7 +92,7 @@ class TestCmdPickManual: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -110,7 +110,7 @@ class TestCmdPickManual: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], 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 460907d..058827f 100644 --- a/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py +++ b/steam_backlog_enforcer/tests/test_main_pick_manual_cascade.py @@ -24,7 +24,7 @@ class TestPickManualCascade: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games") as mock_uninstall, - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -41,7 +41,7 @@ class TestPickManualCascade: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + 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", @@ -59,7 +59,7 @@ class TestPickManualCascade: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -80,7 +80,7 @@ class TestPickManualCascade: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + patch(f"{PKG}.is_game_fully_installed", return_value=True), patch( "steam_backlog_enforcer.library_hider.get_all_owned_app_ids", return_value=[], @@ -98,7 +98,7 @@ class TestPickManualCascade: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + 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], @@ -125,7 +125,7 @@ class TestPickManualCascade: patch("builtins.input", return_value="YES"), patch.object(State, "save"), patch(f"{PKG}.uninstall_other_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), + 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], 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 cd712a9..c1daa35 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 @@ -63,7 +63,7 @@ class TestPickNextGameGroup2: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -100,7 +100,7 @@ class TestPickNextGameGroup2: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -127,7 +127,7 @@ class TestPickNextGameGroup2: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -155,7 +155,7 @@ class TestPickNextGameGroup2: side_effect=lambda *a, **_: echoed.append(a[0]), ), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -184,7 +184,7 @@ class TestPickNextGameGroup2: side_effect=lambda *a, **_: echoed.append(a[0]), ), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( diff --git a/steam_backlog_enforcer/tests/test_scanning_part3.py b/steam_backlog_enforcer/tests/test_scanning_part3.py index 618298a..73c43c0 100644 --- a/steam_backlog_enforcer/tests/test_scanning_part3.py +++ b/steam_backlog_enforcer/tests/test_scanning_part3.py @@ -66,7 +66,7 @@ class TestPickNextGame: side_effect=lambda *a, **_: echoed.append(a[0]), ), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -101,7 +101,7 @@ class TestPickNextGame: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( 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 26d4615..fe8dc8b 100644 --- a/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py +++ b/steam_backlog_enforcer/tests/test_scanning_pick_next_game.py @@ -52,7 +52,7 @@ class TestPickNextGame: patch("steam_backlog_enforcer.scanning._echo"), patch("steam_backlog_enforcer._scanning_confidence._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -86,7 +86,7 @@ class TestPickNextGame: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -130,7 +130,7 @@ class TestPickNextGame: return_value=2, ), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch("builtins.input", return_value="1"), @@ -151,7 +151,7 @@ class TestPickNextGame: patch("steam_backlog_enforcer.scanning._echo"), patch("steam_backlog_enforcer._scanning_confidence._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=False, ), patch( @@ -182,7 +182,7 @@ class TestAssignUninstallsAgainstTheAllowedSet: patch("steam_backlog_enforcer.scanning._echo"), patch("steam_backlog_enforcer._scanning_confidence._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( 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 59990a4..773dd6d 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 @@ -50,7 +50,7 @@ class TestPickNextGameSequential: ) stack.enter_context( patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ) ) 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 38730c8..f97afa7 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 @@ -46,7 +46,7 @@ class TestPickNextGameGroup2: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -70,7 +70,7 @@ class TestPickNextGameGroup2: ), patch("steam_backlog_enforcer.scanning._echo"), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch( @@ -107,7 +107,7 @@ class TestPickNextGameGroup2: side_effect=lambda *a, **_: echoed.append(a[0]), ), patch( - "steam_backlog_enforcer._scanning_assign.is_game_installed", + "steam_backlog_enforcer._scanning_assign.is_game_fully_installed", return_value=True, ), patch(