steam-backlog-enforcer/steam_backlog_enforcer/tests/test_ctl_client.py
Krzysztof kuhy Rudnicki dc22bd557c fast install: never report a failed run as success; queue the manifest first
The BG3 fast install of 2026-10-23 downloaded 160 GB, was refused by the
installer, and the web job still ended on a full bar, "Done!" and Succeeded
while Steam restarted the download from 0%.

- A failed run's reason is read from the installer's own run log (its ERROR
  lines). A waiting job prints "Failed: fast install of <game>: <reason>",
  which fails the job; `install` no longer says "Done!" after it.
- Daemon-started failures stay on the Dashboard's Fast install card (and in
  ctl status, `error`) until a new run starts or the game is installed.
- install_game writes Steam's "needs downloading" manifest before the
  installer runs: it only finishes a manifest it finds, so fresh installs
  were refused for want of one.
- Command building moved to _fast_install_cmd (250-line cap).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GYmCezqhUjUmMisF5YtM4F
2026-10-24 08:36:57 +02:00

207 lines
7.4 KiB
Python

"""Tests for ``_ctl_client``: the raw call and the typed per-op wrappers.
``call`` is exercised against a one-shot fake daemon on a Unix socket in a
short temp dir; the typed wrappers patch ``call`` and check what they send and
how they shape the answer.
"""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from steam_backlog_enforcer import _ctl_client
from steam_backlog_enforcer._ctl_protocol import (
MAX_RESPONSE_BYTES,
CtlError,
DaemonUnreachableError,
)
from steam_backlog_enforcer._ctl_types import (
BlockResult,
DaemonInfo,
FastInstallInfo,
PendingReset,
ResetResult,
StoreWindow,
)
from steam_backlog_enforcer.tests._ctl_fixtures import serve_once, short_socket_dir
if TYPE_CHECKING:
from collections.abc import Iterator
from contextlib import AbstractContextManager
from pathlib import Path
@pytest.fixture
def sock_path() -> Iterator[Path]:
with short_socket_dir() as directory:
yield directory / "c.sock"
def _line(obj: object) -> bytes:
return json.dumps(obj).encode() + b"\n"
class TestCall:
"""The wire round trip."""
def test_returns_data(self, sock_path: Path) -> None:
thread = serve_once(sock_path, _line({"ok": True, "data": {"a": 1}}))
assert _ctl_client.call("ping", {"x": 1}, path=sock_path) == {"a": 1}
thread.join(5)
def test_ok_without_object_data_is_empty(self, sock_path: Path) -> None:
serve_once(sock_path, _line({"ok": True, "data": 5}))
assert _ctl_client.call("ping", path=sock_path) == {}
def test_error_reply_raises_ctl_error(self, sock_path: Path) -> None:
reply = {"ok": False, "error": "locked", "message": "no", "data": {"k": 1}}
serve_once(sock_path, _line(reply))
with pytest.raises(CtlError) as caught:
_ctl_client.call("ping", path=sock_path)
assert (caught.value.code, caught.value.message) == ("locked", "no")
assert caught.value.data == {"k": 1}
def test_error_reply_defaults(self, sock_path: Path) -> None:
serve_once(sock_path, _line({"ok": False}))
with pytest.raises(CtlError) as caught:
_ctl_client.call("ping", path=sock_path)
assert (caught.value.code, caught.value.message) == ("op_failed", "")
assert caught.value.data == {}
def test_missing_socket(self, sock_path: Path) -> None:
with pytest.raises(DaemonUnreachableError, match="control socket"):
_ctl_client.call("ping", path=sock_path)
def test_closed_without_answer(self, sock_path: Path) -> None:
serve_once(sock_path, b"")
with pytest.raises(DaemonUnreachableError, match="without answering"):
_ctl_client.call("ping", path=sock_path)
@pytest.mark.parametrize("reply", [b"{nope\n", b"[1]\n", b"\xff\n"])
def test_malformed_reply(self, sock_path: Path, reply: bytes) -> None:
serve_once(sock_path, reply)
with pytest.raises(DaemonUnreachableError, match="malformed"):
_ctl_client.call("ping", path=sock_path)
def test_oversize_reply(self, sock_path: Path) -> None:
serve_once(sock_path, b"x" * (MAX_RESPONSE_BYTES + 70000))
with pytest.raises(DaemonUnreachableError, match="too large"):
_ctl_client.call("ping", path=sock_path)
_PENDING = {
"pending_id": "abc",
"phrase": "p",
"armed_at": "a",
"ready_at": "r",
"heartbeat_interval_seconds": 10,
}
class TestTypedWrappers:
"""Each wrapper sends the right op and shapes the reply."""
def _call(self, data: dict[str, object]) -> AbstractContextManager[MagicMock]:
return patch.object(_ctl_client, "call", return_value=data)
def test_ping(self) -> None:
with self._call({}) as call:
_ctl_client.ping()
call.assert_called_once_with("ping")
def test_status(self) -> None:
data = {
"state": "running",
"started_at": "t",
"pid": 4,
"restart_available_at": "x",
}
with self._call(data):
assert _ctl_client.status() == DaemonInfo("running", "t", 4, "x")
def test_status_no_restart_time(self) -> None:
data = {"state": "running", "started_at": "t", "pid": 4}
with self._call(data):
info = _ctl_client.status()
assert info.restart_available_at is None
assert info.fast_installs == () # an older daemon sends none
def test_status_fast_installs(self) -> None:
run = {
"app_id": 7,
"name": "G",
"phase": "downloading depot 1/8",
"percent": 46.9,
"elapsed_seconds": 60,
}
data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]}
with self._call(data):
info = _ctl_client.status()
assert info.fast_installs == (
FastInstallInfo(7, "G", "downloading depot 1/8", 46.9, 60),
)
def test_status_failed_fast_install(self) -> None:
run = {"app_id": 7, "name": "G", "phase": "failed", "percent": None}
run |= {"elapsed_seconds": 0, "error": "exit 1"}
data = {"state": "running", "started_at": "t", "pid": 4, "fast_installs": [run]}
with self._call(data):
info = _ctl_client.status()
assert info.fast_installs[0].error == "exit 1"
def test_journal_tail(self) -> None:
with self._call({"lines": ["a", 2]}) as call:
assert _ctl_client.journal_tail(9) == ["a", "2"]
call.assert_called_once_with("journal_tail", {"lines": 9})
def test_store_unblock(self) -> None:
with self._call({"minutes": 5, "until": "u"}) as call:
assert _ctl_client.store_unblock(5, "ph") == StoreWindow(5, "u")
assert call.call_args.args == ("store_unblock", {"minutes": 5, "phrase": "ph"})
def test_block_gaming(self) -> None:
with self._call({"days": 3, "until": "u"}):
assert _ctl_client.block_gaming(3, "ph") == BlockResult(3, "u")
def test_block_gaming_no_until(self) -> None:
with self._call({"days": 3, "until": None}):
assert _ctl_client.block_gaming(3, "ph") == BlockResult(3, None)
def test_gaming_unblock(self) -> None:
with self._call({"released": ["/a"]}):
assert _ctl_client.gaming_unblock("ph") == ["/a"]
def test_reset_arm_and_heartbeat(self) -> None:
expected = PendingReset("abc", "p", "a", "r", 10)
with self._call(dict(_PENDING)):
assert _ctl_client.gaming_reset_arm("ph") == expected
assert _ctl_client.gaming_reset_heartbeat("abc") == expected
def test_reset_commit(self) -> None:
data = {
"day_key": "d",
"seconds_before": 1.5,
"was_blocked": 1,
"released": ["/m"],
}
with self._call(data):
result = _ctl_client.gaming_reset_commit("abc", "ph")
assert result == ResetResult("d", 1.5, was_blocked=True, released=["/m"])
def test_reset_cancel(self) -> None:
with self._call({}) as call:
_ctl_client.gaming_reset_cancel("abc")
call.assert_called_once_with("gaming_reset_cancel", {"pending_id": "abc"})
def test_restart(self) -> None:
with self._call({"restart_available_at": "x"}):
assert _ctl_client.restart() == "x"
def test_restart_no_time(self) -> None:
with self._call({}):
assert _ctl_client.restart() is None