mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +01:00
fix: fast install ignores other downloads; install offers any allowed game
- Fast install no longer refuses while Steam downloads something else: the installer leaves via `steam -shutdown`, which Steam resumes from. Only a running game still refuses. - Non-daemon detached runs (web jobs) wait for the installer with the same stall reaper the daemon uses, and log to a per-uid file: the shared root-owned log made every job spawn raise. - `install` offers every allowed game that is not fully installed (assignment + manual picks), so a skipped fast install can be started later; it takes over Steam's slow download. - Uninstall progress names only removed games; protected runtimes are logged as kept instead of appearing as the last "uninstalled" item. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_017fHzMuPSHq68S2AcPqGe3Q
This commit is contained in:
parent
2735c00716
commit
c31ae2170f
@ -18,7 +18,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
|
||||
"check": "Check assigned game for a new achievement",
|
||||
"status": "Show current status",
|
||||
"list": "List games from snapshot",
|
||||
"install": "Install the assigned game",
|
||||
"install": "Install an allowed game (fast installer first)",
|
||||
"hide": "Hide all non-assigned games in library",
|
||||
"unhide": "Unhide all games in library",
|
||||
"buy-dlc": "Unblock the store for 15 min to buy a game/DLC",
|
||||
|
||||
@ -10,14 +10,18 @@ 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.
|
||||
* **Detached** (no terminal): spawn in its own session. The root daemon
|
||||
returns at once and reaps it on every later pass 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. Anything else (a web job) exits once its command returns, so it
|
||||
runs that same reaper in a loop until the installer is done.
|
||||
|
||||
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).
|
||||
game is running. Another app mid-download is no reason to wait: the installer
|
||||
asks Steam to leave with ``steam -shutdown``, and Steam resumes the transfer
|
||||
when it comes back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -38,10 +42,7 @@ 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,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_state import STEAMAPPS_PATH
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@ -53,6 +54,8 @@ _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
|
||||
# How often a waiting (non-daemon) caller reaps its run.
|
||||
_WAIT_POLL_SECONDS = 5
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -100,14 +103,10 @@ def _steam_account_name() -> str | None:
|
||||
return match.group(1) if match else None
|
||||
|
||||
|
||||
def _refusal(app_id: int) -> str | None:
|
||||
def _refusal() -> 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
|
||||
|
||||
|
||||
@ -181,7 +180,9 @@ def poll_fast_installs() -> None:
|
||||
|
||||
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"
|
||||
# Per user: the daemon's log is root-owned, and a web job runs as the
|
||||
# desktop user - sharing one file made every job spawn raise.
|
||||
log_path = _config.CONFIG_DIR / f"fast_install-{os.geteuid()}.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
|
||||
@ -201,6 +202,18 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None:
|
||||
logger.info("Started steam-game-installer for AppID=%d (log: %s)", app_id, log_path)
|
||||
|
||||
|
||||
def _wait_detached(app_id: int, label: str) -> bool:
|
||||
"""Reap this process's run for *app_id* until it ends; True on success."""
|
||||
_echo(
|
||||
f" Fast-installing {label} with steam-game-installer (Steam will close); "
|
||||
"waiting for it to finish..."
|
||||
)
|
||||
while is_fast_installing(app_id):
|
||||
time.sleep(_WAIT_POLL_SECONDS)
|
||||
poll_fast_installs()
|
||||
return app_id not in _FAILED
|
||||
|
||||
|
||||
def fast_install(app_id: int, label: str) -> bool:
|
||||
"""Install *app_id* with steam-game-installer.
|
||||
|
||||
@ -215,7 +228,7 @@ def fast_install(app_id: int, label: str) -> bool:
|
||||
if installer is None:
|
||||
logger.warning("steam-game-installer not found; using Steam's download.")
|
||||
return False
|
||||
reason = _refusal(app_id)
|
||||
reason = _refusal()
|
||||
if reason is not None:
|
||||
_echo(f" Fast install of {label} skipped: {reason}.")
|
||||
return False
|
||||
@ -223,7 +236,7 @@ def fast_install(app_id: int, label: str) -> bool:
|
||||
cmd = _build_cmd(installer, app_id, user)
|
||||
if not sys.stdin.isatty():
|
||||
_spawn_detached(cmd, app_id)
|
||||
return True
|
||||
return os.geteuid() == 0 or _wait_detached(app_id, label)
|
||||
|
||||
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")
|
||||
returncode = subprocess.run(cmd, check=False).returncode
|
||||
|
||||
@ -79,7 +79,7 @@ def _manifest_transfer_active(manifest: Path) -> bool:
|
||||
) > _field("BytesStaged")
|
||||
|
||||
|
||||
def steam_update_in_progress(exclude_app_id: int | None = None) -> bool:
|
||||
def steam_update_in_progress() -> bool:
|
||||
"""Return True if any installed Steam app is mid-update.
|
||||
|
||||
Steam records live transfer progress in each ``appmanifest_*.acf``: a game
|
||||
@ -90,19 +90,14 @@ def steam_update_in_progress(exclude_app_id: int | None = None) -> 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 if m.name != skip)
|
||||
return any(_manifest_transfer_active(m) for m in manifests)
|
||||
|
||||
|
||||
def is_game_fully_installed(app_id: int) -> bool:
|
||||
|
||||
@ -146,17 +146,22 @@ def uninstall_other_games(allowed_app_ids: set[int]) -> int:
|
||||
|
||||
Returns: number of games uninstalled.
|
||||
"""
|
||||
installed = get_installed_games()
|
||||
count = 0
|
||||
|
||||
for app_id, name in tracked(installed, "Uninstalling games", lambda g: g[1]):
|
||||
to_remove: list[tuple[int, str]] = []
|
||||
kept_protected: list[str] = []
|
||||
for app_id, name in get_installed_games():
|
||||
if app_id in allowed_app_ids:
|
||||
logger.info("KEEPING allowed game: %s (AppID=%d)", name, app_id)
|
||||
continue
|
||||
if is_protected_app(app_id):
|
||||
logger.debug("Skipping protected: %s (AppID=%d)", name, app_id)
|
||||
continue
|
||||
elif is_protected_app(app_id):
|
||||
kept_protected.append(name)
|
||||
else:
|
||||
to_remove.append((app_id, name))
|
||||
if kept_protected:
|
||||
logger.info("KEEPING protected: %s", ", ".join(kept_protected))
|
||||
|
||||
# Only removals go through the progress bar: it names the item it just
|
||||
# finished, and naming a kept runtime there reads as "removed it".
|
||||
count = 0
|
||||
for app_id, name in tracked(to_remove, "Uninstalling games", lambda g: g[1]):
|
||||
logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id)
|
||||
if uninstall_game(app_id, name):
|
||||
count += 1
|
||||
|
||||
@ -7,7 +7,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._progress import current_progress
|
||||
from steam_backlog_enforcer._prompter import confirm_phrase
|
||||
from steam_backlog_enforcer._prompter import (
|
||||
PromptOption,
|
||||
confirm_phrase,
|
||||
current_prompter,
|
||||
)
|
||||
from steam_backlog_enforcer._steam_errors import SteamUnavailableError
|
||||
from steam_backlog_enforcer._steam_state import is_game_fully_installed
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
@ -73,29 +77,48 @@ def cmd_uninstall(_config: Config, state: State) -> None:
|
||||
_echo(f"\nUninstalled {count} games.")
|
||||
|
||||
|
||||
def _pick_install_target(state: State) -> tuple[int, str] | None:
|
||||
"""The allowed game to install: the only unfinished one, or the user's pick.
|
||||
|
||||
Every allowed game counts, not just the assignment: a manual pick whose
|
||||
fast install was skipped (a game was running at pick time) needs a way to
|
||||
start it later too.
|
||||
"""
|
||||
pending = [
|
||||
(aid, n) for aid, n in allowed_games(state) if not is_game_fully_installed(aid)
|
||||
]
|
||||
if not pending:
|
||||
_echo("All allowed games are already installed.")
|
||||
return None
|
||||
if len(pending) == 1:
|
||||
return pending[0]
|
||||
options = [PromptOption(str(aid), name or f"AppID={aid}") for aid, name in pending]
|
||||
picked = int(current_prompter().choice("Install which game", options))
|
||||
return next(game for game in pending if game[0] == picked)
|
||||
|
||||
|
||||
def cmd_install(config: Config, state: State) -> None:
|
||||
"""Manually trigger install of the assigned game."""
|
||||
if state.current_app_id is None:
|
||||
"""Install an allowed game, through the fast installer when it can be used.
|
||||
|
||||
A download Steam already started is taken over, so this is also how a
|
||||
fast install that was skipped earlier gets started.
|
||||
"""
|
||||
if not allowed_games(state):
|
||||
_echo("No game currently assigned. Run 'scan' first.")
|
||||
return
|
||||
|
||||
if is_game_fully_installed(state.current_app_id):
|
||||
_echo(f"{state.current_game_name} is already installed.")
|
||||
target = _pick_install_target(state)
|
||||
if target is None:
|
||||
return
|
||||
app_id, name = target
|
||||
|
||||
_echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...")
|
||||
_echo(f"Installing {name} (AppID={app_id})...")
|
||||
# install_game only *starts* the download (fast installer or steam://);
|
||||
# there is no download-progress loop to report on yet.
|
||||
current_progress().phase(f"Starting install of {state.current_game_name}")
|
||||
if install_game(
|
||||
state.current_app_id,
|
||||
state.current_game_name,
|
||||
config.steam_id,
|
||||
use_steam_protocol=True,
|
||||
):
|
||||
current_progress().phase(f"Starting install of {name}")
|
||||
if install_game(app_id, name, config.steam_id, use_steam_protocol=True):
|
||||
_echo("Done!")
|
||||
else:
|
||||
_echo("Failed to create install manifest.")
|
||||
_echo("Failed to start the install.")
|
||||
|
||||
|
||||
def cmd_hide(config: Config, state: State) -> None:
|
||||
|
||||
@ -111,10 +111,9 @@ class TestFullyInstalled:
|
||||
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:
|
||||
def test_update_check_sees_a_transfer(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:
|
||||
|
||||
@ -10,7 +10,7 @@ from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install
|
||||
from steam_backlog_enforcer import _fast_install, _steam_state
|
||||
|
||||
PKG = "steam_backlog_enforcer._fast_install"
|
||||
|
||||
@ -71,22 +71,18 @@ class TestRefusal:
|
||||
|
||||
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 "")
|
||||
assert "game is running" in (_fast_install._refusal() 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
|
||||
def test_other_download_does_not_refuse(self) -> None:
|
||||
# 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(
|
||||
'"AppState"\n{\n\t"BytesToDownload"\t\t"10"\n'
|
||||
'\t"BytesDownloaded"\t\t"0"\n}\n'
|
||||
)
|
||||
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
|
||||
|
||||
|
||||
class TestBuildCmd:
|
||||
|
||||
84
steam_backlog_enforcer/tests/test_fast_install_job_wait.py
Normal file
84
steam_backlog_enforcer/tests/test_fast_install_job_wait.py
Normal file
@ -0,0 +1,84 @@
|
||||
"""Tests for a fast install started outside the daemon (a web job, a pipe).
|
||||
|
||||
Only the root daemon reaps detached runs on its later passes. Any other
|
||||
process exits once the command returns, so it must wait for the installer
|
||||
itself - otherwise a stalled login prompt would hold Steam closed with
|
||||
nobody left to interrupt it. Its log must also be its own: the daemon's
|
||||
log is root-owned, and opening it as the desktop user used to raise.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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 _ready(tmp_path: Path) -> Path:
|
||||
installer = tmp_path / "run.sh"
|
||||
installer.write_text("#!/bin/bash\n")
|
||||
return installer
|
||||
|
||||
|
||||
def _fake_spawn(*returncodes: int | None) -> MagicMock:
|
||||
"""A ``_spawn_detached`` that registers a run answering *returncodes*."""
|
||||
|
||||
def spawn(_cmd: list[str], app_id: int) -> None:
|
||||
proc = MagicMock(pid=4321)
|
||||
proc.poll.side_effect = list(returncodes)
|
||||
_fast_install._RUNS.append(
|
||||
_fast_install._DetachedRun(app_id, proc, config.CONFIG_DIR / "x.log")
|
||||
)
|
||||
|
||||
return MagicMock(side_effect=spawn)
|
||||
|
||||
|
||||
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:
|
||||
with (
|
||||
patch(f"{PKG}._installer_path", return_value=_ready(tmp_path)),
|
||||
patch(f"{PKG}._refusal", return_value=None),
|
||||
patch(f"{PKG}.sys.stdin.isatty", return_value=False),
|
||||
patch(f"{PKG}.os.geteuid", return_value=euid),
|
||||
patch(f"{PKG}._spawn_detached", spawn),
|
||||
patch(f"{PKG}.time.sleep"),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
return _fast_install.fast_install(7, "G")
|
||||
|
||||
def test_success_after_waiting(self, tmp_path: Path) -> None:
|
||||
assert self._install(tmp_path, _fake_spawn(None, None, 0), 1000) is True
|
||||
assert _fast_install._RUNS == []
|
||||
|
||||
def test_failure_falls_back(self, tmp_path: Path) -> None:
|
||||
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
|
||||
assert {7} == _fast_install._FAILED
|
||||
|
||||
def test_daemon_returns_at_once(self, tmp_path: Path) -> None:
|
||||
assert self._install(tmp_path, _fake_spawn(), 0) is True
|
||||
assert _fast_install.is_fast_installing(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:
|
||||
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
daemon_log = config.CONFIG_DIR / "fast_install.log"
|
||||
daemon_log.write_text("root's\n")
|
||||
daemon_log.chmod(0o400)
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}.subprocess.Popen") as popen,
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
popen.assert_called_once()
|
||||
assert _fast_install._RUNS[0].log_path != daemon_log
|
||||
@ -86,7 +86,7 @@ class TestSpawnDetached:
|
||||
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 == config.CONFIG_DIR / "fast_install-1000.log"
|
||||
assert run.log_path.exists()
|
||||
|
||||
def test_as_root_escapes_the_service_cgroup(self) -> None:
|
||||
|
||||
@ -12,6 +12,7 @@ from steam_backlog_enforcer.game_uninstall import (
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
from pathlib import Path
|
||||
|
||||
PKG = "steam_backlog_enforcer.game_install"
|
||||
@ -165,3 +166,31 @@ class TestUninstallOtherGames:
|
||||
count = uninstall_other_games({440})
|
||||
assert count == 0
|
||||
mock_uninstall.assert_not_called()
|
||||
|
||||
|
||||
class TestUninstallProgressNames:
|
||||
"""The progress bar names only games that are actually removed."""
|
||||
|
||||
def test_protected_and_allowed_never_shown(self) -> None:
|
||||
shown: list[str] = []
|
||||
|
||||
def fake_tracked(
|
||||
items: list[tuple[int, str]],
|
||||
_label: str,
|
||||
name: Callable[[tuple[int, str]], str],
|
||||
) -> Iterator[tuple[int, str]]:
|
||||
for item in items:
|
||||
yield item
|
||||
shown.append(name(item))
|
||||
|
||||
with (
|
||||
patch(
|
||||
f"{GAME_UNINSTALL_PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (730, "CS"), (228980, "Redist")],
|
||||
),
|
||||
patch(f"{GAME_UNINSTALL_PKG}.tracked", side_effect=fake_tracked),
|
||||
patch(f"{GAME_UNINSTALL_PKG}.uninstall_game", return_value=True),
|
||||
):
|
||||
count = uninstall_other_games({440})
|
||||
assert count == 1
|
||||
assert shown == ["CS"]
|
||||
|
||||
62
steam_backlog_enforcer/tests/test_main_install_pick.py
Normal file
62
steam_backlog_enforcer/tests/test_main_install_pick.py
Normal file
@ -0,0 +1,62 @@
|
||||
"""Tests for ``install`` choosing which allowed game to (fast-)install.
|
||||
|
||||
A fast install that was skipped at pick time must be startable later, for the
|
||||
assignment and for every manual pick alike.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._prompter import use_prompter
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import cmd_install
|
||||
from steam_backlog_enforcer.tests._fake_prompter import FakePrompter
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.install"
|
||||
|
||||
|
||||
def _state() -> State:
|
||||
return State(
|
||||
current_app_id=1,
|
||||
current_game_name="Assigned",
|
||||
manual_picks=[{"app_id": 2, "game_name": "Picked"}],
|
||||
)
|
||||
|
||||
|
||||
class TestInstallPicksGame:
|
||||
"""Every allowed game that is not fully installed is offered."""
|
||||
|
||||
def test_only_unfinished_game_installed_without_asking(self) -> None:
|
||||
prompter = FakePrompter()
|
||||
with (
|
||||
use_prompter(prompter),
|
||||
patch(f"{PKG}.is_game_fully_installed", side_effect=lambda a: a == 1),
|
||||
patch(f"{PKG}.install_game", return_value=True) as install,
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), _state())
|
||||
assert prompter.asked == []
|
||||
assert install.call_args.args[:2] == (2, "Picked")
|
||||
|
||||
def test_several_unfinished_games_prompt(self) -> None:
|
||||
prompter = FakePrompter(choices=["2"])
|
||||
with (
|
||||
use_prompter(prompter),
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=True) as install,
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), _state())
|
||||
assert prompter.asked[0][1] == ["1", "2"]
|
||||
assert install.call_args.args[:2] == (2, "Picked")
|
||||
|
||||
def test_all_installed(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_game_fully_installed", return_value=True),
|
||||
patch(f"{PKG}.install_game") as install,
|
||||
patch(f"{PKG}._echo") as echo,
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), _state())
|
||||
install.assert_not_called()
|
||||
assert any("already installed" in str(c) for c in echo.call_args_list)
|
||||
Loading…
Reference in New Issue
Block a user