mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 18:07:07 +01:00
pick keeps already-picked games; fast install reports progress
pick and pick-manual no longer uninstall games that are already allowed: the outgoing assignment is kept as a released pick (installed, no lock) until 'pick --replace', done/check, or abandon-pick. The allowed set is capped at max_manual_picks + 1; a pick over it is refused before the candidate list / confirmation, naming the games. status and the web Picks card list kept games with an Abandon button. Fast install now tracks the installer log from the spawn offset: web jobs drive the progress bar (per-depot %, phase) and echo a line every 30 s; daemon-started runs are exposed via ctl status / /api/daemon and shown on the Dashboard. Also: web deps bumped for dependency freshness (react-query 5.104.5, typescript-eslint 8.72.0, vite 8.3.5, node 24.22.1 in CI). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CUaX4zAa6H4sMu3jd6RFdb
This commit is contained in:
parent
55cf19fae4
commit
1bdf360193
2
.github/workflows/python-tests.yml
vendored
2
.github/workflows/python-tests.yml
vendored
@ -51,7 +51,7 @@ jobs:
|
||||
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 24.22.0
|
||||
node-version: 24.22.1
|
||||
cache: npm
|
||||
cache-dependency-path: web/package-lock.json
|
||||
|
||||
|
||||
@ -50,7 +50,7 @@ mirror is `web/src/api/contract.ts`; change both together or neither.
|
||||
| GET | `/api/commands` | – | `CommandSpec[]` — server-driven catalog |
|
||||
| GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) |
|
||||
| POST | `/api/setup` | `SetupRequest` | `SetupStatus` (validated live against Steam) |
|
||||
| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail) |
|
||||
| GET | `/api/daemon` | – | `DaemonStatus` (state, uptime, journal tail, fast installs) |
|
||||
| GET | `/api/server` | – | `ServerHealth` (stale?, started_at, version) |
|
||||
| POST | `/api/server/restart` | – | `202`; the user unit restarts the process |
|
||||
| GET | `/api/backups` | – | `StateBackup[]` |
|
||||
@ -205,6 +205,9 @@ hard-codes it.
|
||||
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
|
||||
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
|
||||
`gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`.
|
||||
- `status` also lists the daemon's live steam-game-installer runs as
|
||||
`fast_installs: [{app_id, name, phase, percent, elapsed_seconds}]`, read
|
||||
from its log by the enforce loop; `percent` is the current depot's, or null.
|
||||
|
||||
## Verification record (2026-10-10)
|
||||
|
||||
|
||||
@ -15,9 +15,12 @@ from typing import TYPE_CHECKING, Any
|
||||
from steam_backlog_enforcer._allowed_games import (
|
||||
active_manual_picks,
|
||||
allowed_games,
|
||||
drop_inactive_picks,
|
||||
)
|
||||
from steam_backlog_enforcer._assignment_progress import record_assignment
|
||||
from steam_backlog_enforcer._pick_keep import (
|
||||
allowed_cap_refusal,
|
||||
keep_outgoing_assignment,
|
||||
)
|
||||
|
||||
# Marks these two as an intentional re-export (they're imported from
|
||||
# _allowed_games above, not defined here) -- without this, mypy's
|
||||
@ -120,18 +123,23 @@ def apply_manual_pick(
|
||||
its interactive ``YES`` confirmation. Keeping this state-only means an
|
||||
automated caller (the MCP server) can never wipe installed games.
|
||||
|
||||
Picking is an explicit choice of another game, so released, finished and
|
||||
expired picks are dropped here (and enforcement may then remove them).
|
||||
The games already allowed are kept, like plain ``pick``: released,
|
||||
finished and expired picks stay, and the outgoing assignment is parked as
|
||||
a released pick. So the pick is refused when it would push the allowed
|
||||
set over ``max_picks + 1`` (:func:`allowed_cap_refusal`). Re-picking a
|
||||
kept game turns its entry back into an active pick instead of adding one.
|
||||
|
||||
Args:
|
||||
state: The enforcer state to mutate and save.
|
||||
app_id: The Steam app id to lock in.
|
||||
game_name: Human-readable name for the picked game.
|
||||
max_picks: How many picks may be active at once.
|
||||
max_picks: How many picks may be active at once; the allowed set
|
||||
may hold one more game (the assignment).
|
||||
|
||||
Returns:
|
||||
``None`` on success, or a message explaining why the pick was refused
|
||||
(already picked, or no slots left), in which case nothing was saved.
|
||||
(already picked, no active slot left, or the allowed set is full), in
|
||||
which case nothing was changed.
|
||||
"""
|
||||
active = active_manual_picks(state)
|
||||
|
||||
@ -145,8 +153,15 @@ def apply_manual_pick(
|
||||
f"(cap is {max_picks}): {names}."
|
||||
)
|
||||
|
||||
refused = allowed_cap_refusal(state, max_picks, adding=app_id)
|
||||
if refused is not None:
|
||||
return refused
|
||||
|
||||
now = datetime.now(UTC).isoformat()
|
||||
drop_inactive_picks(state)
|
||||
# A kept entry for this game is replaced by the active one, not doubled.
|
||||
state.manual_picks = [p for p in state.manual_picks if p.get("app_id") != app_id]
|
||||
if state.current_app_id != app_id:
|
||||
keep_outgoing_assignment(state)
|
||||
state.manual_picks.append(
|
||||
{"app_id": app_id, "game_name": game_name, "started_at": now}
|
||||
)
|
||||
|
||||
@ -24,8 +24,8 @@ def _pick_is_active(state: State, pick: dict[str, Any]) -> bool:
|
||||
"""Return ``True`` if *pick* still holds the lock and a pick slot.
|
||||
|
||||
An inactive pick is *not* evicted: it stays in ``state.manual_picks``, and
|
||||
so installed and playable, until the user explicitly chooses another game
|
||||
(:func:`drop_inactive_picks`).
|
||||
so installed and playable, until the user explicitly replaces it
|
||||
(:func:`drop_inactive_picks`) or abandons it.
|
||||
|
||||
A pick stops being active once it earned a new achievement since it was
|
||||
picked (``released_at``), once its game is 100% finished, or once
|
||||
@ -75,9 +75,10 @@ def drop_inactive_picks(state: State) -> list[dict[str, Any]]:
|
||||
"""Forget every pick that no longer holds the lock; return the dropped ones.
|
||||
|
||||
Released, finished and expired picks stay installed and playable for as
|
||||
long as the user wants. Only an explicit choice of another game (a new
|
||||
manual pick, or accepting the next assignment) calls this and so lets
|
||||
enforcement remove them. Does not save.
|
||||
long as the user wants. Only an explicit replacement (``pick --replace``,
|
||||
or accepting the next assignment in ``done``/``check``/``scan``) calls
|
||||
this and so lets enforcement remove them; plain ``pick`` and
|
||||
``pick-manual`` keep them. Does not save.
|
||||
|
||||
Args:
|
||||
state: The enforcer state to mutate.
|
||||
@ -96,7 +97,7 @@ def allowed_games(state: State) -> list[tuple[int, str]]:
|
||||
|
||||
That is the current assignment plus every stored manual pick, including
|
||||
released and expired ones: a game is only removed once the user has
|
||||
explicitly chosen something else.
|
||||
explicitly replaced or abandoned it.
|
||||
|
||||
Args:
|
||||
state: The loaded enforcer state.
|
||||
|
||||
@ -27,7 +27,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
|
||||
"uninstall": "Uninstall all non-assigned games",
|
||||
"setup": "Run first-time setup",
|
||||
"done": "Move on after a new achievement, pick next",
|
||||
"pick": "Manually pick your next game from candidates",
|
||||
"pick": "Pick another game from candidates (keeps current; --replace swaps)",
|
||||
"stats": "Show backlog completion-time estimates",
|
||||
"gaming-status": "Show today's gaming time and block state",
|
||||
"gaming-reset": "Reset today's gaming counter (root + phrase)",
|
||||
|
||||
@ -19,7 +19,7 @@ from steam_backlog_enforcer._store_window import (
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Mapping
|
||||
|
||||
ParamType = Literal["int", "string", "app_id"]
|
||||
ParamType = Literal["int", "string", "app_id", "bool"]
|
||||
ParamValue = int | str
|
||||
|
||||
|
||||
@ -54,6 +54,17 @@ class ParamSpec:
|
||||
_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1)
|
||||
|
||||
PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = {
|
||||
# Unchecked keeps the games already allowed; checked is `pick --replace`.
|
||||
"pick": (
|
||||
ParamSpec(
|
||||
"replace",
|
||||
"Replace current games (uninstalls them)",
|
||||
"bool",
|
||||
required=False,
|
||||
default=0,
|
||||
help="Leave unchecked to keep playing them alongside the new pick.",
|
||||
),
|
||||
),
|
||||
"pick-manual": (_APP_ID,),
|
||||
"abandon-pick": (_APP_ID,),
|
||||
"add-exception": (
|
||||
@ -106,6 +117,13 @@ def _coerce(spec: ParamSpec, raw: object) -> ParamValue:
|
||||
msg = f"{spec.name} must be a non-empty string"
|
||||
raise InvalidParamsError(msg)
|
||||
return raw.strip()
|
||||
if spec.type == "bool":
|
||||
# A checkbox; carried as 0/1 so ParamValue stays int | str.
|
||||
# True/False are ints too, so this accepts true, false, 0 and 1.
|
||||
if isinstance(raw, int) and raw in (0, 1):
|
||||
return int(raw)
|
||||
msg = f"{spec.name} must be true or false"
|
||||
raise InvalidParamsError(msg)
|
||||
# bool is an int subclass in Python; true/false is never a count.
|
||||
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
||||
msg = f"{spec.name} must be a whole number"
|
||||
|
||||
@ -26,6 +26,7 @@ from steam_backlog_enforcer._ctl_protocol import (
|
||||
from steam_backlog_enforcer._ctl_types import (
|
||||
BlockResult,
|
||||
DaemonInfo,
|
||||
FastInstallInfo,
|
||||
PendingReset,
|
||||
ResetResult,
|
||||
StoreWindow,
|
||||
@ -130,15 +131,29 @@ def ping() -> None:
|
||||
call("ping")
|
||||
|
||||
|
||||
def _fast_install_info(run: dict[str, object]) -> FastInstallInfo:
|
||||
"""One ``status.fast_installs`` entry, typed."""
|
||||
percent = run.get("percent")
|
||||
return FastInstallInfo(
|
||||
app_id=int(cast("int", run["app_id"])),
|
||||
name=str(run["name"]),
|
||||
phase=str(run["phase"]),
|
||||
percent=None if percent is None else float(cast("float", percent)),
|
||||
elapsed_seconds=int(cast("int", run["elapsed_seconds"])),
|
||||
)
|
||||
|
||||
|
||||
def status() -> DaemonInfo:
|
||||
"""The daemon's state, start time, pid and next-restart time."""
|
||||
"""The daemon's state, start time, pid, next-restart time and fast installs."""
|
||||
data = call("status")
|
||||
until = data.get("restart_available_at")
|
||||
runs = cast("list[dict[str, object]]", data.get("fast_installs") or [])
|
||||
return DaemonInfo(
|
||||
state=str(data["state"]),
|
||||
started_at=str(data["started_at"]),
|
||||
pid=int(cast("int", data["pid"])),
|
||||
restart_available_at=str(until) if until else None,
|
||||
fast_installs=tuple(map(_fast_install_info, runs)),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -13,6 +13,7 @@ import contextlib
|
||||
import logging
|
||||
import os
|
||||
import subprocess
|
||||
import time
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from steam_backlog_enforcer._ctl_context import (
|
||||
@ -24,6 +25,7 @@ from steam_backlog_enforcer._ctl_context import (
|
||||
from steam_backlog_enforcer._ctl_protocol import OP_FAILED, CtlError
|
||||
from steam_backlog_enforcer._ctl_reset import RESET_OPS
|
||||
from steam_backlog_enforcer._ctl_restart import RESTART_OPS, restart_available_at
|
||||
from steam_backlog_enforcer._fast_install_progress import fast_install_status
|
||||
from steam_backlog_enforcer._playtime_block import release_block
|
||||
from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window
|
||||
from steam_backlog_enforcer._total_block import (
|
||||
@ -56,12 +58,19 @@ def op_ping(_ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
|
||||
|
||||
|
||||
def op_status(ctx: CtlContext, _args: dict[str, object]) -> dict[str, object]:
|
||||
"""The daemon's state, start time, pid and when a restart is next allowed."""
|
||||
"""The daemon's state, start time, pid, next restart and fast installs.
|
||||
|
||||
``fast_installs`` are the steam-game-installer runs the daemon started,
|
||||
as of the enforce loop's last read of their log: this thread never reads
|
||||
the file. It rides on ``status`` rather than a file of its own because the
|
||||
web server runs as the desktop user and a root-owned file is no use to it.
|
||||
"""
|
||||
return {
|
||||
"state": "restarting" if ctx.restart_event.is_set() else "running",
|
||||
"started_at": ctx.started_at.isoformat(),
|
||||
"pid": os.getpid(),
|
||||
"restart_available_at": restart_available_at(),
|
||||
"fast_installs": fast_install_status(time.monotonic()),
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -9,6 +9,21 @@ from __future__ import annotations
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FastInstallInfo:
|
||||
"""A steam-game-installer run the daemon started, as far as its log tells.
|
||||
|
||||
``percent`` is the current depot's (``None`` until one is known); ``phase``
|
||||
names the depot, e.g. ``downloading depot 3/8``.
|
||||
"""
|
||||
|
||||
app_id: int
|
||||
name: str
|
||||
phase: str
|
||||
percent: float | None
|
||||
elapsed_seconds: int
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DaemonInfo:
|
||||
"""The daemon's own account of itself (``status``)."""
|
||||
@ -17,6 +32,8 @@ class DaemonInfo:
|
||||
started_at: str
|
||||
pid: int
|
||||
restart_available_at: str | None
|
||||
# Empty from a daemon too old to report them.
|
||||
fast_installs: tuple[FastInstallInfo, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
|
||||
@ -7,21 +7,19 @@ 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** (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.
|
||||
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run with
|
||||
the terminal attached, so depotdownloader's progress shows and a password /
|
||||
Steam Guard prompt can be answered.
|
||||
* **Detached** (no terminal): spawn in its own session. The root daemon returns
|
||||
at once; :func:`poll_fast_installs` reaps the run on later passes, records a
|
||||
failure (the caller falls back to ``steam://install``) and interrupts a run
|
||||
whose output stalled - likely a login prompt nobody will answer, with Steam
|
||||
shut down. A web job loops that reaper until the run ends. Either way the
|
||||
log is followed for progress (:mod:`_fast_install_progress`).
|
||||
|
||||
The installer closes Steam to do its work, so it is refused outright while a
|
||||
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.
|
||||
The installer closes Steam, so it is refused while a game is running. Another
|
||||
app mid-download is no reason to wait: asked to leave with ``steam -shutdown``,
|
||||
Steam resumes the transfer when it comes back.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@ -38,6 +36,7 @@ import subprocess
|
||||
import sys
|
||||
import time
|
||||
|
||||
from steam_backlog_enforcer import _fast_install_progress as _fip
|
||||
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
|
||||
@ -176,9 +175,10 @@ def poll_fast_installs() -> None:
|
||||
returncode,
|
||||
run.log_path,
|
||||
)
|
||||
_fip.poll_trackers({run.app_id for run in _RUNS})
|
||||
|
||||
|
||||
def _spawn_detached(cmd: list[str], app_id: int) -> None:
|
||||
def _spawn_detached(cmd: list[str], app_id: int, label: str = "") -> None:
|
||||
"""Start the installer in its own session, output to a log file."""
|
||||
# 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.
|
||||
@ -191,6 +191,8 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None:
|
||||
if os.geteuid() == 0 and shutil.which("systemd-run"):
|
||||
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
|
||||
with log_path.open("ab") as log:
|
||||
# Append mode starts at the end: earlier runs' lines are never parsed.
|
||||
_fip.track(app_id, label, log_path, offset=log.tell(), now=time.monotonic())
|
||||
proc = subprocess.Popen(
|
||||
cmd,
|
||||
stdin=subprocess.DEVNULL,
|
||||
@ -204,13 +206,11 @@ def _spawn_detached(cmd: list[str], app_id: int) -> None:
|
||||
|
||||
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..."
|
||||
)
|
||||
reporter = _fip.WaitReporter(app_id, label, started=time.monotonic())
|
||||
while is_fast_installing(app_id):
|
||||
time.sleep(_WAIT_POLL_SECONDS)
|
||||
poll_fast_installs()
|
||||
reporter.report(time.monotonic())
|
||||
return app_id not in _FAILED
|
||||
|
||||
|
||||
@ -235,7 +235,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)
|
||||
_spawn_detached(cmd, app_id, label)
|
||||
return os.geteuid() == 0 or _wait_detached(app_id, label)
|
||||
|
||||
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")
|
||||
|
||||
226
steam_backlog_enforcer/_fast_install_progress.py
Normal file
226
steam_backlog_enforcer/_fast_install_progress.py
Normal file
@ -0,0 +1,226 @@
|
||||
"""Live progress of a detached steam-game-installer run, read from its log.
|
||||
|
||||
The installer runs depotdownloader on a pty and flushes every chunk, so the
|
||||
per-user ``fast_install-<euid>.log`` grows while the download runs (CRLF line
|
||||
endings, from the pty). Its markers, verbatim from a real run:
|
||||
|
||||
* ``Shutting down the Steam client...``
|
||||
* ``Steam already staged 71.53 GB for this app ...`` (adopting a partial download)
|
||||
* ``Downloading via depotdownloader (fast path)...`` (connecting, logging in)
|
||||
* ``Processing depot 1086941`` once per depot, then ``Downloading depot 1086941``
|
||||
as each one starts
|
||||
* `` 46.90% /path/of/finished/file (657.13 Mbit/s)`` per finished file
|
||||
* ``Checking for owned DLC ...``, then the manifest is finished
|
||||
* ``Restarting the Steam client...``
|
||||
|
||||
The percentage is depotdownloader's progress through the *current depot*: it
|
||||
restarts at 0 for every depot. Depots are wildly uneven (one Baldur's Gate 3
|
||||
depot is 128 of its 132 GB), so averaging over depot count would lie; the
|
||||
phase names the depot instead (``downloading depot 3/8``) and the percent
|
||||
belongs to it.
|
||||
|
||||
The log is opened in append mode and keeps every earlier run, so a tracker
|
||||
reads only bytes past the offset recorded at spawn, and only the new ones on
|
||||
each poll: the log of a large game runs to tens of megabytes.
|
||||
|
||||
Who reads it: a waiting web job (:class:`WaitReporter`, through the Progress
|
||||
API and a line every :data:`ECHO_SECONDS`), and the root daemon's ctl
|
||||
``status`` op (:func:`fast_install_status`), which never touches the file -
|
||||
the daemon's own enforce loop polls the trackers.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import re
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
from steam_backlog_enforcer._echo import _echo
|
||||
from steam_backlog_enforcer._progress import current_progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
STARTING: Final = "starting"
|
||||
_DOWNLOADING: Final = "downloading"
|
||||
# Line prefix -> the phase it starts.
|
||||
_MARKERS: Final = (
|
||||
("Shutting down the Steam client", "shutting Steam down"),
|
||||
("Steam already staged", "adopting Steam's partial download"),
|
||||
("Downloading via depotdownloader", "connecting to Steam"),
|
||||
("Checking for owned DLC", "finishing manifest"),
|
||||
("Reconciling with steamcmd", "finishing manifest"),
|
||||
("Restarting the Steam client", "restarting Steam"),
|
||||
)
|
||||
_PLANNED_DEPOT: Final = re.compile(r"Processing depot \d+$")
|
||||
_STARTED_DEPOT: Final = re.compile(r"Downloading depot \d+$")
|
||||
_PERCENT: Final = re.compile(r"\s*(\d{1,3}\.\d+)% ")
|
||||
# A waiting job prints a status line this often; the bar moves every poll.
|
||||
ECHO_SECONDS: Final = 30
|
||||
_READ_CHUNK: Final = 1 << 20
|
||||
|
||||
|
||||
def _clock(seconds: int) -> str:
|
||||
"""``42s`` or ``5m 12s``."""
|
||||
minutes, secs = divmod(seconds, 60)
|
||||
return f"{minutes}m {secs:02d}s" if minutes else f"{secs}s"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class InstallProgress:
|
||||
"""Where a run is: its phase, the current depot's percent, time spent."""
|
||||
|
||||
phase: str
|
||||
percent: float | None
|
||||
elapsed_seconds: int
|
||||
|
||||
@property
|
||||
def downloading(self) -> bool:
|
||||
"""Whether the phase is a depot download (the one with a percent)."""
|
||||
return self.phase.startswith(_DOWNLOADING)
|
||||
|
||||
def describe(self) -> str:
|
||||
"""``46% (downloading depot 3/8), 5m 12s elapsed``; no % before one is known."""
|
||||
elapsed = f"{_clock(self.elapsed_seconds)} elapsed"
|
||||
if self.percent is None:
|
||||
return f"{self.phase}, {elapsed}"
|
||||
return f"{int(self.percent)}% ({self.phase}), {elapsed}"
|
||||
|
||||
|
||||
class InstallTracker:
|
||||
"""Follows one run's log from its spawn offset, a complete line at a time."""
|
||||
|
||||
def __init__(self, log_path: Path, *, offset: int, started: float) -> None:
|
||||
"""Track *log_path* from byte *offset*; *started* is a monotonic time."""
|
||||
self._path = log_path
|
||||
self._offset = offset
|
||||
self._started = started
|
||||
self._partial = b""
|
||||
self._phase = STARTING
|
||||
self._percent: float | None = None
|
||||
self._planned = 0
|
||||
self._depot = 0
|
||||
|
||||
def poll(self) -> None:
|
||||
"""Parse whatever the run has written since the last poll.
|
||||
|
||||
A log that is missing or unreadable changes nothing: the run itself is
|
||||
judged by its exit status, never by its log.
|
||||
"""
|
||||
try:
|
||||
with self._path.open("rb") as log:
|
||||
log.seek(self._offset)
|
||||
while chunk := log.read(_READ_CHUNK):
|
||||
self._offset += len(chunk)
|
||||
*lines, self._partial = (self._partial + chunk).split(b"\n")
|
||||
for line in lines:
|
||||
self._feed(line.decode("utf-8", "replace").rstrip("\r"))
|
||||
except OSError:
|
||||
return
|
||||
|
||||
def _feed(self, line: str) -> None:
|
||||
"""Apply one log line to the phase / percent state."""
|
||||
if match := _PERCENT.match(line):
|
||||
if self._phase.startswith(_DOWNLOADING):
|
||||
# Max within a depot: a depot's progress only ever grows.
|
||||
self._percent = max(self._percent or 0.0, float(match.group(1)))
|
||||
elif _PLANNED_DEPOT.match(line):
|
||||
self._planned += 1
|
||||
elif _STARTED_DEPOT.match(line):
|
||||
self._depot += 1
|
||||
# DLC depots come on top of the planned ones: no total to show then.
|
||||
of = f"/{self._planned}" if self._depot <= self._planned else ""
|
||||
self._phase, self._percent = f"{_DOWNLOADING} depot {self._depot}{of}", None
|
||||
else:
|
||||
for prefix, phase in _MARKERS:
|
||||
if line.startswith(prefix):
|
||||
self._phase, self._percent = phase, None
|
||||
|
||||
def snapshot(self, now: float) -> InstallProgress:
|
||||
"""The run's progress as of monotonic time *now*."""
|
||||
elapsed = max(0, int(now - self._started))
|
||||
return InstallProgress(self._phase, self._percent, elapsed)
|
||||
|
||||
|
||||
# Live detached runs of this process by app id, with the name to show. The
|
||||
# enforce loop polls them; ctl connection threads read snapshots.
|
||||
_TRACKERS: dict[int, tuple[str, InstallTracker]] = {}
|
||||
_LOCK = threading.Lock()
|
||||
|
||||
|
||||
def track(app_id: int, name: str, log_path: Path, *, offset: int, now: float) -> None:
|
||||
"""Start following a run just spawned with its output at *offset* of the log."""
|
||||
with _LOCK:
|
||||
_TRACKERS[app_id] = (name, InstallTracker(log_path, offset=offset, started=now))
|
||||
|
||||
|
||||
def poll_trackers(live: set[int]) -> None:
|
||||
"""Read new log lines of the *live* runs and forget every other one."""
|
||||
with _LOCK:
|
||||
for app_id in [aid for aid in _TRACKERS if aid not in live]:
|
||||
del _TRACKERS[app_id]
|
||||
for _name, tracker in _TRACKERS.values():
|
||||
tracker.poll()
|
||||
|
||||
|
||||
def progress_of(app_id: int, now: float) -> InstallProgress | None:
|
||||
"""The tracked run's progress for *app_id*, or None if it is not tracked."""
|
||||
with _LOCK:
|
||||
entry = _TRACKERS.get(app_id)
|
||||
return entry[1].snapshot(now) if entry else None
|
||||
|
||||
|
||||
def fast_install_status(now: float) -> list[dict[str, object]]:
|
||||
"""Every tracked run as the ctl ``status`` op reports it (no file I/O)."""
|
||||
with _LOCK:
|
||||
entries = [(aid, name, t.snapshot(now)) for aid, (name, t) in _TRACKERS.items()]
|
||||
return [
|
||||
{
|
||||
"app_id": app_id,
|
||||
"name": name,
|
||||
"phase": snap.phase,
|
||||
"percent": None if snap.percent is None else round(snap.percent, 2),
|
||||
"elapsed_seconds": snap.elapsed_seconds,
|
||||
}
|
||||
for app_id, name, snap in entries
|
||||
]
|
||||
|
||||
|
||||
class WaitReporter:
|
||||
"""Shows a waiting caller's run: the job progress bar plus a periodic line.
|
||||
|
||||
The installer prints a line per finished file (thousands), so nothing here
|
||||
runs per line: one report per poll, one echo per :data:`ECHO_SECONDS`.
|
||||
"""
|
||||
|
||||
def __init__(self, app_id: int, label: str, *, started: float) -> None:
|
||||
"""Announce the wait for *app_id*'s run; *started* is when it began."""
|
||||
self._app_id = app_id
|
||||
self._label = label
|
||||
self._phase: str | None = None
|
||||
self._last_echo = started
|
||||
_echo(
|
||||
f" Fast-installing {label} with steam-game-installer (Steam will "
|
||||
f"close); progress every {ECHO_SECONDS}s..."
|
||||
)
|
||||
|
||||
def report(self, now: float) -> None:
|
||||
"""Push the run's current progress; a no-op once it is not tracked."""
|
||||
snap = progress_of(self._app_id, now)
|
||||
if snap is None:
|
||||
return
|
||||
progress = current_progress()
|
||||
if snap.phase != self._phase:
|
||||
# Only on a change: phase() restarts the job's ETA clock.
|
||||
self._phase = snap.phase
|
||||
total = 100 if snap.downloading else None
|
||||
progress.phase(f"Fast-installing {self._label}: {snap.phase}", total)
|
||||
if snap.downloading:
|
||||
# Floor, so 99.99 never reads as done before the depot is.
|
||||
progress.advance(self._label, step=int(snap.percent or 0))
|
||||
else:
|
||||
progress.advance(f"{_clock(snap.elapsed_seconds)} elapsed", step=0)
|
||||
if now - self._last_echo >= ECHO_SECONDS:
|
||||
self._last_echo = now
|
||||
_echo(f" Fast-installing {self._label}: {snap.describe()}")
|
||||
@ -135,6 +135,7 @@ def status_payload(state: State) -> dict[str, Any]:
|
||||
if state.current_app_id
|
||||
else None
|
||||
)
|
||||
active = active_manual_picks(state)
|
||||
return {
|
||||
"current_app_id": state.current_app_id,
|
||||
"current_game_name": state.current_game_name or None,
|
||||
@ -153,6 +154,9 @@ def status_payload(state: State) -> dict[str, Any]:
|
||||
**pick,
|
||||
"age_days": manual_pick_age_days(state, pick["app_id"]),
|
||||
}
|
||||
for pick in active_manual_picks(state)
|
||||
for pick in active
|
||||
],
|
||||
# Released, expired or parked by 'pick': still allowed and installed,
|
||||
# but holding no lock; 'abandon-pick' removes them.
|
||||
"kept_picks": [{**p} for p in state.manual_picks if p not in active],
|
||||
}
|
||||
|
||||
@ -38,7 +38,8 @@ def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
|
||||
|
||||
With ``confirm=False`` (the default) this performs **no** mutation and
|
||||
returns a preview of what confirming would do. Call again with
|
||||
``confirm=True`` to apply. Applying overrides the current assignment and
|
||||
``confirm=True`` to apply. Applying makes it the current assignment
|
||||
(the games already allowed stay, up to the cap) and
|
||||
locks all other commands for ``MANUAL_LOCK_DAYS`` days (or until one new
|
||||
achievement is earned in it). Unlike the CLI's ``pick-manual``, this
|
||||
mutates **state only** — it never uninstalls, installs, or hides games.
|
||||
@ -61,7 +62,8 @@ def pick_manual(app_id: int, *, confirm: bool = False) -> dict[str, Any]:
|
||||
"app_id": app_id,
|
||||
"game_name": game_name,
|
||||
"effect": (
|
||||
"Overrides the current assignment and locks all other commands "
|
||||
"Makes it the current assignment (the games already allowed "
|
||||
"stay, up to the cap) and locks all other commands "
|
||||
f"for {MANUAL_LOCK_DAYS} days or until you earn one new "
|
||||
"achievement in it."
|
||||
),
|
||||
|
||||
117
steam_backlog_enforcer/_pick_keep.py
Normal file
117
steam_backlog_enforcer/_pick_keep.py
Normal file
@ -0,0 +1,117 @@
|
||||
"""``pick`` and ``pick-manual`` keep the games already allowed.
|
||||
|
||||
Picking a game used to replace the assignment: the outgoing game and every
|
||||
released manual pick were dropped from the allowed set and uninstalled. In
|
||||
keep mode the outgoing assignment is parked in ``state.manual_picks`` as a
|
||||
*released* entry instead, so it reuses the existing model end to end: it
|
||||
stays in :func:`allowed_games` (installed, visible, never killed), holds no
|
||||
lock and no pick slot, and leaves only on an explicit ``pick --replace``,
|
||||
``done``/``check`` reassignment or ``abandon-pick``. ``pick-manual`` parks it
|
||||
the same way and shares the cap (:func:`allowed_cap_refusal`).
|
||||
|
||||
Stdout-free and state-only, like :mod:`._actions`: callers print and exit.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from steam_backlog_enforcer._allowed_games import allowed_games
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
|
||||
def keep_outgoing_assignment(state: State) -> dict[str, Any] | None:
|
||||
"""Park the current assignment as a released pick so it stays allowed.
|
||||
|
||||
Must run before the new assignment overwrites ``current_app_id``. A game
|
||||
already stored as a pick (an active ``pick-manual`` lock, or an earlier
|
||||
released entry) is left exactly as it is: it is allowed already, and a
|
||||
second entry would double-count it. Does not save.
|
||||
|
||||
Args:
|
||||
state: The enforcer state to mutate.
|
||||
|
||||
Returns:
|
||||
The appended entry, or ``None`` when nothing needed parking.
|
||||
"""
|
||||
app_id = state.current_app_id
|
||||
if app_id is None or any(p.get("app_id") == app_id for p in state.manual_picks):
|
||||
return None
|
||||
now = datetime.now(UTC).isoformat()
|
||||
entry: dict[str, Any] = {
|
||||
"app_id": app_id,
|
||||
"game_name": state.current_game_name,
|
||||
"started_at": state.current_assigned_at or now,
|
||||
# Released, not active: allowed, but no lock and no pick slot.
|
||||
"released_at": now,
|
||||
}
|
||||
state.manual_picks.append(entry)
|
||||
return entry
|
||||
|
||||
|
||||
def keep_refusal(config: Config, state: State) -> str | None:
|
||||
"""Why ``pick`` (keep mode) cannot add another game, or ``None`` if it can.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration (for ``max_manual_picks``).
|
||||
state: The loaded enforcer state.
|
||||
|
||||
Returns:
|
||||
A message naming every game using a slot and how to free one.
|
||||
"""
|
||||
return allowed_cap_refusal(state, config.max_manual_picks, offer_replace=True)
|
||||
|
||||
|
||||
def allowed_cap_refusal(
|
||||
state: State,
|
||||
max_picks: int,
|
||||
adding: int | None = None,
|
||||
*,
|
||||
offer_replace: bool = False,
|
||||
) -> str | None:
|
||||
"""Why one more allowed game would break the cap, or ``None`` if it fits.
|
||||
|
||||
The one rule ``pick`` and ``pick-manual`` share: at most ``max_picks + 1``
|
||||
games (the assignment plus the manual picks) may be allowed at once.
|
||||
|
||||
Args:
|
||||
state: The loaded enforcer state.
|
||||
max_picks: The manual-pick cap (``Config.max_manual_picks``).
|
||||
adding: The app about to be allowed. One already allowed (a kept
|
||||
game picked again) does not grow the set, so it always fits.
|
||||
offer_replace: Also suggest ``pick --replace`` (only ``pick`` has it).
|
||||
|
||||
Returns:
|
||||
A message naming every game using a slot and how to free one.
|
||||
"""
|
||||
games = allowed_games(state)
|
||||
cap = max_picks + 1
|
||||
if len(games) < cap or any(app_id == adding for app_id, _ in games):
|
||||
return None
|
||||
picked = {p.get("app_id") for p in state.manual_picks}
|
||||
header = (
|
||||
f"You already have {len(games)} games allowed (cap is {cap}: your"
|
||||
f" assignment plus {max_picks} manual pick(s)):"
|
||||
)
|
||||
lines = [header]
|
||||
for app_id, name in games:
|
||||
role = "current assignment" if app_id == state.current_app_id else "kept"
|
||||
# Only a stored pick can be abandoned; the bare assignment cannot.
|
||||
how = f"; free it with 'abandon-pick {app_id}'" if app_id in picked else ""
|
||||
lines.append(f" - {name} (AppID={app_id}): {role}{how}")
|
||||
tail = "Free a slot with 'abandon-pick <app_id>'"
|
||||
if offer_replace:
|
||||
tail += (
|
||||
", or run 'pick --replace' to swap them all for the new game"
|
||||
" (uninstalls them)"
|
||||
)
|
||||
lines.append(tail + ".")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def kept_alongside(state: State, app_id: int) -> list[str]:
|
||||
"""Names of the allowed games that stay installed next to *app_id*."""
|
||||
return [name for aid, name in allowed_games(state) if aid != app_id]
|
||||
@ -20,6 +20,7 @@ from steam_backlog_enforcer._own_pick import (
|
||||
OWN_PICK_VALUE,
|
||||
prompt_own_pick,
|
||||
)
|
||||
from steam_backlog_enforcer._pick_keep import keep_outgoing_assignment
|
||||
from steam_backlog_enforcer._prompter import PromptOption, current_prompter
|
||||
from steam_backlog_enforcer._scanning_candidates import (
|
||||
_pick_next_shortest_candidate,
|
||||
@ -80,13 +81,24 @@ def _assign_chosen_game(
|
||||
games: list[GameInfo],
|
||||
state: State,
|
||||
config: Config,
|
||||
*,
|
||||
keep: bool = False,
|
||||
) -> None:
|
||||
"""Save assignment, announce it, and handle install/uninstall.
|
||||
|
||||
Accepting a new game is the explicit choice that lets enforcement remove
|
||||
released picks, so they are dropped here.
|
||||
Replacing (the default, and what ``done``/``check`` do) is the explicit
|
||||
choice that lets enforcement remove released picks, so they are dropped
|
||||
here. With ``keep`` (plain ``pick``) nothing is dropped and the outgoing
|
||||
assignment is parked as a released pick, so it stays allowed and the
|
||||
uninstall below spares it.
|
||||
"""
|
||||
drop_inactive_picks(state)
|
||||
if keep:
|
||||
if chosen.app_id in allowed_app_ids(state):
|
||||
_echo(f"\n{chosen.name} (AppID={chosen.app_id}) is already allowed.")
|
||||
return
|
||||
keep_outgoing_assignment(state)
|
||||
else:
|
||||
drop_inactive_picks(state)
|
||||
record_assignment(state, chosen.app_id, chosen.name)
|
||||
state.save()
|
||||
hours_str = (
|
||||
|
||||
@ -12,6 +12,7 @@ contract's ``PendingAction``.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict
|
||||
from http import HTTPStatus
|
||||
from typing import TYPE_CHECKING, Final
|
||||
|
||||
@ -68,6 +69,7 @@ def daemon_view(_request: Request) -> Reply:
|
||||
"pid": None,
|
||||
"journal_tail": [],
|
||||
"restart_available_at": None,
|
||||
"fast_installs": [],
|
||||
}
|
||||
)
|
||||
except CtlError as exc:
|
||||
@ -83,6 +85,7 @@ def daemon_view(_request: Request) -> Reply:
|
||||
"pid": info.pid,
|
||||
"journal_tail": journal,
|
||||
"restart_available_at": info.restart_available_at,
|
||||
"fast_installs": [asdict(run) for run in info.fast_installs],
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@ -44,6 +44,10 @@ def status_view(_request: Request) -> Reply:
|
||||
payload = status_payload(State.load())
|
||||
payload["assigned_game_installed"] = bool(payload["assigned_game_installed"])
|
||||
payload["manual_picks"] = [_manual_pick(p) for p in payload["manual_picks"]]
|
||||
payload["kept_picks"] = [
|
||||
{"app_id": p["app_id"], "name": p.get("game_name") or str(p["app_id"])}
|
||||
for p in payload.get("kept_picks", [])
|
||||
]
|
||||
return ok(payload)
|
||||
|
||||
|
||||
|
||||
@ -79,11 +79,21 @@ past its own 14-day deadline.
|
||||
so freeing a slot cannot hand you an unrequested extra game.
|
||||
- Releasing never evicts. A released, finished or expired pick stays in
|
||||
`State.manual_picks`, and therefore in `allowed_app_ids`, installed and
|
||||
playable for as long as the user wants. Only an explicit choice of another
|
||||
game — a new `pick-manual`, or accepting the next assignment in
|
||||
`done`/`check`/`pick`/`scan` — calls `drop_inactive_picks`, after which
|
||||
playable for as long as the user wants. Only an explicit replacement —
|
||||
`pick --replace`, or accepting the next assignment in
|
||||
`done`/`check`/`scan` — calls `drop_inactive_picks`, after which
|
||||
enforcement may remove them. `abandon-pick` works on them too. When no
|
||||
replacement is accepted, the current game stays assigned.
|
||||
- Plain `pick` and `pick-manual` keep them (changed 2026-10-23): the
|
||||
outgoing assignment is parked as a released pick too
|
||||
(`_pick_keep.keep_outgoing_assignment`), so both commands only ever add a
|
||||
game. Both share one cap, `_pick_keep.allowed_cap_refusal`: at most
|
||||
`max_manual_picks + 1` games allowed, refused (naming every game and its
|
||||
`abandon-pick <id>`) before the list or the confirmation prompt. Picking
|
||||
a game that is already allowed adds nothing and always fits; `pick-manual`
|
||||
on a kept game turns its entry back into an active pick, never a second
|
||||
entry. The active-pick cap (`max_manual_picks` locked at once) still
|
||||
applies on top.
|
||||
- `mark_finished` is the single writer of `finished_app_ids`, so the sweep
|
||||
and `done`/`check` cannot both record the same completion.
|
||||
|
||||
|
||||
@ -95,6 +95,15 @@ def _add_exception(
|
||||
return {"app_id": params["app_id"]}
|
||||
|
||||
|
||||
def _pick(
|
||||
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
|
||||
) -> JobResult:
|
||||
"""``pick [--replace]``: the web checkbox becomes the CLI flag."""
|
||||
state = State.load()
|
||||
cmd_pick(Config.load(), state, ["--replace"] if params.get("replace") else [])
|
||||
return _assignment(state)
|
||||
|
||||
|
||||
def _enforce_demo(
|
||||
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
|
||||
) -> JobResult:
|
||||
@ -145,7 +154,7 @@ HANDLERS: dict[str, JobHandler] = {
|
||||
"check": _cli(do_check),
|
||||
"scan": _cli(do_scan),
|
||||
"done": _cli(cmd_done),
|
||||
"pick": _cli(cmd_pick),
|
||||
"pick": _pick,
|
||||
"pick-manual": _with_args(cmd_pick_manual, "app_id"),
|
||||
"abandon-pick": _with_args(cmd_abandon_pick, "app_id"),
|
||||
"install": _cli(cmd_install),
|
||||
|
||||
@ -187,6 +187,9 @@ def _dispatch_extra_command(command: str, config: Config, state: State) -> bool:
|
||||
"unblock": lambda: cmd_unblock(config, state, args),
|
||||
"block-gaming": lambda: cmd_block_gaming(args),
|
||||
"serve": lambda: cmd_serve(args),
|
||||
# Also in COMMANDS (help order, direct callers), but only here does
|
||||
# it see argv, so --replace is honoured.
|
||||
"pick": lambda: cmd_pick(config, state, args),
|
||||
"pick-manual": lambda: cmd_pick_manual(config, state, args),
|
||||
"abandon-pick": lambda: cmd_abandon_pick(config, state, args),
|
||||
}
|
||||
|
||||
@ -3,14 +3,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from steam_backlog_enforcer._actions import (
|
||||
ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._actions import (
|
||||
active_manual_picks,
|
||||
find_manual_pick,
|
||||
)
|
||||
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
|
||||
from steam_backlog_enforcer._prompter import confirm_phrase
|
||||
@ -54,21 +53,28 @@ def _abandon_pick_target(state: State, args: list[str]) -> int | None:
|
||||
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
|
||||
return None
|
||||
|
||||
picks = active_manual_picks(state)
|
||||
# Every stored pick, not only active ones: a released pick, or a game
|
||||
# 'pick' kept, is still installed and is what 'pick' points users at.
|
||||
picks = state.manual_picks
|
||||
if not picks:
|
||||
_echo("No manual pick is active — nothing to abandon.")
|
||||
_echo("No manual pick or kept game — nothing to abandon.")
|
||||
return None
|
||||
|
||||
if find_manual_pick(state, app_id) is None:
|
||||
if _stored_pick(state, app_id) is None:
|
||||
listed = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in picks)
|
||||
_echo(
|
||||
f"Error: AppID={app_id} is not one of your manual picks.\nActive: {listed}."
|
||||
f"Error: AppID={app_id} is not one of your manual picks.\nYours: {listed}."
|
||||
)
|
||||
return None
|
||||
|
||||
return app_id
|
||||
|
||||
|
||||
def _stored_pick(state: State, app_id: int) -> dict[str, Any] | None:
|
||||
"""Return the stored pick for *app_id*, active, released or kept."""
|
||||
return next((p for p in state.manual_picks if p.get("app_id") == app_id), None)
|
||||
|
||||
|
||||
def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
|
||||
"""Back out of a manual pick, whatever its age.
|
||||
|
||||
@ -81,7 +87,7 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
|
||||
if app_id is None:
|
||||
sys.exit(1)
|
||||
|
||||
pick = find_manual_pick(state, app_id)
|
||||
pick = _stored_pick(state, app_id)
|
||||
game_name = str(pick["game_name"]) if pick else ""
|
||||
|
||||
others = [p for p in active_manual_picks(state) if p["app_id"] != app_id]
|
||||
@ -96,6 +102,9 @@ def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
|
||||
if others:
|
||||
kept = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in others)
|
||||
_echo(f"\n Your other pick(s) stay locked in: {kept}")
|
||||
elif state.current_app_id not in (None, app_id):
|
||||
# A kept game is not the assignment, so abandoning it leaves one.
|
||||
_echo(f"\n - {state.current_game_name} stays assigned")
|
||||
else:
|
||||
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
|
||||
_echo()
|
||||
|
||||
@ -20,6 +20,11 @@ from steam_backlog_enforcer._pick_completion import (
|
||||
report_completion,
|
||||
warn_stale_assignment,
|
||||
)
|
||||
from steam_backlog_enforcer._pick_keep import (
|
||||
allowed_cap_refusal,
|
||||
keep_refusal,
|
||||
kept_alongside,
|
||||
)
|
||||
from steam_backlog_enforcer._progress import tracked
|
||||
from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter
|
||||
from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name
|
||||
@ -42,8 +47,28 @@ if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
|
||||
def cmd_pick(config: Config, state: State) -> None:
|
||||
"""Manually pick a new game from the shortest-first candidate list."""
|
||||
def cmd_pick(config: Config, state: State, args: list[str] | None = None) -> None:
|
||||
"""Pick another game from the shortest-first candidate list.
|
||||
|
||||
The games already allowed stay installed and playable next to the new
|
||||
one, up to the cap (refused before the list is shown). ``--replace``
|
||||
swaps them all for the new game and uninstalls them instead.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration.
|
||||
state: Current enforcer state.
|
||||
args: Remaining CLI args: nothing, or ``--replace``.
|
||||
"""
|
||||
unknown = [a for a in args or [] if a != "--replace"]
|
||||
if unknown:
|
||||
_echo(f"Unknown argument(s): {' '.join(unknown)}\nUsage: pick [--replace]")
|
||||
sys.exit(1)
|
||||
keep = "--replace" not in (args or [])
|
||||
refusal = keep_refusal(config, state) if keep else None
|
||||
if refusal is not None:
|
||||
_echo(f"Error: {refusal}")
|
||||
sys.exit(1)
|
||||
|
||||
snapshot_data = load_snapshot()
|
||||
if not snapshot_data:
|
||||
_echo("No snapshot found. Run 'scan' first.")
|
||||
@ -55,7 +80,7 @@ def cmd_pick(config: Config, state: State) -> None:
|
||||
if game.app_id in hltb_cache:
|
||||
game.completionist_hours = hltb_cache[game.app_id]
|
||||
|
||||
pick_next_game(games, state, config)
|
||||
pick_next_game(games, state, config, keep=keep)
|
||||
|
||||
if state.current_app_id is not None:
|
||||
hide_others_and_report(config, state)
|
||||
@ -84,12 +109,15 @@ def _resolve_game_name(config: Config, app_id: int) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
|
||||
"""Show which manual picks are already locked in; exit if the cap is full.
|
||||
def _report_pick_slots(
|
||||
config: Config, state: State, app_id: int
|
||||
) -> list[dict[str, object]]:
|
||||
"""Show which manual picks are already locked in; exit if a cap is full.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration (for ``max_manual_picks``).
|
||||
state: Current enforcer state.
|
||||
app_id: The game about to be picked (an allowed one adds nothing).
|
||||
|
||||
Returns:
|
||||
The currently-active manual picks.
|
||||
@ -107,7 +135,10 @@ def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
|
||||
f"\ncan be abandoned at any time."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
refusal = allowed_cap_refusal(state, config.max_manual_picks, adding=app_id)
|
||||
if refusal is not None:
|
||||
_echo(f"\nError: {refusal}")
|
||||
sys.exit(1)
|
||||
return existing
|
||||
|
||||
|
||||
@ -146,6 +177,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
|
||||
"""Manually pick a game by Steam app_id, locking the enforcer.
|
||||
|
||||
The lock lasts 2 weeks, or until one new achievement is earned in it.
|
||||
Already-allowed games stay installed, up to the cap (checked pre-prompt).
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration.
|
||||
@ -173,7 +205,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
|
||||
|
||||
retired = report_completion(config, state)
|
||||
|
||||
existing = _report_pick_slots(config, state)
|
||||
existing = _report_pick_slots(config, state, app_id)
|
||||
|
||||
_echo(
|
||||
f"\nWARNING: Picking this game will:"
|
||||
@ -185,12 +217,9 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
|
||||
f"\n {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}"
|
||||
f"\n - Stay undoable at any time via 'abandon-pick {app_id}'"
|
||||
)
|
||||
active = active_manual_picks(state)
|
||||
for old_pick in (p for p in state.manual_picks if p not in active):
|
||||
_echo(
|
||||
f" - Replace {old_pick['game_name']} (released or expired; it stays"
|
||||
" playable until you confirm)"
|
||||
)
|
||||
kept = kept_alongside(state, app_id)
|
||||
if kept:
|
||||
_echo(f" - Keep these allowed, they stay installed: {', '.join(kept)}")
|
||||
_echo()
|
||||
if not confirm_phrase(
|
||||
"pick-manual",
|
||||
|
||||
@ -71,6 +71,16 @@ def cmd_status(config: Config, state: State) -> None:
|
||||
_echo(f" {pick['game_name']} (AppID={pick['app_id']}){since}")
|
||||
_echo("\n[MANUAL PICK LOCK is active — most commands are blocked]")
|
||||
|
||||
# Released/expired picks and games 'pick' kept: allowed, but no lock.
|
||||
kept = [p for p in state.manual_picks if p not in picks]
|
||||
if kept:
|
||||
_echo(f"\nKept games, no lock ({len(kept)}):")
|
||||
for pick in kept:
|
||||
_echo(
|
||||
f" {pick.get('game_name', '')} (AppID={pick['app_id']})"
|
||||
f" — 'abandon-pick {pick['app_id']}' removes it"
|
||||
)
|
||||
|
||||
|
||||
def cmd_list(_config: Config, state: State) -> None:
|
||||
"""List games from the last snapshot."""
|
||||
|
||||
@ -6,6 +6,7 @@ import logging
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids
|
||||
from steam_backlog_enforcer._assignment_progress import (
|
||||
release_game,
|
||||
release_verdict,
|
||||
@ -153,6 +154,7 @@ def pick_next_game(
|
||||
config: Config,
|
||||
*,
|
||||
on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
|
||||
keep: bool = False,
|
||||
) -> None:
|
||||
"""Present a ranked list of eligible games and let the user pick one.
|
||||
|
||||
@ -165,12 +167,20 @@ def pick_next_game(
|
||||
bypassed: the function instead presents the shortest playable
|
||||
candidate to ``on_select`` (typically a yes/no prompt) and, if the
|
||||
callback rejects it, records a 7-day skip and re-evaluates.
|
||||
|
||||
``keep`` (plain ``pick``) adds the chosen game next to the ones already
|
||||
allowed instead of replacing them, so those are not offered again. The
|
||||
sequential path (``done``/``check``) always replaces.
|
||||
"""
|
||||
if on_select is not None:
|
||||
_pick_next_game_sequential(games, state, config, on_select)
|
||||
return
|
||||
|
||||
candidates = _open_candidates(games, state)
|
||||
# Offering an allowed game in keep mode would be a no-op "pick"; the
|
||||
# ranked list and the own-game search both draw from this pool.
|
||||
allowed = allowed_app_ids(state) if keep else set()
|
||||
pool = [g for g in games if g.app_id not in allowed]
|
||||
candidates = _open_candidates(pool, state)
|
||||
if not candidates:
|
||||
_clear_assignment(state, _NO_CONF_MSG)
|
||||
return
|
||||
@ -182,8 +192,8 @@ def pick_next_game(
|
||||
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
|
||||
return
|
||||
|
||||
chosen = _prompt_user_pick(qualified, games, state)
|
||||
_assign_chosen_game(chosen, games, state, config)
|
||||
chosen = _prompt_user_pick(qualified, pool, state)
|
||||
_assign_chosen_game(chosen, games, state, config, keep=keep)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
@ -18,7 +18,7 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _fast_install
|
||||
from steam_backlog_enforcer import _fast_install, _fast_install_progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -39,6 +39,7 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
fake_steamapps.mkdir(exist_ok=True)
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
_fast_install_progress._TRACKERS.clear()
|
||||
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
|
||||
patches += [
|
||||
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
|
||||
@ -54,3 +55,4 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
||||
p.stop()
|
||||
_fast_install._RUNS.clear()
|
||||
_fast_install._FAILED.clear()
|
||||
_fast_install_progress._TRACKERS.clear()
|
||||
|
||||
100
steam_backlog_enforcer/tests/fast_install_log_excerpt.txt
Normal file
100
steam_backlog_enforcer/tests/fast_install_log_excerpt.txt
Normal file
@ -0,0 +1,100 @@
|
||||
Run log: /home/kuhy/.local/state/steam-game-installer/run-20261022-211022-1086940.log
|
||||
Confirmed: Baldur's Gate 3 (appid 1086940, matched by flag)
|
||||
Install target: /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3
|
||||
Shutting down the Steam client...
|
||||
Steam exited after 3s.
|
||||
Downloading via depotdownloader (fast path)...
|
||||
Connecting to Steam3... Done!
|
||||
Logging 'steam_user' into Steam3...
|
||||
Done!
|
||||
Using Steam3 suggested CellID: 38
|
||||
Got 2146 licenses for account!
|
||||
Got AppInfo for 1086940
|
||||
Using app branch: 'public'.
|
||||
Got AppInfo for 228980
|
||||
Got depot key for 228989 result: OK
|
||||
Got depot key for 228990 result: OK
|
||||
Got depot key for 1086941 result: OK
|
||||
Got depot key for 1086942 result: OK
|
||||
Got depot key for 1419652 result: OK
|
||||
Got depot key for 1419653 result: OK
|
||||
Depot 1419655 is not available from this account.
|
||||
Got depot key for 2378500 result: OK
|
||||
Got depot key for 2330358 result: OK
|
||||
|
||||
Processing depot 228989
|
||||
Downloading depot 228989 manifest
|
||||
Manifest 5753583882400741046 (06/30/2026 23:39:35)
|
||||
Processing depot 228990
|
||||
Downloading depot 228990 manifest
|
||||
Manifest 1829726630299308803 (02/18/2013 21:47:57)
|
||||
Processing depot 1086941
|
||||
Downloading depot 1086941 manifest
|
||||
Manifest 8253106406752983615 (09/21/2026 20:03:27)
|
||||
Processing depot 1086942
|
||||
Downloading depot 1086942 manifest
|
||||
Manifest 17875152607970122 (09/10/2020 10:06:04)
|
||||
Processing depot 1419652
|
||||
Downloading depot 1419652 manifest
|
||||
Manifest 1625295449847783523 (09/21/2026 20:04:37)
|
||||
Processing depot 1419653
|
||||
Downloading depot 1419653 manifest
|
||||
Manifest 3221749745104422496 (09/21/2026 20:04:48)
|
||||
Processing depot 2378500
|
||||
Downloading depot 2378500 manifest
|
||||
Manifest 6453229909780803137 (08/08/2023 10:54:38)
|
||||
Processing depot 2330358
|
||||
Downloading depot 2330358 manifest
|
||||
Manifest 7444994150796633975 (08/26/2025 13:58:18)
|
||||
Downloading depot 228989
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x86.cmd
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/installscript.vdf
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x86.exe
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x64.exe
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x64.cmd
|
||||
46.90% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x64.cmd (657.13 Mbit/s)
|
||||
46.90% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/Microsoft Visual C++ 2022 x86.cmd (641.92 Mbit/s)
|
||||
59.16% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/installscript.vdf (673.62 Mbit/s)
|
||||
95.92% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x64.exe (762.02 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/vcredist/2022/VC_redist.x86.exe (452.17 Mbit/s)
|
||||
Depot 228989 - Downloaded 25108528 bytes (25674515 bytes uncompressed)
|
||||
Downloading depot 228990
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Apr2005_d3dx9_25_x64.cab
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Apr2005_d3dx9_25_x86.cab
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Apr2006_d3dx9_30_x64.cab
|
||||
98.04% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Nov2008_d3dx9_40_x64.cab (902.96 Mbit/s)
|
||||
98.98% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Nov2008_d3dx10_40_x86.cab (910.11 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/_CommonRedist/DirectX/Jun2010/Mar2009_d3dx9_41_x64.cab (912.52 Mbit/s)
|
||||
Depot 228990 - Downloaded 100658080 bytes (102931551 bytes uncompressed)
|
||||
Downloading depot 1086941
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/EngineShaders.pak
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/DiceSet01.pak
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/DiceSet03.pak
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/DiceSet02.pak
|
||||
92.49% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Models.pak (931.36 Mbit/s)
|
||||
96.02% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/SharedSounds.pak (931.27 Mbit/s)
|
||||
99.35% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Localization/Voice.pak (931.18 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Gustav.pak (931.16 Mbit/s)
|
||||
Depot 1086941 - Downloaded 127976565376 bytes (154767161103 bytes uncompressed)
|
||||
Downloading depot 1086942
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Localization/language.lsx
|
||||
99.91% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/DigitalDeluxe/OST/WAV Format/32 Baldur's Gate 3 OST - The Legacy Of Bhaal.wav (925.53 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/DigitalDeluxe/OST/WAV Format/38 Baldur's Gate 3 OST - Old Time Battles - Part II.wav (921.57 Mbit/s)
|
||||
Depot 2378500 - Downloaded 2868626208 bytes (3700167797 bytes uncompressed)
|
||||
Downloading depot 2330358
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Editor/Config/AllSpark/ComponentDefinition.xcd
|
||||
Pre-allocating /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Editor/Config/AllSpark/ModuleDefinition.xmd
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/_Trade.txt (352.87 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/__Shared_Campaign.txt (352.86 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/SharedDev/Story/RawFiles/Goals/_Doppelgangers.txt (352.86 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/__GLOBAL_Dialogs.txt (352.86 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/Shared/Story/RawFiles/Goals/__AAA_FirstGoal.txt (352.86 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Mods/SharedDev/Story/RawFiles/Goals/Respec.txt (352.85 Mbit/s)
|
||||
100.00% /home/kuhy/.local/share/Steam/steamapps/common/Baldurs Gate 3/Data/Projects/GustavDev/thumbnail.png (352.64 Mbit/s)
|
||||
Depot 2330358 - Downloaded 1098084960 bytes (11227179410 bytes uncompressed)
|
||||
Total downloaded: 132328691216 bytes (170530942667 bytes uncompressed) from 8 depots
|
||||
Disconnected from Steam
|
||||
Checking for owned DLC with depots of its own...
|
||||
No /home/kuhy/.local/share/Steam/steamapps/appmanifest_1086940.acf exists, so there is nothing to finish. This tool only ever patches a manifest Steam or steamcmd already wrote; it never fabricates one. Re-run without --skip-steamcmd so steamcmd creates it (it writes one even when it crashes).
|
||||
Run log (send this): /home/kuhy/.local/state/steam-game-installer/run-20261022-211022-1086940.log
|
||||
Restarting the Steam client...
|
||||
@ -197,11 +197,11 @@ class TestApplyManualPickCap:
|
||||
assert refused is not None
|
||||
assert "already one of your manual picks" in refused
|
||||
|
||||
def test_prunes_finished_entries(self) -> None:
|
||||
"""Test prunes finished entries."""
|
||||
def test_keeps_finished_entries(self) -> None:
|
||||
"""A finished pick stays allowed: a new pick no longer drops it."""
|
||||
state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440])
|
||||
assert apply_manual_pick(state, 620, "Portal 2", max_picks=2) is None
|
||||
assert [p["app_id"] for p in state.manual_picks] == [620]
|
||||
assert [p["app_id"] for p in state.manual_picks] == [440, 620]
|
||||
|
||||
def test_slots_left(self) -> None:
|
||||
"""Test slots left."""
|
||||
|
||||
@ -22,6 +22,7 @@ from steam_backlog_enforcer._ctl_protocol import (
|
||||
from steam_backlog_enforcer._ctl_types import (
|
||||
BlockResult,
|
||||
DaemonInfo,
|
||||
FastInstallInfo,
|
||||
PendingReset,
|
||||
ResetResult,
|
||||
StoreWindow,
|
||||
@ -125,7 +126,24 @@ class TestTypedWrappers:
|
||||
def test_status_no_restart_time(self) -> None:
|
||||
data = {"state": "running", "started_at": "t", "pid": 4}
|
||||
with self._call(data):
|
||||
assert _ctl_client.status().restart_available_at is None
|
||||
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_journal_tail(self) -> None:
|
||||
with self._call({"lines": ["a", 2]}) as call:
|
||||
|
||||
@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _ctl_ops
|
||||
from steam_backlog_enforcer import _ctl_ops, _fast_install_progress
|
||||
from steam_backlog_enforcer._ctl_protocol import CtlError
|
||||
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
||||
|
||||
@ -35,6 +35,24 @@ class TestSimpleOps:
|
||||
assert data["restart_available_at"] is None
|
||||
assert isinstance(data["pid"], int)
|
||||
|
||||
def test_status_reports_fast_installs(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(b"Shutting down the Steam client...\r\n")
|
||||
_fast_install_progress.track(7, "G", log, offset=0, now=0.0)
|
||||
_fast_install_progress.poll_trackers({7})
|
||||
with patch.object(_ctl_ops, "restart_available_at", return_value=None):
|
||||
data = _ctl_ops.op_status(make_ctx(), {})
|
||||
runs = data["fast_installs"]
|
||||
assert isinstance(runs, list)
|
||||
(run,) = runs
|
||||
assert run["name"] == "G"
|
||||
assert run["phase"] == "shutting Steam down"
|
||||
assert run["percent"] is None
|
||||
|
||||
def test_status_without_fast_installs(self) -> None:
|
||||
with patch.object(_ctl_ops, "restart_available_at", return_value=None):
|
||||
assert _ctl_ops.op_status(make_ctx(), {})["fast_installs"] == []
|
||||
|
||||
def test_status_restarting(self) -> None:
|
||||
ctx = make_ctx()
|
||||
ctx.restart_event.set()
|
||||
|
||||
@ -12,7 +12,8 @@ from __future__ import annotations
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer import _fast_install, config
|
||||
from steam_backlog_enforcer import _fast_install, _fast_install_progress, config
|
||||
from steam_backlog_enforcer._progress import use_progress
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@ -26,15 +27,21 @@ def _ready(tmp_path: Path) -> Path:
|
||||
return installer
|
||||
|
||||
|
||||
def _fake_spawn(*returncodes: int | None) -> MagicMock:
|
||||
"""A ``_spawn_detached`` that registers a run answering *returncodes*."""
|
||||
def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> MagicMock:
|
||||
"""A ``_spawn_detached`` that registers a run answering *returncodes*.
|
||||
|
||||
def spawn(_cmd: list[str], app_id: int) -> None:
|
||||
With *log_text*, the run also gets a log holding it, tracked from byte 0.
|
||||
"""
|
||||
|
||||
def spawn(_cmd: list[str], app_id: int, label: str = "") -> 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")
|
||||
)
|
||||
log = config.CONFIG_DIR / "x.log"
|
||||
if log_text is not None:
|
||||
log.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.write_bytes(log_text)
|
||||
_fast_install_progress.track(app_id, label, log, offset=0, now=0.0)
|
||||
_fast_install._RUNS.append(_fast_install._DetachedRun(app_id, proc, log))
|
||||
|
||||
return MagicMock(side_effect=spawn)
|
||||
|
||||
@ -62,6 +69,18 @@ class TestJobWaitsForInstaller:
|
||||
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
|
||||
assert {7} == _fast_install._FAILED
|
||||
|
||||
def test_progress_is_reported_while_waiting(self, tmp_path: Path) -> None:
|
||||
text = b"Shutting down the Steam client...\r\nDownloading via depot\r\n"
|
||||
text += b"Processing depot 1\r\nDownloading depot 1\r\n"
|
||||
text += b" 46.90% /x/a.pak (657.13 Mbit/s)\r\n"
|
||||
progress = MagicMock()
|
||||
with use_progress(progress):
|
||||
assert self._install(tmp_path, _fake_spawn(None, 0, log_text=text), 1000)
|
||||
progress.phase.assert_called_with(
|
||||
"Fast-installing G: downloading depot 1/1", 100
|
||||
)
|
||||
progress.advance.assert_called_with("G", step=46)
|
||||
|
||||
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)
|
||||
@ -82,3 +101,17 @@ class TestLogIsPerUser:
|
||||
_fast_install._spawn_detached(["run.sh"], 9)
|
||||
popen.assert_called_once()
|
||||
assert _fast_install._RUNS[0].log_path != daemon_log
|
||||
|
||||
def test_progress_starts_at_the_current_end(self) -> None:
|
||||
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
||||
log = config.CONFIG_DIR / "fast_install-1000.log"
|
||||
log.write_bytes(b"Restarting the Steam client...\r\n") # the last run
|
||||
with (
|
||||
patch(f"{PKG}.os.geteuid", return_value=1000),
|
||||
patch(f"{PKG}.subprocess.Popen"),
|
||||
):
|
||||
_fast_install._spawn_detached(["run.sh"], 9, "G")
|
||||
_fast_install_progress.poll_trackers({9})
|
||||
progress = _fast_install_progress.progress_of(9, now=0.0)
|
||||
assert progress is not None
|
||||
assert progress.phase == _fast_install_progress.STARTING
|
||||
|
||||
236
steam_backlog_enforcer/tests/test_fast_install_progress.py
Normal file
236
steam_backlog_enforcer/tests/test_fast_install_progress.py
Normal file
@ -0,0 +1,236 @@
|
||||
"""Tests for _fast_install_progress: reading a live installer log.
|
||||
|
||||
The fixture is a verbatim excerpt of a real Baldur's Gate 3 run (the per-file
|
||||
lines of the big depots trimmed). It is stored with LF endings and written
|
||||
back with CRLF, which is what the installer's pty actually produces.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, call, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _fast_install_progress as fip
|
||||
|
||||
MOD = "steam_backlog_enforcer._fast_install_progress"
|
||||
EXCERPT = (Path(__file__).parent / "fast_install_log_excerpt.txt").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
LINES = EXCERPT.splitlines()
|
||||
|
||||
|
||||
def _upto(marker: str, *, extra: int = 0) -> str:
|
||||
"""The excerpt through the line equal to *marker*, else the first it starts.
|
||||
|
||||
Exact first: ``Downloading depot 228989`` is also the start of the earlier
|
||||
``Downloading depot 228989 manifest``.
|
||||
"""
|
||||
exact = [i for i, line in enumerate(LINES) if line == marker]
|
||||
index = (exact or [i for i, ln in enumerate(LINES) if ln.startswith(marker)])[0]
|
||||
return "".join(f"{line}\r\n" for line in LINES[: index + 1 + extra])
|
||||
|
||||
|
||||
def _tracker(tmp_path: Path, text: str, *, offset: int = 0) -> fip.InstallTracker:
|
||||
log = tmp_path / "fast_install-1000.log"
|
||||
log.write_bytes(text.encode())
|
||||
tracker = fip.InstallTracker(log, offset=offset, started=100.0)
|
||||
tracker.poll()
|
||||
return tracker
|
||||
|
||||
|
||||
class TestPhases:
|
||||
"""Each installer stage the log announces becomes a phase."""
|
||||
|
||||
def test_nothing_yet_is_starting(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, "").snapshot(now=103.0)
|
||||
assert snap == fip.InstallProgress(fip.STARTING, None, 3)
|
||||
|
||||
def test_shutting_steam_down(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Shutting down")).snapshot(now=112.0)
|
||||
assert snap.phase == "shutting Steam down"
|
||||
assert snap.percent is None
|
||||
assert snap.describe() == "shutting Steam down, 12s elapsed"
|
||||
|
||||
def test_connecting_before_any_depot(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Logging ")).snapshot(now=100.0)
|
||||
assert snap.phase == "connecting to Steam"
|
||||
|
||||
def test_adopting_steams_partial_download(self, tmp_path: Path) -> None:
|
||||
line = (
|
||||
"Steam already staged 71.53 GB for this app (161 files). Hardlinking "
|
||||
"it into place so depotdownloader verifies instead of refetching.\r\n"
|
||||
)
|
||||
snap = _tracker(tmp_path, line).snapshot(now=100.0)
|
||||
assert snap.phase == "adopting Steam's partial download"
|
||||
|
||||
def test_first_depot_percent(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto(" 46.90%")).snapshot(now=100.0)
|
||||
assert snap.phase == "downloading depot 1/8"
|
||||
assert snap.percent == pytest.approx(46.90)
|
||||
assert snap.downloading
|
||||
|
||||
def test_new_depot_resets_the_percent(self, tmp_path: Path) -> None:
|
||||
# Pre-allocating lines only: the previous depot's 100% must not show.
|
||||
text = _upto("Downloading depot 228990", extra=3)
|
||||
snap = _tracker(tmp_path, text).snapshot(now=100.0)
|
||||
assert snap.phase == "downloading depot 2/8"
|
||||
assert snap.percent is None
|
||||
assert snap.describe() == "downloading depot 2/8, 0s elapsed"
|
||||
|
||||
def test_percent_outside_a_depot_is_ignored(self, tmp_path: Path) -> None:
|
||||
text = _upto("Shutting down") + " 46.90% /x/y.cmd (657.13 Mbit/s)\r\n"
|
||||
snap = _tracker(tmp_path, text).snapshot(now=100.0)
|
||||
assert snap.phase == "shutting Steam down"
|
||||
assert snap.percent is None
|
||||
|
||||
def test_percent_is_per_depot(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto(" 92.49%")).snapshot(now=100.0)
|
||||
assert snap.phase == "downloading depot 3/8"
|
||||
assert snap.percent == pytest.approx(92.49)
|
||||
|
||||
def test_finishing_after_the_download(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Checking for owned DLC")).snapshot(now=0)
|
||||
assert snap.phase == "finishing manifest"
|
||||
assert snap.percent is None
|
||||
assert not snap.downloading
|
||||
|
||||
def test_whole_run_ends_restarting_steam(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto("Restarting")).snapshot(now=100.0)
|
||||
assert snap.phase == "restarting Steam"
|
||||
|
||||
def test_describe_with_percent_and_minutes(self, tmp_path: Path) -> None:
|
||||
snap = _tracker(tmp_path, _upto(" 99.35%")).snapshot(now=100.0 + 312)
|
||||
assert snap.describe() == "99% (downloading depot 3/8), 5m 12s elapsed"
|
||||
|
||||
|
||||
class TestIncrementalRead:
|
||||
"""Only bytes written since the spawn are read, a line at a time."""
|
||||
|
||||
def test_previous_run_before_offset_is_ignored(self, tmp_path: Path) -> None:
|
||||
previous = _upto("Restarting") # the last run ended at 100%...
|
||||
log = tmp_path / "fast_install-1000.log"
|
||||
log.write_bytes(previous.encode())
|
||||
tracker = fip.InstallTracker(log, offset=log.stat().st_size, started=0.0)
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=1.0).phase == fip.STARTING
|
||||
with log.open("ab") as out: # ...and the new one has just begun.
|
||||
out.write(_upto("Shutting down").encode())
|
||||
tracker.poll()
|
||||
snap = tracker.snapshot(now=1.0)
|
||||
assert snap.phase == "shutting Steam down"
|
||||
assert snap.percent is None
|
||||
|
||||
def test_partial_line_waits_for_its_end(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(_upto("Downloading depot 228989").encode() + b" 46.9")
|
||||
tracker = fip.InstallTracker(log, offset=0, started=0.0)
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=0).percent is None
|
||||
with log.open("ab") as out:
|
||||
out.write(b"0% /x/y.cmd (657.13 Mbit/s)\r\n")
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=0).percent == pytest.approx(46.90)
|
||||
|
||||
def test_missing_log_is_a_no_op(self, tmp_path: Path) -> None:
|
||||
tracker = fip.InstallTracker(tmp_path / "gone.log", offset=0, started=0.0)
|
||||
tracker.poll()
|
||||
assert tracker.snapshot(now=0).phase == fip.STARTING
|
||||
|
||||
|
||||
class TestRegistry:
|
||||
"""Daemon runs are tracked by app id and exposed as status entries."""
|
||||
|
||||
def test_status_lists_live_runs(self, tmp_path: Path) -> None:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(_upto(" 59.16%").encode())
|
||||
fip.track(7, "Baldur's Gate 3", log, offset=0, now=10.0)
|
||||
fip.poll_trackers({7})
|
||||
assert fip.fast_install_status(now=70.0) == [
|
||||
{
|
||||
"app_id": 7,
|
||||
"name": "Baldur's Gate 3",
|
||||
"phase": "downloading depot 1/8",
|
||||
"percent": 59.16,
|
||||
"elapsed_seconds": 60,
|
||||
}
|
||||
]
|
||||
|
||||
def test_finished_runs_are_dropped(self, tmp_path: Path) -> None:
|
||||
fip.track(7, "G", tmp_path / "log", offset=0, now=0.0)
|
||||
fip.poll_trackers(set())
|
||||
assert fip.fast_install_status(now=1.0) == []
|
||||
assert fip.progress_of(7, now=1.0) is None
|
||||
|
||||
|
||||
class TestWaitReporter:
|
||||
"""A waiting web job moves its progress bar and prints a line every 30 s."""
|
||||
|
||||
def _reporter(self, tmp_path: Path, text: str) -> fip.WaitReporter:
|
||||
log = tmp_path / "log"
|
||||
log.write_bytes(text.encode())
|
||||
fip.track(7, "G", log, offset=0, now=0.0)
|
||||
fip.poll_trackers({7})
|
||||
with patch(f"{MOD}._echo"):
|
||||
return fip.WaitReporter(7, "G", started=0.0)
|
||||
|
||||
def test_announces_the_wait(self) -> None:
|
||||
with patch(f"{MOD}._echo") as echo:
|
||||
fip.WaitReporter(7, "G", started=0.0)
|
||||
echo.assert_called_once_with(
|
||||
" Fast-installing G with steam-game-installer (Steam will close); "
|
||||
"progress every 30s..."
|
||||
)
|
||||
|
||||
def test_phase_then_percent_step(self, tmp_path: Path) -> None:
|
||||
reporter = self._reporter(tmp_path, _upto(" 99.35%"))
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo"),
|
||||
):
|
||||
reporter.report(now=5.0)
|
||||
reporter.report(now=10.0)
|
||||
# The phase is announced once; 99.35 floors so it never claims done.
|
||||
progress.phase.assert_called_once_with(
|
||||
"Fast-installing G: downloading depot 3/8", 100
|
||||
)
|
||||
assert progress.advance.call_args_list == [
|
||||
call("G", step=99),
|
||||
call("G", step=99),
|
||||
]
|
||||
|
||||
def test_indeterminate_phase_shows_elapsed(self, tmp_path: Path) -> None:
|
||||
reporter = self._reporter(tmp_path, _upto("Shutting down"))
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo"),
|
||||
):
|
||||
reporter.report(now=12.0)
|
||||
progress.phase.assert_called_once_with(
|
||||
"Fast-installing G: shutting Steam down", None
|
||||
)
|
||||
progress.advance.assert_called_once_with("12s elapsed", step=0)
|
||||
|
||||
def test_echo_is_throttled_to_30_seconds(self, tmp_path: Path) -> None:
|
||||
reporter = self._reporter(tmp_path, _upto(" 46.90%"))
|
||||
with patch(f"{MOD}.current_progress"), patch(f"{MOD}._echo") as echo:
|
||||
for now in (5.0, 10.0, 29.0, 31.0, 45.0, 62.0):
|
||||
reporter.report(now=now)
|
||||
assert echo.call_args_list == [
|
||||
call(" Fast-installing G: 46% (downloading depot 1/8), 31s elapsed"),
|
||||
call(" Fast-installing G: 46% (downloading depot 1/8), 1m 02s elapsed"),
|
||||
]
|
||||
|
||||
def test_untracked_run_reports_nothing(self) -> None:
|
||||
progress = MagicMock()
|
||||
with (
|
||||
patch(f"{MOD}.current_progress", return_value=progress),
|
||||
patch(f"{MOD}._echo") as echo,
|
||||
):
|
||||
fip.WaitReporter(99, "G", started=0.0).report(now=60.0)
|
||||
progress.phase.assert_not_called()
|
||||
progress.advance.assert_not_called()
|
||||
assert echo.call_count == 1 # the announcement only
|
||||
@ -123,7 +123,7 @@ class TestPickManualCap:
|
||||
class TestPickManualRetirementNotices:
|
||||
"""A pick retired by the sweep is announced before the typed-phrase prompt."""
|
||||
|
||||
def test_warns_about_uninstall_and_stale_assignment(self) -> None:
|
||||
def test_lists_kept_games_and_stale_assignment(self) -> None:
|
||||
state = locked_state(app_id=100)
|
||||
state.current_app_id = 200
|
||||
state.current_game_name = "Finished"
|
||||
@ -146,7 +146,8 @@ class TestPickManualRetirementNotices:
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Replace Finished (released or expired" in output
|
||||
# The retired game is kept now, not replaced: it stays installed.
|
||||
assert "they stay installed: Finished, TestGame" in output
|
||||
assert "Aborted." in output
|
||||
# Abort leaves the finished game as the assignment, so say so.
|
||||
note = " ".join(str(c) for c in mock_note.call_args_list)
|
||||
|
||||
206
steam_backlog_enforcer/tests/test_main_pick_keep.py
Normal file
206
steam_backlog_enforcer/tests/test_main_pick_keep.py
Normal file
@ -0,0 +1,206 @@
|
||||
"""``pick`` CLI/web wiring: the keep default, ``--replace`` and the cap.
|
||||
|
||||
The keep/replace state model itself is covered in ``test_pick_keep``; this
|
||||
file covers how the choice reaches it (argv, web params, job handler), the
|
||||
refusal when the allowed set is full, and abandoning a kept game.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._command_params import (
|
||||
PARAMS,
|
||||
InvalidParamsError,
|
||||
validate_params,
|
||||
)
|
||||
from steam_backlog_enforcer._prompter import use_prompter
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.jobs import _registry
|
||||
from steam_backlog_enforcer.main import (
|
||||
_dispatch_extra_command,
|
||||
cmd_abandon_pick,
|
||||
cmd_pick,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._fake_prompter import FakePrompter
|
||||
from steam_backlog_enforcer.tests._main_helpers import STARTED_AT, snap
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.picks"
|
||||
_ABANDON = "steam_backlog_enforcer.main.abandon"
|
||||
|
||||
|
||||
def _pick(app_id: int, name: str, *, released: bool = True) -> dict[str, str | int]:
|
||||
entry: dict[str, str | int] = {
|
||||
"app_id": app_id,
|
||||
"game_name": name,
|
||||
"started_at": STARTED_AT,
|
||||
}
|
||||
if released:
|
||||
entry["released_at"] = STARTED_AT
|
||||
return entry
|
||||
|
||||
|
||||
def _full_state() -> State:
|
||||
"""Assignment plus two kept games: 3 allowed, the cap for 2 manual picks."""
|
||||
return State(
|
||||
current_app_id=1,
|
||||
current_game_name="Current",
|
||||
manual_picks=[_pick(2, "KeptA"), _pick(3, "KeptB")],
|
||||
)
|
||||
|
||||
|
||||
def _config() -> Config:
|
||||
return Config(steam_api_key="k", steam_id="i", max_manual_picks=2)
|
||||
|
||||
|
||||
def _output(mock_echo: MagicMock) -> str:
|
||||
return " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdPickKeep:
|
||||
def test_keeps_by_default(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
patch(f"{PKG}.hide_others_and_report"),
|
||||
):
|
||||
cmd_pick(_config(), State(), [])
|
||||
assert mock_pick.call_args.kwargs["keep"] is True
|
||||
|
||||
def test_replace_flag_replaces(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
patch(f"{PKG}.hide_others_and_report"),
|
||||
):
|
||||
cmd_pick(_config(), _full_state(), ["--replace"])
|
||||
assert mock_pick.call_args.kwargs["keep"] is False
|
||||
|
||||
def test_unknown_argument_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_pick(_config(), State(), ["--keep"])
|
||||
assert exc.value.code == 1
|
||||
assert "--replace" in _output(mock_echo)
|
||||
mock_pick.assert_not_called()
|
||||
|
||||
def test_full_allowed_set_is_refused_before_the_list(self) -> None:
|
||||
# No scripted answers: being asked anything raises IndexError.
|
||||
with (
|
||||
use_prompter(FakePrompter()),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}.load_snapshot") as mock_snapshot,
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_pick(_config(), _full_state(), [])
|
||||
assert exc.value.code == 1
|
||||
mock_snapshot.assert_not_called()
|
||||
mock_pick.assert_not_called()
|
||||
output = _output(mock_echo)
|
||||
for name in ("Current (AppID=1)", "KeptA (AppID=2)", "KeptB (AppID=3)"):
|
||||
assert name in output
|
||||
assert "abandon-pick 2" in output
|
||||
assert "--replace" in output
|
||||
|
||||
def test_one_below_the_cap_is_allowed(self) -> None:
|
||||
state = State(
|
||||
current_app_id=1, current_game_name="Current", manual_picks=[_pick(2, "A")]
|
||||
)
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[snap(5, "New")]),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
patch(f"{PKG}.hide_others_and_report"),
|
||||
):
|
||||
cmd_pick(_config(), state, [])
|
||||
mock_pick.assert_called_once()
|
||||
|
||||
|
||||
class TestDispatch:
|
||||
def test_main_passes_the_argv_tail_to_pick(self) -> None:
|
||||
config, state = _config(), State()
|
||||
with (
|
||||
patch("sys.argv", ["sbe", "pick", "--replace"]),
|
||||
patch("steam_backlog_enforcer.main.cmd_pick") as mock_pick,
|
||||
):
|
||||
assert _dispatch_extra_command("pick", config, state) is True
|
||||
mock_pick.assert_called_once_with(config, state, ["--replace"])
|
||||
|
||||
|
||||
class TestReplaceParam:
|
||||
def test_defaults_to_keep(self) -> None:
|
||||
assert validate_params("pick", {}) == {"replace": 0}
|
||||
|
||||
@pytest.mark.parametrize(("raw", "want"), [(True, 1), (False, 0), (1, 1)])
|
||||
def test_accepts_booleans(self, raw: object, want: int) -> None:
|
||||
assert validate_params("pick", {"replace": raw}) == {"replace": want}
|
||||
|
||||
@pytest.mark.parametrize("raw", [2, "yes", None])
|
||||
def test_rejects_non_booleans(self, raw: object) -> None:
|
||||
with pytest.raises(InvalidParamsError):
|
||||
validate_params("pick", {"replace": raw})
|
||||
|
||||
def test_is_an_unchecked_checkbox_in_the_catalog(self) -> None:
|
||||
[spec] = PARAMS["pick"]
|
||||
assert spec.to_json() == {
|
||||
"name": "replace",
|
||||
"label": "Replace current games (uninstalls them)",
|
||||
"type": "bool",
|
||||
"required": False,
|
||||
"default": 0,
|
||||
"help": spec.help,
|
||||
}
|
||||
|
||||
|
||||
class TestPickJob:
|
||||
@pytest.mark.parametrize(("replace", "argv"), [(1, ["--replace"]), (0, [])])
|
||||
def test_replace_param_becomes_the_flag(
|
||||
self, replace: int, argv: list[str]
|
||||
) -> None:
|
||||
with patch("steam_backlog_enforcer.jobs._registry.cmd_pick") as command:
|
||||
_registry.HANDLERS["pick"]({"replace": replace}, MagicMock(), MagicMock())
|
||||
assert command.call_args.args[2] == argv
|
||||
|
||||
|
||||
class TestAbandonKeptGame:
|
||||
def test_a_kept_game_can_be_abandoned(self) -> None:
|
||||
state = State(
|
||||
current_app_id=1,
|
||||
current_game_name="Current",
|
||||
manual_picks=[_pick(2, "KeptA")],
|
||||
)
|
||||
with (
|
||||
patch(f"{_ABANDON}._echo"),
|
||||
patch("builtins.input", return_value="abandon KeptA"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{_ABANDON}.is_game_installed", return_value=True),
|
||||
patch(f"{_ABANDON}.uninstall_game", return_value=True) as uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["2"])
|
||||
assert state.manual_picks == []
|
||||
assert state.current_app_id == 1
|
||||
uninstall.assert_called_once_with(2, "KeptA")
|
||||
|
||||
def test_warning_names_the_assignment_that_survives(self) -> None:
|
||||
"""Abandoning a kept game does not leave you without an assignment."""
|
||||
state = State(
|
||||
current_app_id=1,
|
||||
current_game_name="Current",
|
||||
manual_picks=[_pick(2, "KeptA")],
|
||||
)
|
||||
with (
|
||||
patch(f"{_ABANDON}._echo") as echo,
|
||||
patch("builtins.input", return_value="no"),
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["2"])
|
||||
output = " ".join(str(c) for c in echo.call_args_list)
|
||||
assert "no assigned game" not in output
|
||||
assert "Current stays assigned" in output
|
||||
@ -144,7 +144,9 @@ class TestCmdPick:
|
||||
config = Config(steam_api_key="k", steam_id="i")
|
||||
state = State()
|
||||
|
||||
def capture_pick(games: list[GameInfo], *_args: object) -> None:
|
||||
def capture_pick(
|
||||
games: list[GameInfo], *_args: object, **_kwargs: object
|
||||
) -> None:
|
||||
captured_games.append(list(games))
|
||||
|
||||
with (
|
||||
|
||||
185
steam_backlog_enforcer/tests/test_pick_keep.py
Normal file
185
steam_backlog_enforcer/tests/test_pick_keep.py
Normal file
@ -0,0 +1,185 @@
|
||||
"""``pick`` keeps the games already allowed; ``--replace`` and ``done`` swap.
|
||||
|
||||
Drives :func:`steam_backlog_enforcer.scanning.pick_next_game` end to end with
|
||||
every filesystem, network and Steam effect patched out, and asserts on the
|
||||
allowed set (``allowed_app_ids``), which is what the daemon, the hider, the
|
||||
uninstall guard and the auto-installer all read.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer._actions import active_manual_picks, allowed_app_ids
|
||||
from steam_backlog_enforcer._prompter import use_prompter
|
||||
from steam_backlog_enforcer._scanning_assign import _assign_chosen_game
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.scanning import pick_next_game
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
from steam_backlog_enforcer.tests._fake_prompter import FakePrompter
|
||||
from steam_backlog_enforcer.tests._main_helpers import STARTED_AT
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable, Iterator
|
||||
|
||||
_ASSIGN = "steam_backlog_enforcer._scanning_assign"
|
||||
_SCAN = "steam_backlog_enforcer.scanning"
|
||||
_RELEASED = {"released_at": STARTED_AT}
|
||||
|
||||
|
||||
def _game(app_id: int, name: str) -> GameInfo:
|
||||
return GameInfo(
|
||||
app_id=app_id,
|
||||
name=name,
|
||||
total_achievements=10,
|
||||
unlocked_achievements=1,
|
||||
playtime_minutes=60,
|
||||
completionist_hours=5.0,
|
||||
)
|
||||
|
||||
|
||||
OLD, NEW = _game(1, "Old"), _game(2, "New")
|
||||
|
||||
|
||||
def _config() -> Config:
|
||||
return Config(steam_api_key="k", steam_id="i", max_manual_picks=2)
|
||||
|
||||
|
||||
def _state_on(
|
||||
app_id: int = 1,
|
||||
name: str = "Old",
|
||||
*,
|
||||
manual_picks: list[dict[str, Any]] | None = None,
|
||||
) -> State:
|
||||
state = State(manual_picks=manual_picks or [])
|
||||
state.current_app_id = app_id
|
||||
state.current_game_name = name
|
||||
state.current_assigned_at = STARTED_AT
|
||||
state.last_assigned_at[str(app_id)] = STARTED_AT
|
||||
return state
|
||||
|
||||
|
||||
class _Effects:
|
||||
"""Every side effect of an assignment, patched; the mocks for asserts."""
|
||||
|
||||
def __init__(self, stack: ExitStack, *, installed: bool) -> None:
|
||||
def p(target: str, **kw: object) -> MagicMock:
|
||||
return stack.enter_context(patch(target, **cast("dict[str, Any]", kw)))
|
||||
|
||||
p(f"{_SCAN}._collect_qualified_candidates", side_effect=lambda c: (c, 0, 0))
|
||||
p(
|
||||
f"{_ASSIGN}._pick_next_shortest_candidate",
|
||||
side_effect=lambda c: (c[0], 0, 0),
|
||||
)
|
||||
p(f"{_ASSIGN}._apply_cached_confidence_to_candidates")
|
||||
p(f"{_ASSIGN}._report_poll_confidence")
|
||||
p(f"{_ASSIGN}._echo")
|
||||
p(f"{_ASSIGN}.is_game_fully_installed", return_value=installed)
|
||||
self.uninstall = p(f"{_ASSIGN}.uninstall_other_games", return_value=0)
|
||||
self.install = p(f"{_ASSIGN}.install_game")
|
||||
stack.enter_context(patch.object(State, "save"))
|
||||
|
||||
|
||||
@contextmanager
|
||||
def _effects(*, installed: bool = True) -> Iterator[_Effects]:
|
||||
with ExitStack() as stack:
|
||||
yield _Effects(stack, installed=installed)
|
||||
|
||||
|
||||
def _pick(
|
||||
state: State,
|
||||
prompter: FakePrompter,
|
||||
*,
|
||||
keep: bool = False,
|
||||
on_select: Callable[[GameInfo], bool] | None = None,
|
||||
) -> _Effects:
|
||||
with _effects(installed=False) as effects, use_prompter(prompter):
|
||||
pick_next_game([OLD, NEW], state, _config(), keep=keep, on_select=on_select)
|
||||
return effects
|
||||
|
||||
|
||||
class TestKeep:
|
||||
def test_old_game_stays_allowed_and_is_not_uninstalled(self) -> None:
|
||||
state = _state_on()
|
||||
effects = _pick(state, FakePrompter(choices=["0"]), keep=True)
|
||||
assert state.current_app_id == 2
|
||||
assert allowed_app_ids(state) == {1, 2}
|
||||
effects.uninstall.assert_called_once_with({1, 2})
|
||||
effects.install.assert_called_once_with(2, "New", "i", use_steam_protocol=True)
|
||||
|
||||
def test_kept_game_is_a_released_pick_holding_no_lock(self) -> None:
|
||||
state = _state_on()
|
||||
_pick(state, FakePrompter(choices=["0"]), keep=True)
|
||||
[kept] = state.manual_picks
|
||||
assert kept["app_id"] == 1
|
||||
assert kept["game_name"] == "Old"
|
||||
assert kept["started_at"] == STARTED_AT
|
||||
assert kept["released_at"]
|
||||
assert active_manual_picks(state) == []
|
||||
assert state.finished_app_ids == []
|
||||
assert state.released_at == {}
|
||||
|
||||
def test_allowed_games_are_not_offered(self) -> None:
|
||||
state = _state_on()
|
||||
prompter = FakePrompter(choices=["0"])
|
||||
_pick(state, prompter, keep=True)
|
||||
assert prompter.asked[0][1] == ["0", "own"]
|
||||
|
||||
def test_own_pick_search_excludes_allowed_games(self) -> None:
|
||||
state = _state_on()
|
||||
with patch(f"{_ASSIGN}.prompt_own_pick", return_value=NEW) as own:
|
||||
_pick(state, FakePrompter(choices=["own"]), keep=True)
|
||||
assert [g.app_id for g in own.call_args.args[0]] == [2]
|
||||
|
||||
def test_pick_manual_then_pick_does_not_duplicate_the_active_pick(self) -> None:
|
||||
state = _state_on(
|
||||
manual_picks=[{"app_id": 1, "game_name": "Old", "started_at": STARTED_AT}]
|
||||
)
|
||||
_pick(state, FakePrompter(choices=["0"]), keep=True)
|
||||
assert state.manual_picks == [
|
||||
{"app_id": 1, "game_name": "Old", "started_at": STARTED_AT}
|
||||
]
|
||||
assert [p["app_id"] for p in active_manual_picks(state)] == [1]
|
||||
assert allowed_app_ids(state) == {1, 2}
|
||||
|
||||
def test_released_entry_for_the_current_game_is_not_duplicated(self) -> None:
|
||||
entry = {"app_id": 1, "game_name": "Old", "started_at": STARTED_AT}
|
||||
state = _state_on(manual_picks=[entry | _RELEASED])
|
||||
_pick(state, FakePrompter(choices=["0"]), keep=True)
|
||||
assert [p["app_id"] for p in state.manual_picks] == [1]
|
||||
|
||||
def test_earlier_kept_games_survive_another_keep(self) -> None:
|
||||
entry = {"app_id": 9, "game_name": "Kept", "started_at": STARTED_AT}
|
||||
state = _state_on(manual_picks=[entry | _RELEASED])
|
||||
effects = _pick(state, FakePrompter(choices=["0"]), keep=True)
|
||||
assert allowed_app_ids(state) == {1, 2, 9}
|
||||
effects.uninstall.assert_called_once_with({1, 2, 9})
|
||||
|
||||
def test_choosing_an_allowed_game_changes_nothing(self) -> None:
|
||||
state = _state_on()
|
||||
with _effects() as effects:
|
||||
_assign_chosen_game(OLD, [OLD, NEW], state, _config(), keep=True)
|
||||
assert state.manual_picks == []
|
||||
assert state.current_app_id == 1
|
||||
effects.uninstall.assert_not_called()
|
||||
|
||||
|
||||
class TestReplace:
|
||||
def test_replace_drops_old_and_released_games(self) -> None:
|
||||
entry = {"app_id": 9, "game_name": "Kept", "started_at": STARTED_AT}
|
||||
state = _state_on(manual_picks=[entry | _RELEASED])
|
||||
effects = _pick(state, FakePrompter(choices=["0"]))
|
||||
assert state.current_app_id == 2
|
||||
assert allowed_app_ids(state) == {2}
|
||||
effects.uninstall.assert_called_once_with({2})
|
||||
|
||||
def test_done_path_still_replaces(self) -> None:
|
||||
"""``done``/``check`` reach the sequential picker; it never keeps."""
|
||||
state = _state_on()
|
||||
state.skip_for_days(1, 7)
|
||||
effects = _pick(state, FakePrompter(), on_select=lambda _g: True)
|
||||
assert state.current_app_id == 2
|
||||
assert state.manual_picks == []
|
||||
effects.uninstall.assert_called_once_with({2})
|
||||
176
steam_backlog_enforcer/tests/test_pick_manual_keep.py
Normal file
176
steam_backlog_enforcer/tests/test_pick_manual_keep.py
Normal file
@ -0,0 +1,176 @@
|
||||
"""``pick-manual`` keeps the games already allowed, under the ``pick`` cap.
|
||||
|
||||
Covers the shared core (:func:`apply_manual_pick`), the CLI
|
||||
(``cmd_pick_manual``) and the MCP ``pick_manual`` tool. Every Steam,
|
||||
filesystem and network effect is patched out.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import ExitStack
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer import _mcp_actions as mcp_actions
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids, apply_manual_pick
|
||||
from steam_backlog_enforcer._allowed_games import active_manual_picks
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import cmd_pick_manual
|
||||
from steam_backlog_enforcer.tests._main_helpers import STARTED_AT
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.picks"
|
||||
|
||||
|
||||
def _kept(app_id: int, name: str) -> dict[str, str | int]:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"game_name": name,
|
||||
"started_at": STARTED_AT,
|
||||
"released_at": STARTED_AT,
|
||||
}
|
||||
|
||||
|
||||
def _state(*kept: tuple[int, str], current: tuple[int, str] = (1, "Current")) -> State:
|
||||
"""An auto assignment (not a stored pick) plus released, kept games."""
|
||||
return State(
|
||||
current_app_id=current[0],
|
||||
current_game_name=current[1],
|
||||
current_assigned_at=STARTED_AT,
|
||||
manual_picks=[_kept(a, n) for a, n in kept],
|
||||
)
|
||||
|
||||
|
||||
def _entries(state: State, app_id: int) -> list[dict[str, object]]:
|
||||
return [p for p in state.manual_picks if p.get("app_id") == app_id]
|
||||
|
||||
|
||||
class TestApplyManualPickKeeps:
|
||||
def test_kept_games_and_outgoing_assignment_stay_allowed(self) -> None:
|
||||
state = _state((2, "KeptA"))
|
||||
with patch.object(State, "save"):
|
||||
assert apply_manual_pick(state, 5, "New", max_picks=2) is None
|
||||
assert allowed_app_ids(state) == {1, 2, 5}
|
||||
assert [p["app_id"] for p in active_manual_picks(state)] == [5]
|
||||
assert state.current_app_id == 5
|
||||
|
||||
def test_over_cap_is_refused_naming_the_games(self) -> None:
|
||||
state = _state((2, "KeptA"), (3, "KeptB"))
|
||||
with patch.object(State, "save") as save:
|
||||
refused = apply_manual_pick(state, 5, "New", max_picks=2)
|
||||
assert refused is not None
|
||||
for name in ("Current (AppID=1)", "KeptA (AppID=2)", "KeptB (AppID=3)"):
|
||||
assert name in refused
|
||||
assert "abandon-pick 2" in refused
|
||||
assert "--replace" not in refused
|
||||
save.assert_not_called()
|
||||
assert state.current_app_id == 1
|
||||
assert [p["app_id"] for p in state.manual_picks] == [2, 3]
|
||||
|
||||
def test_repicking_a_kept_game_activates_it_without_duplicating(self) -> None:
|
||||
# At the cap, but re-picking a game already allowed adds nothing.
|
||||
state = _state((2, "KeptA"), (3, "KeptB"))
|
||||
with patch.object(State, "save"):
|
||||
assert apply_manual_pick(state, 2, "KeptA", max_picks=2) is None
|
||||
assert len(_entries(state, 2)) == 1
|
||||
assert "released_at" not in _entries(state, 2)[0]
|
||||
assert [p["app_id"] for p in active_manual_picks(state)] == [2]
|
||||
assert allowed_app_ids(state) == {1, 2, 3}
|
||||
|
||||
def test_repicking_the_kept_current_game_does_not_duplicate(self) -> None:
|
||||
state = _state((2, "KeptA"), current=(2, "KeptA"))
|
||||
with patch.object(State, "save"):
|
||||
assert apply_manual_pick(state, 2, "KeptA", max_picks=2) is None
|
||||
assert len(state.manual_picks) == 1
|
||||
assert active_manual_picks(state) == state.manual_picks
|
||||
|
||||
def test_finished_pick_is_kept_too(self) -> None:
|
||||
state = _state((2, "Done"))
|
||||
state.finished_app_ids = [2]
|
||||
with patch.object(State, "save"):
|
||||
assert apply_manual_pick(state, 5, "New", max_picks=2) is None
|
||||
assert 2 in allowed_app_ids(state)
|
||||
|
||||
|
||||
def _cli_effects(stack: ExitStack, *, confirm: bool = True) -> dict[str, MagicMock]:
|
||||
mocks = {
|
||||
"echo": stack.enter_context(patch(f"{PKG}._echo")),
|
||||
"confirm": stack.enter_context(
|
||||
patch(f"{PKG}.confirm_phrase", return_value=confirm)
|
||||
),
|
||||
"uninstall": stack.enter_context(
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0)
|
||||
),
|
||||
}
|
||||
stack.enter_context(patch(f"{PKG}.report_completion", return_value=[]))
|
||||
stack.enter_context(patch(f"{PKG}._resolve_game_name", return_value="New"))
|
||||
stack.enter_context(patch.object(State, "save"))
|
||||
stack.enter_context(patch(f"{PKG}.is_game_fully_installed", return_value=True))
|
||||
stack.enter_context(
|
||||
patch(
|
||||
"steam_backlog_enforcer.library_hider.get_all_owned_app_ids",
|
||||
return_value=[],
|
||||
)
|
||||
)
|
||||
return mocks
|
||||
|
||||
|
||||
def _output(mock_echo: MagicMock) -> str:
|
||||
return " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdPickManualKeeps:
|
||||
def test_kept_games_are_not_uninstalled(self) -> None:
|
||||
state = _state((2, "KeptA"))
|
||||
with ExitStack() as stack:
|
||||
mocks = _cli_effects(stack)
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["5"])
|
||||
mocks["uninstall"].assert_called_once_with({1, 2, 5})
|
||||
output = _output(mocks["echo"])
|
||||
assert "Replace" not in output
|
||||
assert "stay installed" in output
|
||||
assert "KeptA" in output
|
||||
|
||||
def test_over_cap_is_refused_before_the_confirmation(self) -> None:
|
||||
state = _state((2, "KeptA"), (3, "KeptB"))
|
||||
with ExitStack() as stack:
|
||||
mocks = _cli_effects(stack)
|
||||
with pytest.raises(SystemExit) as exc:
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["5"])
|
||||
assert exc.value.code == 1
|
||||
mocks["confirm"].assert_not_called()
|
||||
mocks["uninstall"].assert_not_called()
|
||||
output = _output(mocks["echo"])
|
||||
for name in ("Current (AppID=1)", "KeptA (AppID=2)", "KeptB (AppID=3)"):
|
||||
assert name in output
|
||||
assert "abandon-pick 3" in output
|
||||
|
||||
|
||||
class TestMcpPickManualKeeps:
|
||||
def _run(self, state: State) -> dict[str, object]:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer._snapshot.load_snapshot",
|
||||
return_value=[{"app_id": 5, "name": "New"}],
|
||||
),
|
||||
patch.object(Config, "load", return_value=Config()),
|
||||
patch.object(State, "load", return_value=state),
|
||||
patch.object(State, "save"),
|
||||
patch.object(mcp_actions, "retire_completed_manual_picks", return_value=[]),
|
||||
patch(f"{PKG}.uninstall_other_games") as uninstall,
|
||||
):
|
||||
out = mcp_actions.pick_manual(5, confirm=True)
|
||||
uninstall.assert_not_called()
|
||||
return out
|
||||
|
||||
def test_kept_game_survives(self) -> None:
|
||||
state = _state((2, "KeptA"))
|
||||
assert self._run(state)["ok"] is True
|
||||
assert allowed_app_ids(state) == {1, 2, 5}
|
||||
|
||||
def test_over_cap_is_refused(self) -> None:
|
||||
state = _state((2, "KeptA"), (3, "KeptB"))
|
||||
out = self._run(state)
|
||||
assert out["ok"] is False
|
||||
assert "KeptB (AppID=3)" in str(out["reason"])
|
||||
assert allowed_app_ids(state) == {1, 2, 3}
|
||||
@ -104,12 +104,12 @@ class TestReleasedPicksStayPlayable:
|
||||
assert [p["app_id"] for p in dropped] == [2]
|
||||
assert [p["app_id"] for p in state.manual_picks] == [3]
|
||||
|
||||
def test_new_manual_pick_replaces_released_ones(self) -> None:
|
||||
def test_new_manual_pick_keeps_released_ones(self) -> None:
|
||||
state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)])
|
||||
with patch.object(State, "save"):
|
||||
assert apply_manual_pick(state, 4, "G4", max_picks=1) is None
|
||||
assert allowed_app_ids(state) == {4}
|
||||
assert state.current_assigned_at == state.manual_picks[0]["started_at"]
|
||||
assert allowed_app_ids(state) == {2, 4}
|
||||
assert state.current_assigned_at == state.manual_picks[-1]["started_at"]
|
||||
|
||||
def test_released_pick_can_be_abandoned(self) -> None:
|
||||
state = State(
|
||||
|
||||
72
steam_backlog_enforcer/tests/test_status_kept_picks.py
Normal file
72
steam_backlog_enforcer/tests/test_status_kept_picks.py
Normal file
@ -0,0 +1,72 @@
|
||||
"""Kept games (released, no-lock picks) are visible in every status view.
|
||||
|
||||
``pick`` parks the outgoing game as a released pick; it is installed and
|
||||
playable, so ``status``, ``GET /api/status`` and the web Picks card must list
|
||||
it (under the active picks) with a way to abandon it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer import _web_views
|
||||
from steam_backlog_enforcer._manual_pick_lifecycle import status_payload
|
||||
from steam_backlog_enforcer._total_block import TotalBlockStatus
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import cmd_status
|
||||
from steam_backlog_enforcer.tests._main_helpers import STARTED_AT
|
||||
from steam_backlog_enforcer.tests._web_request import make_request, payload_of
|
||||
|
||||
_ACTIVE = {"app_id": 1, "game_name": "Locked", "started_at": STARTED_AT}
|
||||
_KEPT = {
|
||||
"app_id": 2,
|
||||
"game_name": "Kept",
|
||||
"started_at": STARTED_AT,
|
||||
"released_at": STARTED_AT,
|
||||
}
|
||||
_NO_BLOCK = TotalBlockStatus(
|
||||
active=False, started_at=None, until=None, days=0, days_remaining=0.0
|
||||
)
|
||||
_STATUS = "steam_backlog_enforcer.main.status"
|
||||
|
||||
|
||||
def _state() -> State:
|
||||
return State(
|
||||
current_app_id=3,
|
||||
current_game_name="Current",
|
||||
manual_picks=[dict(_ACTIVE), dict(_KEPT)],
|
||||
)
|
||||
|
||||
|
||||
def test_payload_lists_kept_picks_apart_from_active_ones() -> None:
|
||||
with patch.multiple(
|
||||
"steam_backlog_enforcer._manual_pick_lifecycle",
|
||||
get_total_block_status=lambda: _NO_BLOCK,
|
||||
get_installed_games=list,
|
||||
is_store_blocked=lambda: True,
|
||||
is_protected_app=lambda _aid: False,
|
||||
):
|
||||
payload = status_payload(_state())
|
||||
assert [p["app_id"] for p in payload["manual_picks"]] == [1]
|
||||
assert payload["kept_picks"] == [_KEPT]
|
||||
|
||||
|
||||
def test_status_view_projects_kept_picks() -> None:
|
||||
raw = {"assigned_game_installed": True, "manual_picks": [], "kept_picks": [_KEPT]}
|
||||
with patch(f"{_web_views.__name__}.status_payload", return_value=raw):
|
||||
body = payload_of(_web_views.status_view(make_request()))
|
||||
assert body["kept_picks"] == [{"app_id": 2, "name": "Kept"}]
|
||||
|
||||
|
||||
def test_cli_status_lists_kept_games() -> None:
|
||||
with (
|
||||
patch(f"{_STATUS}.is_store_blocked", return_value=False),
|
||||
patch(f"{_STATUS}.get_installed_games", return_value=[]),
|
||||
patch(f"{_STATUS}.report_completion", return_value=[]),
|
||||
patch(f"{_STATUS}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), _state())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Kept games, no lock (1):" in output
|
||||
assert "Kept (AppID=2)" in output
|
||||
assert "abandon-pick 2" in output
|
||||
@ -9,7 +9,12 @@ import pytest
|
||||
|
||||
from steam_backlog_enforcer import _web_daemon
|
||||
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError
|
||||
from steam_backlog_enforcer._ctl_types import DaemonInfo, PendingReset, ResetResult
|
||||
from steam_backlog_enforcer._ctl_types import (
|
||||
DaemonInfo,
|
||||
FastInstallInfo,
|
||||
PendingReset,
|
||||
ResetResult,
|
||||
)
|
||||
from steam_backlog_enforcer._web_errors import ApiError
|
||||
from steam_backlog_enforcer.jobs._view import list_jobs
|
||||
from steam_backlog_enforcer.tests._web_request import make_request, payload_of
|
||||
@ -45,6 +50,7 @@ class TestDaemonView:
|
||||
with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError):
|
||||
reply = _web_daemon.daemon_view(make_request())
|
||||
assert payload_of(reply)["state"] == "unreachable"
|
||||
assert payload_of(reply)["fast_installs"] == []
|
||||
|
||||
def test_status_refusal_is_an_error(self) -> None:
|
||||
with (
|
||||
@ -67,8 +73,27 @@ class TestDaemonView:
|
||||
"pid": 77,
|
||||
"journal_tail": ["a", "b"],
|
||||
"restart_available_at": None,
|
||||
"fast_installs": [],
|
||||
}
|
||||
|
||||
def test_fast_installs_pass_through(self) -> None:
|
||||
run = FastInstallInfo(7, "G", "downloading depot 3/8", 46.9, 60)
|
||||
info = DaemonInfo("running", "t", 77, None, fast_installs=(run,))
|
||||
with (
|
||||
patch(f"{_CLIENT}.status", return_value=info),
|
||||
patch(f"{_CLIENT}.journal_tail", return_value=[]),
|
||||
):
|
||||
reply = _web_daemon.daemon_view(make_request())
|
||||
assert payload_of(reply)["fast_installs"] == [
|
||||
{
|
||||
"app_id": 7,
|
||||
"name": "G",
|
||||
"phase": "downloading depot 3/8",
|
||||
"percent": 46.9,
|
||||
"elapsed_seconds": 60,
|
||||
}
|
||||
]
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"failure", [DaemonUnreachableError(), CtlError("busy", "m")]
|
||||
)
|
||||
|
||||
@ -39,7 +39,13 @@ export const BASE: Base[] = [
|
||||
spec('check', 'Check assigned game for a new achievement', 'backlog', 'job', { mutating: false }),
|
||||
spec('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }),
|
||||
spec('done', 'Move on after a new achievement, pick next', 'backlog', 'job'),
|
||||
spec('pick', 'Manually pick your next game from candidates', 'backlog', 'job', { cancellable: true }),
|
||||
spec('pick', 'Pick another game from candidates (keeps current; --replace swaps)', 'backlog', 'job', {
|
||||
cancellable: true,
|
||||
params: [{
|
||||
name: 'replace', label: 'Replace current games (uninstalls them)', type: 'bool', required: false, default: 0,
|
||||
help: 'Leave unchecked to keep playing them alongside the new pick.',
|
||||
}],
|
||||
}),
|
||||
spec('pick-manual', 'Pick a game by app_id, lock enforcer for 14 days', 'backlog', 'job', {
|
||||
params: [APP_ID], friction: phrase('lock in {game_name}'),
|
||||
}),
|
||||
|
||||
@ -60,6 +60,7 @@ function checkParam(p: ParamSpec, raw: unknown): number | string | undefined {
|
||||
}
|
||||
if (p.type === 'string') return String(raw)
|
||||
const n = Number(raw)
|
||||
if (p.type === 'bool' && n !== 0 && n !== 1) throw new HttpError(400, 'invalid_params', `${p.label} must be true or false.`)
|
||||
if (!Number.isInteger(n)) throw new HttpError(400, 'invalid_params', `${p.label} must be a whole number.`)
|
||||
if (p.min !== undefined && n < p.min) throw new HttpError(400, 'invalid_params', `${p.label} must be at least ${p.min}.`)
|
||||
if (p.max !== undefined && n > p.max) throw new HttpError(400, 'invalid_params', `${p.label} must be at most ${p.max}.`)
|
||||
|
||||
@ -76,6 +76,12 @@ const pick: Script = async (ctx) => {
|
||||
}
|
||||
ctx.progress(2, 2, 'Assigning', gameName(Number(chosen)))
|
||||
await ctx.sleep(700)
|
||||
// Mirrors _pick_keep: unchecked parks the outgoing game; replace drops all.
|
||||
const old = world.currentAppId
|
||||
if (Number(ctx.params.replace)) world.keptPicks = []
|
||||
else if (old !== null && !world.keptPicks.includes(old) && !world.manualPicks.some((p) => p.app_id === old)) {
|
||||
world.keptPicks.push(old)
|
||||
}
|
||||
world.currentAppId = Number(chosen)
|
||||
return { summary: `Assigned ${gameName(Number(chosen))}.` }
|
||||
}
|
||||
@ -177,6 +183,7 @@ export const SCRIPTS: Partial<Record<CommandName, Script>> = {
|
||||
'abandon-pick': async (ctx) => {
|
||||
await ctx.sleep(800)
|
||||
world.manualPicks = world.manualPicks.filter((p) => p.app_id !== Number(ctx.params.app_id))
|
||||
world.keptPicks = world.keptPicks.filter((id) => id !== Number(ctx.params.app_id))
|
||||
return { summary: `Abandoned ${gameName(Number(ctx.params.app_id))}.` }
|
||||
},
|
||||
'add-exception': async (ctx) => {
|
||||
|
||||
@ -35,6 +35,8 @@ export interface World {
|
||||
storeBlockedUntil: number | null
|
||||
totalBlockUntil: number | null
|
||||
manualPicks: { app_id: number; picked_at: number }[]
|
||||
/** Games `pick` kept: allowed, no lock. */
|
||||
keptPicks: number[]
|
||||
installed: InstalledGame[]
|
||||
backups: StateBackup[]
|
||||
journal: string[]
|
||||
@ -57,6 +59,7 @@ export const world: World = {
|
||||
storeBlockedUntil: null,
|
||||
totalBlockUntil: null,
|
||||
manualPicks: [],
|
||||
keptPicks: [],
|
||||
installed: makeInstalled(1145360),
|
||||
backups: [
|
||||
{ id: '20261009-221504', created_at: iso(now - DAY), reason: 'before reset', size_bytes: 48_211 },
|
||||
@ -106,6 +109,7 @@ export function status(): StatusPayload {
|
||||
name: gameName(p.app_id),
|
||||
age_days: Math.floor((t - p.picked_at) / DAY),
|
||||
})),
|
||||
kept_picks: world.keptPicks.map((id) => ({ app_id: id, name: gameName(id) })),
|
||||
}
|
||||
}
|
||||
|
||||
@ -163,6 +167,7 @@ export function daemonStatus(): DaemonStatus {
|
||||
journal_tail: reachable ? world.journal.slice(-60) : [],
|
||||
restart_available_at:
|
||||
world.restartAvailableAt !== null && world.restartAvailableAt > t ? iso(world.restartAvailableAt) : null,
|
||||
fast_installs: [],
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
302
web/package-lock.json
generated
302
web/package-lock.json
generated
@ -8,7 +8,7 @@
|
||||
"name": "web",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.104.4",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@tanstack/react-router": "1.170.45",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
@ -33,8 +33,8 @@
|
||||
"globals": "17.13.0",
|
||||
"jsdom": "30.1.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"vite": "8.3.4",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
},
|
||||
@ -1186,9 +1186,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@oxc-project/types": {
|
||||
"version": "0.153.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
||||
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
||||
"version": "0.155.0",
|
||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@ -1196,9 +1196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
||||
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -1213,9 +1213,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-android-arm64": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1230,9 +1230,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1247,9 +1247,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-darwin-x64": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1264,9 +1264,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
|
||||
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1281,9 +1281,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
|
||||
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
@ -1298,9 +1298,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1318,9 +1318,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1338,9 +1338,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
@ -1358,9 +1358,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
@ -1378,9 +1378,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
|
||||
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1398,9 +1398,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
|
||||
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1418,9 +1418,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
|
||||
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1435,9 +1435,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
@ -1452,9 +1452,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
|
||||
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
@ -1520,9 +1520,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/query-core": {
|
||||
"version": "5.104.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.4.tgz",
|
||||
"integrity": "sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==",
|
||||
"version": "5.104.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.5.tgz",
|
||||
"integrity": "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@ -1530,12 +1530,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tanstack/react-query": {
|
||||
"version": "5.104.4",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.4.tgz",
|
||||
"integrity": "sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==",
|
||||
"version": "5.104.5",
|
||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.5.tgz",
|
||||
"integrity": "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tanstack/query-core": "5.104.4"
|
||||
"@tanstack/query-core": "5.104.5"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
@ -1810,17 +1810,17 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
|
||||
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
|
||||
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/regexpp": "^4.12.2",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/type-utils": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/type-utils": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"ignore": "^7.0.5",
|
||||
"natural-compare": "^1.4.0",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
@ -1833,15 +1833,15 @@
|
||||
"url": "https://opencollective.com/typescript-eslint"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@typescript-eslint/parser": "^8.71.2",
|
||||
"@typescript-eslint/parser": "^8.72.0",
|
||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||
"typescript": ">=4.8.4 <6.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||
"version": "7.0.12",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
|
||||
"integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
|
||||
"version": "7.0.13",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz",
|
||||
"integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -1849,16 +1849,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/parser": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
|
||||
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
|
||||
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -1874,14 +1874,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/project-service": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
|
||||
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
||||
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/tsconfig-utils": "^8.71.2",
|
||||
"@typescript-eslint/types": "^8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
||||
"@typescript-eslint/types": "^8.72.0",
|
||||
"debug": "^4.4.3"
|
||||
},
|
||||
"engines": {
|
||||
@ -1896,14 +1896,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/scope-manager": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
|
||||
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
||||
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2"
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@ -1914,9 +1914,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
||||
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -1931,15 +1931,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/type-utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
|
||||
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
|
||||
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0",
|
||||
"debug": "^4.4.3",
|
||||
"ts-api-utils": "^2.5.0"
|
||||
},
|
||||
@ -1956,9 +1956,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/types": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
|
||||
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
||||
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@ -1970,16 +1970,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
|
||||
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
||||
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/project-service": "8.71.2",
|
||||
"@typescript-eslint/tsconfig-utils": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
||||
"@typescript-eslint/project-service": "8.72.0",
|
||||
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||
"debug": "^4.4.3",
|
||||
"minimatch": "^10.2.2",
|
||||
"semver": "^7.7.3",
|
||||
@ -2011,16 +2011,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/utils": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
|
||||
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
||||
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@eslint-community/eslint-utils": "^4.9.1",
|
||||
"@typescript-eslint/scope-manager": "8.71.2",
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2"
|
||||
"@typescript-eslint/scope-manager": "8.72.0",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@ -2035,13 +2035,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/visitor-keys": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
|
||||
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
||||
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/types": "8.71.2",
|
||||
"@typescript-eslint/types": "8.72.0",
|
||||
"eslint-visitor-keys": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
@ -3892,9 +3892,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.29",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
||||
"version": "8.5.31",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
||||
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@ -3912,7 +3912,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.19",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
@ -4028,13 +4028,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/rolldown": {
|
||||
"version": "1.2.13",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
|
||||
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
|
||||
"version": "1.2.15",
|
||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@oxc-project/types": "=0.153.0",
|
||||
"@oxc-project/types": "=0.155.0",
|
||||
"@rolldown/pluginutils": "^1.0.0"
|
||||
},
|
||||
"bin": {
|
||||
@ -4044,21 +4044,21 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.13",
|
||||
"@rolldown/binding-android-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.13",
|
||||
"@rolldown/binding-darwin-x64": "1.2.13",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.13",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.13",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.13",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.13",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.13",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.13"
|
||||
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||
"@rolldown/binding-android-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||
}
|
||||
},
|
||||
"node_modules/saxes": {
|
||||
@ -4298,16 +4298,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/typescript-eslint": {
|
||||
"version": "8.71.2",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz",
|
||||
"integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==",
|
||||
"version": "8.72.0",
|
||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz",
|
||||
"integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
||||
"@typescript-eslint/parser": "8.71.2",
|
||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
||||
"@typescript-eslint/utils": "8.71.2"
|
||||
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||
"@typescript-eslint/parser": "8.72.0",
|
||||
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||
"@typescript-eslint/utils": "8.72.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
@ -4402,16 +4402,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "8.3.4",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
||||
"version": "8.3.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.33.0",
|
||||
"picomatch": "^4.0.7",
|
||||
"postcss": "^8.5.29",
|
||||
"rolldown": "~1.2.12",
|
||||
"postcss": "^8.5.30",
|
||||
"rolldown": "~1.2.14",
|
||||
"tinyglobby": "^0.2.17"
|
||||
},
|
||||
"bin": {
|
||||
@ -4428,7 +4428,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@types/node": "^20.19.0 || >=22.12.0",
|
||||
"@vitejs/devtools": "^0.7.1",
|
||||
"@vitejs/devtools": "^0.7.1 || ^0.8.0",
|
||||
"esbuild": "^0.27.0 || ^0.28.0",
|
||||
"jiti": ">=1.21.0",
|
||||
"less": "^4.0.0",
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
"coverage": "vitest run --coverage"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "5.104.4",
|
||||
"@tanstack/react-query": "5.104.5",
|
||||
"@tanstack/react-router": "1.170.45",
|
||||
"react": "19.3.1",
|
||||
"react-dom": "19.3.1"
|
||||
@ -39,8 +39,8 @@
|
||||
"globals": "17.13.0",
|
||||
"jsdom": "30.1.3",
|
||||
"typescript": "6.0.3",
|
||||
"typescript-eslint": "8.71.2",
|
||||
"vite": "8.3.4",
|
||||
"typescript-eslint": "8.72.0",
|
||||
"vite": "8.3.5",
|
||||
"vitest": "5.0.5"
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,7 +47,8 @@ export type CommandCategory = 'backlog' | 'library' | 'gaming' | 'store' | 'syst
|
||||
export interface ParamSpec {
|
||||
name: string
|
||||
label: string
|
||||
type: 'int' | 'string' | 'app_id'
|
||||
/** `bool` is a checkbox, sent as 0/1. */
|
||||
type: 'int' | 'string' | 'app_id' | 'bool'
|
||||
required: boolean
|
||||
min?: number
|
||||
max?: number
|
||||
@ -101,6 +102,13 @@ export interface StatusPayload {
|
||||
total_block: { active: boolean; days_remaining: number; until: string | null }
|
||||
manual_pick_locked: boolean
|
||||
manual_picks: ManualPickInfo[]
|
||||
/** Released/expired picks and games `pick` kept: allowed, no lock. */
|
||||
kept_picks: KeptPickInfo[]
|
||||
}
|
||||
|
||||
export interface KeptPickInfo {
|
||||
app_id: number
|
||||
name: string
|
||||
}
|
||||
|
||||
export interface StatsPayload {
|
||||
@ -156,6 +164,17 @@ export interface SetupRequest {
|
||||
|
||||
export type DaemonState = 'running' | 'restarting' | 'unreachable'
|
||||
|
||||
/** A steam-game-installer run the root daemon started, read from its log. */
|
||||
export interface FastInstallRun {
|
||||
app_id: number
|
||||
name: string
|
||||
/** e.g. `shutting Steam down`, `downloading depot 3/8`, `finishing manifest`. */
|
||||
phase: string
|
||||
/** The current depot's percent; null until depotdownloader reports one. */
|
||||
percent: number | null
|
||||
elapsed_seconds: number
|
||||
}
|
||||
|
||||
export interface DaemonStatus {
|
||||
state: DaemonState
|
||||
started_at: string | null
|
||||
@ -163,6 +182,11 @@ export interface DaemonStatus {
|
||||
journal_tail: string[]
|
||||
/** ISO time before which `restart` is rate-limited, or null. */
|
||||
restart_available_at: string | null
|
||||
/**
|
||||
* Empty when none is running or the daemon is unreachable; absent from a
|
||||
* web server older than this UI (the window the `stale` health flag covers).
|
||||
*/
|
||||
fast_installs?: FastInstallRun[]
|
||||
}
|
||||
|
||||
export interface ServerHealth {
|
||||
|
||||
@ -56,4 +56,20 @@ describe('ParamsForm', () => {
|
||||
render(<Harness disabled />)
|
||||
expect(screen.getByLabelText('Note')).toBeDisabled()
|
||||
})
|
||||
|
||||
it('renders a bool as an unchecked checkbox that toggles 0/1', async () => {
|
||||
const box: ParamSpec[] = [{ name: 'replace', label: 'Replace', type: 'bool', required: false, default: 0 }]
|
||||
function BoxHarness() {
|
||||
const [values, setValues] = useState<ParamValues>({ replace: '0' })
|
||||
return <ParamsForm specs={box} values={values} onChange={setValues} games={[]} showErrors disabled={false} />
|
||||
}
|
||||
render(<BoxHarness />)
|
||||
const input = screen.getByLabelText('Replace')
|
||||
expect(input).toHaveAttribute('type', 'checkbox')
|
||||
expect(input).not.toBeChecked()
|
||||
await userEvent.click(input)
|
||||
expect(input).toBeChecked()
|
||||
await userEvent.click(input)
|
||||
expect(input).not.toBeChecked()
|
||||
})
|
||||
})
|
||||
|
||||
@ -18,6 +18,26 @@ export function ParamsForm({ specs, values, onChange, games, showErrors, disable
|
||||
{specs.map((p, i) => {
|
||||
const id = `${base}-${p.name}`
|
||||
const raw = values[p.name] ?? ''
|
||||
if (p.type === 'bool') {
|
||||
return (
|
||||
<div className="form-field" key={p.name}>
|
||||
<label className="check" htmlFor={id}>
|
||||
<input
|
||||
id={id}
|
||||
type="checkbox"
|
||||
checked={raw === '1'}
|
||||
disabled={disabled}
|
||||
aria-describedby={`${id}-help`}
|
||||
onChange={(e) => onChange({ ...values, [p.name]: e.target.checked ? '1' : '0' })}
|
||||
/>
|
||||
{p.label}
|
||||
</label>
|
||||
<p id={`${id}-help`} className="field-help">
|
||||
{p.help ?? ' '}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
const err = paramError(p, raw, games)
|
||||
const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined
|
||||
return (
|
||||
|
||||
@ -172,7 +172,7 @@ function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) {
|
||||
</ErrorNotice>
|
||||
)}
|
||||
<div className="actions">
|
||||
<button type="submit" className={phrase ? 'btn btn-danger' : 'btn btn-primary'} disabled={!canSubmit || start.isPending} autoFocus={!phrase && spec.params.length === 0}>
|
||||
<button type="submit" className={phrase ? 'btn btn-danger' : 'btn btn-primary'} disabled={!canSubmit || start.isPending} autoFocus={!phrase && spec.params.every((p) => p.type === 'bool')}>
|
||||
{start.isPending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${spec.name}`}
|
||||
</button>
|
||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||
|
||||
@ -14,6 +14,10 @@ describe('paramError', () => {
|
||||
expect(paramError(spec({ required: false }), '', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('never flags a checkbox', () => {
|
||||
expect(paramError(spec({ type: 'bool', required: false, default: 0 }), '1', [])).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts any text for a string', () => {
|
||||
expect(paramError(spec({ type: 'string' }), 'anything', [])).toBeNull()
|
||||
})
|
||||
@ -52,6 +56,12 @@ describe('toParams', () => {
|
||||
expect(toParams(specs, { a: '', b: ' hi ', c: '42' })).toEqual({ a: 7, b: 'hi', c: 42 })
|
||||
})
|
||||
|
||||
it('sends a checkbox as 0/1', () => {
|
||||
const box = [spec({ name: 'r', type: 'bool', required: false, default: 0 })]
|
||||
expect(toParams(box, { r: '1' })).toEqual({ r: 1 })
|
||||
expect(toParams(box, {})).toEqual({ r: 0 })
|
||||
})
|
||||
|
||||
it('keeps a typed int', () => {
|
||||
expect(toParams(specs, { a: '2', d: '9' })).toEqual({ a: 2, d: 9 })
|
||||
})
|
||||
|
||||
@ -11,6 +11,8 @@ export interface GameOption {
|
||||
|
||||
/** Error text for one field, or null when the value is acceptable. */
|
||||
export function paramError(p: ParamSpec, raw: string, games: GameOption[]): string | null {
|
||||
// A checkbox is always '0' or '1'; there is nothing to get wrong.
|
||||
if (p.type === 'bool') return null
|
||||
const v = raw.trim()
|
||||
if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : null
|
||||
if (p.type === 'string') return null
|
||||
|
||||
@ -5,8 +5,18 @@ import { makeJob, makeSpec, makeStatus } from '../test/fixtures'
|
||||
import { apiError, renderRouted, stubApi } from '../test/harness'
|
||||
import { Dashboard } from './Dashboard'
|
||||
|
||||
const daemon = (fast_installs: unknown[] = []) => ({
|
||||
state: 'running',
|
||||
started_at: null,
|
||||
pid: 1,
|
||||
journal_tail: [],
|
||||
restart_available_at: null,
|
||||
fast_installs,
|
||||
})
|
||||
const bg3 = { app_id: 1086940, name: "Baldur's Gate 3", phase: 'downloading depot 3/8', percent: 46.9, elapsed_seconds: 312 }
|
||||
const base = (over: Record<string, unknown> = {}) => ({
|
||||
'GET /api/status': makeStatus(),
|
||||
'GET /api/daemon': daemon(),
|
||||
'GET /api/budget': makeBudget(),
|
||||
'GET /api/jobs': [],
|
||||
'GET /api/commands': [makeSpec('done'), makeSpec('install'), makeSpec('pick'), makeSpec('scan'), makeSpec('check')],
|
||||
@ -67,6 +77,27 @@ describe('Dashboard', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('fast install', () => {
|
||||
it('shows a daemon-started run with its depot percent', async () => {
|
||||
show({ 'GET /api/daemon': daemon([bg3]) })
|
||||
expect(await screen.findByText("Fast-installing Baldur's Gate 3 — 46% (downloading depot 3/8)")).toBeInTheDocument()
|
||||
expect(screen.getByRole('progressbar', { name: "Baldur's Gate 3 download" })).toHaveAttribute('aria-valuenow', '46')
|
||||
expect(screen.getByText('5m elapsed')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the phase alone before a percent is known', async () => {
|
||||
show({ 'GET /api/daemon': daemon([{ ...bg3, phase: 'shutting Steam down', percent: null, elapsed_seconds: 12 }]) })
|
||||
expect(await screen.findByText("Fast-installing Baldur's Gate 3 — shutting Steam down")).toBeInTheDocument()
|
||||
expect(screen.getByRole('progressbar', { name: "Baldur's Gate 3 download" })).toHaveClass('bar-indeterminate')
|
||||
})
|
||||
|
||||
it('stays out of the way when nothing is installing', async () => {
|
||||
show()
|
||||
expect(await screen.findByText('Hollow Knight')).toBeInTheDocument()
|
||||
expect(screen.queryByText(/Fast-installing/)).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
describe('gaming today', () => {
|
||||
it('shows time left and what is billing', async () => {
|
||||
show()
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { Link } from '@tanstack/react-router'
|
||||
import type { StatusPayload } from '../api/contract'
|
||||
import type { FastInstallRun, StatusPayload } from '../api/contract'
|
||||
import { q } from '../api/queries'
|
||||
import { CommandButton } from '../commands/CommandCard'
|
||||
import { commandLabel } from '../commands/catalog'
|
||||
@ -34,6 +34,29 @@ function StatusCard({ s }: { s: StatusPayload }) {
|
||||
)
|
||||
}
|
||||
|
||||
function fastInstallLine(r: FastInstallRun): string {
|
||||
const where = r.percent === null ? r.phase : `${Math.floor(r.percent)}% (${r.phase})`
|
||||
return `Fast-installing ${r.name} — ${where}`
|
||||
}
|
||||
|
||||
/** Daemon-started fast installs; a web job shows its own in the job view. */
|
||||
function FastInstalls() {
|
||||
const { data } = useQuery(q.daemon)
|
||||
const runs = data?.fast_installs ?? []
|
||||
if (!runs.length) return null
|
||||
return (
|
||||
<Card title="Fast install">
|
||||
{runs.map((r) => (
|
||||
<div key={r.app_id}>
|
||||
<p>{fastInstallLine(r)}</p>
|
||||
<ProgressBar fraction={r.percent === null ? null : Math.floor(r.percent) / 100} label={`${r.name} download`} />
|
||||
<p className="field-help">{fmtDuration(r.elapsed_seconds)} elapsed</p>
|
||||
</div>
|
||||
))}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BudgetGlance() {
|
||||
const { data, error } = useQuery(q.budget)
|
||||
if (error) return <Card title="Gaming today"><ErrorNotice error={error} /></Card>
|
||||
@ -94,6 +117,7 @@ export function Dashboard() {
|
||||
{error ? <Card title="Assignment"><ErrorNotice error={error} /></Card> : data ? <StatusCard s={data} /> : <Card title="Assignment"><Loading what="status" /></Card>}
|
||||
<BudgetGlance />
|
||||
</div>
|
||||
<FastInstalls />
|
||||
<RecentJobs />
|
||||
</>
|
||||
)
|
||||
|
||||
@ -7,6 +7,7 @@ import { Actions, Card, PageHead } from '../ui/Page'
|
||||
/** Everything that changes which game is assigned. */
|
||||
export function Picks() {
|
||||
const { data, error } = useQuery(q.status)
|
||||
const none = data !== undefined && data.manual_picks.length === 0 && data.kept_picks.length === 0
|
||||
return (
|
||||
<>
|
||||
<PageHead
|
||||
@ -23,7 +24,7 @@ export function Picks() {
|
||||
<Card title="Manual picks">
|
||||
{error && <ErrorNotice error={error} />}
|
||||
{!data && !error && <Loading what="status" />}
|
||||
{data && data.manual_picks.length === 0 && (
|
||||
{none && (
|
||||
<Empty>No manual picks. "Lock in a game" adds one and locks most commands for 14 days.</Empty>
|
||||
)}
|
||||
<ul className="rows">
|
||||
@ -35,8 +36,17 @@ export function Picks() {
|
||||
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
|
||||
</li>
|
||||
))}
|
||||
{/* Released, expired or kept by "Pick…": still installed and playable. */}
|
||||
{data?.kept_picks.map((p) => (
|
||||
<li key={p.app_id} className="row">
|
||||
<span className="grow">
|
||||
<strong>{p.name}</strong> <span className="muted">· kept · no lock · {p.app_id}</span>
|
||||
</span>
|
||||
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
{data && data.manual_picks.length === 0 && (
|
||||
{none && (
|
||||
<div className="actions">
|
||||
<CommandButton name="abandon-pick" label="Abandon a pick by app id…" />
|
||||
</div>
|
||||
|
||||
@ -37,6 +37,21 @@ describe('Picks', () => {
|
||||
expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('lists kept games under the active picks, each abandonable', async () => {
|
||||
stubApi({
|
||||
'GET /api/commands': SPECS,
|
||||
'GET /api/status': makeStatus({
|
||||
manual_picks: [{ app_id: 5, name: 'Five', age_days: 3 }],
|
||||
kept_picks: [{ app_id: 7, name: 'Seven' }],
|
||||
}),
|
||||
})
|
||||
renderRouted(<Picks />)
|
||||
expect(await screen.findByText('Seven')).toBeInTheDocument()
|
||||
expect(screen.getByText(/kept · no lock · 7/)).toBeInTheDocument()
|
||||
expect(screen.getAllByRole('button', { name: 'Abandon…' })).toHaveLength(2)
|
||||
expect(screen.queryByText(/No manual picks/)).toBeNull()
|
||||
})
|
||||
|
||||
it('invites locking in a game when there are none', async () => {
|
||||
stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) })
|
||||
renderRouted(<Picks />)
|
||||
|
||||
@ -57,6 +57,7 @@ export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
|
||||
total_block: { active: false, days_remaining: 0, until: null },
|
||||
manual_pick_locked: false,
|
||||
manual_picks: [],
|
||||
kept_picks: [],
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user