mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-10 19:27:04 +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
|
- uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 24.22.0
|
node-version: 24.22.1
|
||||||
cache: npm
|
cache: npm
|
||||||
cache-dependency-path: web/package-lock.json
|
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/commands` | – | `CommandSpec[]` — server-driven catalog |
|
||||||
| GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) |
|
| GET | `/api/setup` | – | `SetupStatus` (configured flags, no secrets) |
|
||||||
| POST | `/api/setup` | `SetupRequest` | `SetupStatus` (validated live against Steam) |
|
| 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) |
|
| GET | `/api/server` | – | `ServerHealth` (stale?, started_at, version) |
|
||||||
| POST | `/api/server/restart` | – | `202`; the user unit restarts the process |
|
| POST | `/api/server/restart` | – | `202`; the user unit restarts the process |
|
||||||
| GET | `/api/backups` | – | `StateBackup[]` |
|
| GET | `/api/backups` | – | `StateBackup[]` |
|
||||||
@ -205,6 +205,9 @@ hard-codes it.
|
|||||||
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
|
`gaming_unblock{phrase}`, `gaming_reset_arm{phrase}`,
|
||||||
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
|
`gaming_reset_heartbeat{pending_id}`, `gaming_reset_commit{pending_id, phrase}`,
|
||||||
`gaming_reset_cancel{pending_id}`, `restart{}`. There is no `stop`.
|
`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)
|
## Verification record (2026-10-10)
|
||||||
|
|
||||||
|
|||||||
@ -15,9 +15,12 @@ from typing import TYPE_CHECKING, Any
|
|||||||
from steam_backlog_enforcer._allowed_games import (
|
from steam_backlog_enforcer._allowed_games import (
|
||||||
active_manual_picks,
|
active_manual_picks,
|
||||||
allowed_games,
|
allowed_games,
|
||||||
drop_inactive_picks,
|
|
||||||
)
|
)
|
||||||
from steam_backlog_enforcer._assignment_progress import record_assignment
|
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
|
# Marks these two as an intentional re-export (they're imported from
|
||||||
# _allowed_games above, not defined here) -- without this, mypy's
|
# _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
|
its interactive ``YES`` confirmation. Keeping this state-only means an
|
||||||
automated caller (the MCP server) can never wipe installed games.
|
automated caller (the MCP server) can never wipe installed games.
|
||||||
|
|
||||||
Picking is an explicit choice of another game, so released, finished and
|
The games already allowed are kept, like plain ``pick``: released,
|
||||||
expired picks are dropped here (and enforcement may then remove them).
|
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:
|
Args:
|
||||||
state: The enforcer state to mutate and save.
|
state: The enforcer state to mutate and save.
|
||||||
app_id: The Steam app id to lock in.
|
app_id: The Steam app id to lock in.
|
||||||
game_name: Human-readable name for the picked game.
|
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:
|
Returns:
|
||||||
``None`` on success, or a message explaining why the pick was refused
|
``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)
|
active = active_manual_picks(state)
|
||||||
|
|
||||||
@ -145,8 +153,15 @@ def apply_manual_pick(
|
|||||||
f"(cap is {max_picks}): {names}."
|
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()
|
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(
|
state.manual_picks.append(
|
||||||
{"app_id": app_id, "game_name": game_name, "started_at": now}
|
{"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.
|
"""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
|
An inactive pick is *not* evicted: it stays in ``state.manual_picks``, and
|
||||||
so installed and playable, until the user explicitly chooses another game
|
so installed and playable, until the user explicitly replaces it
|
||||||
(:func:`drop_inactive_picks`).
|
(:func:`drop_inactive_picks`) or abandons it.
|
||||||
|
|
||||||
A pick stops being active once it earned a new achievement since it was
|
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
|
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.
|
"""Forget every pick that no longer holds the lock; return the dropped ones.
|
||||||
|
|
||||||
Released, finished and expired picks stay installed and playable for as
|
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
|
long as the user wants. Only an explicit replacement (``pick --replace``,
|
||||||
manual pick, or accepting the next assignment) calls this and so lets
|
or accepting the next assignment in ``done``/``check``/``scan``) calls
|
||||||
enforcement remove them. Does not save.
|
this and so lets enforcement remove them; plain ``pick`` and
|
||||||
|
``pick-manual`` keep them. Does not save.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: The enforcer state to mutate.
|
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
|
That is the current assignment plus every stored manual pick, including
|
||||||
released and expired ones: a game is only removed once the user has
|
released and expired ones: a game is only removed once the user has
|
||||||
explicitly chosen something else.
|
explicitly replaced or abandoned it.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
state: The loaded enforcer state.
|
state: The loaded enforcer state.
|
||||||
|
|||||||
@ -27,7 +27,7 @@ COMMAND_DESCRIPTIONS: Final[dict[str, str]] = {
|
|||||||
"uninstall": "Uninstall all non-assigned games",
|
"uninstall": "Uninstall all non-assigned games",
|
||||||
"setup": "Run first-time setup",
|
"setup": "Run first-time setup",
|
||||||
"done": "Move on after a new achievement, pick next",
|
"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",
|
"stats": "Show backlog completion-time estimates",
|
||||||
"gaming-status": "Show today's gaming time and block state",
|
"gaming-status": "Show today's gaming time and block state",
|
||||||
"gaming-reset": "Reset today's gaming counter (root + phrase)",
|
"gaming-reset": "Reset today's gaming counter (root + phrase)",
|
||||||
|
|||||||
@ -19,7 +19,7 @@ from steam_backlog_enforcer._store_window import (
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Mapping
|
from collections.abc import Mapping
|
||||||
|
|
||||||
ParamType = Literal["int", "string", "app_id"]
|
ParamType = Literal["int", "string", "app_id", "bool"]
|
||||||
ParamValue = int | str
|
ParamValue = int | str
|
||||||
|
|
||||||
|
|
||||||
@ -54,6 +54,17 @@ class ParamSpec:
|
|||||||
_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1)
|
_APP_ID = ParamSpec("app_id", "Steam app id", "app_id", min=1)
|
||||||
|
|
||||||
PARAMS: Final[dict[str, tuple[ParamSpec, ...]]] = {
|
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,),
|
"pick-manual": (_APP_ID,),
|
||||||
"abandon-pick": (_APP_ID,),
|
"abandon-pick": (_APP_ID,),
|
||||||
"add-exception": (
|
"add-exception": (
|
||||||
@ -106,6 +117,13 @@ def _coerce(spec: ParamSpec, raw: object) -> ParamValue:
|
|||||||
msg = f"{spec.name} must be a non-empty string"
|
msg = f"{spec.name} must be a non-empty string"
|
||||||
raise InvalidParamsError(msg)
|
raise InvalidParamsError(msg)
|
||||||
return raw.strip()
|
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.
|
# bool is an int subclass in Python; true/false is never a count.
|
||||||
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
if isinstance(raw, bool) or not isinstance(raw, (int, str)):
|
||||||
msg = f"{spec.name} must be a whole number"
|
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 (
|
from steam_backlog_enforcer._ctl_types import (
|
||||||
BlockResult,
|
BlockResult,
|
||||||
DaemonInfo,
|
DaemonInfo,
|
||||||
|
FastInstallInfo,
|
||||||
PendingReset,
|
PendingReset,
|
||||||
ResetResult,
|
ResetResult,
|
||||||
StoreWindow,
|
StoreWindow,
|
||||||
@ -130,15 +131,29 @@ def ping() -> None:
|
|||||||
call("ping")
|
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:
|
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")
|
data = call("status")
|
||||||
until = data.get("restart_available_at")
|
until = data.get("restart_available_at")
|
||||||
|
runs = cast("list[dict[str, object]]", data.get("fast_installs") or [])
|
||||||
return DaemonInfo(
|
return DaemonInfo(
|
||||||
state=str(data["state"]),
|
state=str(data["state"]),
|
||||||
started_at=str(data["started_at"]),
|
started_at=str(data["started_at"]),
|
||||||
pid=int(cast("int", data["pid"])),
|
pid=int(cast("int", data["pid"])),
|
||||||
restart_available_at=str(until) if until else None,
|
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 logging
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
from steam_backlog_enforcer._ctl_context import (
|
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_protocol import OP_FAILED, CtlError
|
||||||
from steam_backlog_enforcer._ctl_reset import RESET_OPS
|
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._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._playtime_block import release_block
|
||||||
from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window
|
from steam_backlog_enforcer._store_window import MAX_WINDOW_MINUTES, open_store_window
|
||||||
from steam_backlog_enforcer._total_block import (
|
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]:
|
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 {
|
return {
|
||||||
"state": "restarting" if ctx.restart_event.is_set() else "running",
|
"state": "restarting" if ctx.restart_event.is_set() else "running",
|
||||||
"started_at": ctx.started_at.isoformat(),
|
"started_at": ctx.started_at.isoformat(),
|
||||||
"pid": os.getpid(),
|
"pid": os.getpid(),
|
||||||
"restart_available_at": restart_available_at(),
|
"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
|
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)
|
@dataclass(frozen=True)
|
||||||
class DaemonInfo:
|
class DaemonInfo:
|
||||||
"""The daemon's own account of itself (``status``)."""
|
"""The daemon's own account of itself (``status``)."""
|
||||||
@ -17,6 +32,8 @@ class DaemonInfo:
|
|||||||
started_at: str
|
started_at: str
|
||||||
pid: int
|
pid: int
|
||||||
restart_available_at: str | None
|
restart_available_at: str | None
|
||||||
|
# Empty from a daemon too old to report them.
|
||||||
|
fast_installs: tuple[FastInstallInfo, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True)
|
@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:
|
Two modes, picked by whether anyone can answer a prompt:
|
||||||
|
|
||||||
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run to
|
* **Foreground** (stdin is a terminal - ``pick-manual``, ``done``...): run with
|
||||||
completion with the terminal attached, so depotdownloader's progress shows
|
the terminal attached, so depotdownloader's progress shows and a password /
|
||||||
and a password / Steam Guard prompt can be answered.
|
Steam Guard prompt can be answered.
|
||||||
* **Detached** (no terminal): spawn in its own session. The root daemon
|
* **Detached** (no terminal): spawn in its own session. The root daemon returns
|
||||||
returns at once and reaps it on every later pass through
|
at once; :func:`poll_fast_installs` reaps the run on later passes, records a
|
||||||
:func:`poll_fast_installs`, which records a failure (so the caller falls
|
failure (the caller falls back to ``steam://install``) and interrupts a run
|
||||||
back to ``steam://install``) and interrupts a run whose output has stalled -
|
whose output stalled - likely a login prompt nobody will answer, with Steam
|
||||||
most likely a login prompt nobody will ever answer, sitting there with Steam
|
shut down. A web job loops that reaper until the run ends. Either way the
|
||||||
shut down. Anything else (a web job) exits once its command returns, so it
|
log is followed for progress (:mod:`_fast_install_progress`).
|
||||||
runs that same reaper in a loop until the installer is done.
|
|
||||||
|
|
||||||
The installer closes Steam to do its work, so it is refused outright while a
|
The installer closes Steam, so it is refused while a game is running. Another
|
||||||
game is running. Another app mid-download is no reason to wait: the installer
|
app mid-download is no reason to wait: asked to leave with ``steam -shutdown``,
|
||||||
asks Steam to leave with ``steam -shutdown``, and Steam resumes the transfer
|
Steam resumes the transfer when it comes back.
|
||||||
when it comes back.
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@ -38,6 +36,7 @@ import subprocess
|
|||||||
import sys
|
import sys
|
||||||
import time
|
import time
|
||||||
|
|
||||||
|
from steam_backlog_enforcer import _fast_install_progress as _fip
|
||||||
from steam_backlog_enforcer import config as _config
|
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._desktop_env import desktop_user_cmd, resolve_desktop_user
|
||||||
from steam_backlog_enforcer._echo import _echo
|
from steam_backlog_enforcer._echo import _echo
|
||||||
@ -176,9 +175,10 @@ def poll_fast_installs() -> None:
|
|||||||
returncode,
|
returncode,
|
||||||
run.log_path,
|
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."""
|
"""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
|
# 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.
|
# 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"):
|
if os.geteuid() == 0 and shutil.which("systemd-run"):
|
||||||
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
|
cmd = ["systemd-run", "--scope", "--quiet", "--collect", *cmd]
|
||||||
with log_path.open("ab") as log:
|
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(
|
proc = subprocess.Popen(
|
||||||
cmd,
|
cmd,
|
||||||
stdin=subprocess.DEVNULL,
|
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:
|
def _wait_detached(app_id: int, label: str) -> bool:
|
||||||
"""Reap this process's run for *app_id* until it ends; True on success."""
|
"""Reap this process's run for *app_id* until it ends; True on success."""
|
||||||
_echo(
|
reporter = _fip.WaitReporter(app_id, label, started=time.monotonic())
|
||||||
f" Fast-installing {label} with steam-game-installer (Steam will close); "
|
|
||||||
"waiting for it to finish..."
|
|
||||||
)
|
|
||||||
while is_fast_installing(app_id):
|
while is_fast_installing(app_id):
|
||||||
time.sleep(_WAIT_POLL_SECONDS)
|
time.sleep(_WAIT_POLL_SECONDS)
|
||||||
poll_fast_installs()
|
poll_fast_installs()
|
||||||
|
reporter.report(time.monotonic())
|
||||||
return app_id not in _FAILED
|
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)
|
cmd = _build_cmd(installer, app_id, user)
|
||||||
if not sys.stdin.isatty():
|
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)
|
return os.geteuid() == 0 or _wait_detached(app_id, label)
|
||||||
|
|
||||||
_echo(f" Fast-installing {label} with steam-game-installer (Steam will close)...")
|
_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
|
if state.current_app_id
|
||||||
else None
|
else None
|
||||||
)
|
)
|
||||||
|
active = active_manual_picks(state)
|
||||||
return {
|
return {
|
||||||
"current_app_id": state.current_app_id,
|
"current_app_id": state.current_app_id,
|
||||||
"current_game_name": state.current_game_name or None,
|
"current_game_name": state.current_game_name or None,
|
||||||
@ -153,6 +154,9 @@ def status_payload(state: State) -> dict[str, Any]:
|
|||||||
**pick,
|
**pick,
|
||||||
"age_days": manual_pick_age_days(state, pick["app_id"]),
|
"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
|
With ``confirm=False`` (the default) this performs **no** mutation and
|
||||||
returns a preview of what confirming would do. Call again with
|
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
|
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
|
achievement is earned in it). Unlike the CLI's ``pick-manual``, this
|
||||||
mutates **state only** — it never uninstalls, installs, or hides games.
|
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,
|
"app_id": app_id,
|
||||||
"game_name": game_name,
|
"game_name": game_name,
|
||||||
"effect": (
|
"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 "
|
f"for {MANUAL_LOCK_DAYS} days or until you earn one new "
|
||||||
"achievement in it."
|
"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,
|
OWN_PICK_VALUE,
|
||||||
prompt_own_pick,
|
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._prompter import PromptOption, current_prompter
|
||||||
from steam_backlog_enforcer._scanning_candidates import (
|
from steam_backlog_enforcer._scanning_candidates import (
|
||||||
_pick_next_shortest_candidate,
|
_pick_next_shortest_candidate,
|
||||||
@ -80,13 +81,24 @@ def _assign_chosen_game(
|
|||||||
games: list[GameInfo],
|
games: list[GameInfo],
|
||||||
state: State,
|
state: State,
|
||||||
config: Config,
|
config: Config,
|
||||||
|
*,
|
||||||
|
keep: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Save assignment, announce it, and handle install/uninstall.
|
"""Save assignment, announce it, and handle install/uninstall.
|
||||||
|
|
||||||
Accepting a new game is the explicit choice that lets enforcement remove
|
Replacing (the default, and what ``done``/``check`` do) is the explicit
|
||||||
released picks, so they are dropped here.
|
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)
|
record_assignment(state, chosen.app_id, chosen.name)
|
||||||
state.save()
|
state.save()
|
||||||
hours_str = (
|
hours_str = (
|
||||||
|
|||||||
@ -12,6 +12,7 @@ contract's ``PendingAction``.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from dataclasses import asdict
|
||||||
from http import HTTPStatus
|
from http import HTTPStatus
|
||||||
from typing import TYPE_CHECKING, Final
|
from typing import TYPE_CHECKING, Final
|
||||||
|
|
||||||
@ -68,6 +69,7 @@ def daemon_view(_request: Request) -> Reply:
|
|||||||
"pid": None,
|
"pid": None,
|
||||||
"journal_tail": [],
|
"journal_tail": [],
|
||||||
"restart_available_at": None,
|
"restart_available_at": None,
|
||||||
|
"fast_installs": [],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
except CtlError as exc:
|
except CtlError as exc:
|
||||||
@ -83,6 +85,7 @@ def daemon_view(_request: Request) -> Reply:
|
|||||||
"pid": info.pid,
|
"pid": info.pid,
|
||||||
"journal_tail": journal,
|
"journal_tail": journal,
|
||||||
"restart_available_at": info.restart_available_at,
|
"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 = status_payload(State.load())
|
||||||
payload["assigned_game_installed"] = bool(payload["assigned_game_installed"])
|
payload["assigned_game_installed"] = bool(payload["assigned_game_installed"])
|
||||||
payload["manual_picks"] = [_manual_pick(p) for p in payload["manual_picks"]]
|
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)
|
return ok(payload)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@ -79,11 +79,21 @@ past its own 14-day deadline.
|
|||||||
so freeing a slot cannot hand you an unrequested extra game.
|
so freeing a slot cannot hand you an unrequested extra game.
|
||||||
- Releasing never evicts. A released, finished or expired pick stays in
|
- Releasing never evicts. A released, finished or expired pick stays in
|
||||||
`State.manual_picks`, and therefore in `allowed_app_ids`, installed and
|
`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
|
playable for as long as the user wants. Only an explicit replacement —
|
||||||
game — a new `pick-manual`, or accepting the next assignment in
|
`pick --replace`, or accepting the next assignment in
|
||||||
`done`/`check`/`pick`/`scan` — calls `drop_inactive_picks`, after which
|
`done`/`check`/`scan` — calls `drop_inactive_picks`, after which
|
||||||
enforcement may remove them. `abandon-pick` works on them too. When no
|
enforcement may remove them. `abandon-pick` works on them too. When no
|
||||||
replacement is accepted, the current game stays assigned.
|
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
|
- `mark_finished` is the single writer of `finished_app_ids`, so the sweep
|
||||||
and `done`/`check` cannot both record the same completion.
|
and `done`/`check` cannot both record the same completion.
|
||||||
|
|
||||||
|
|||||||
@ -95,6 +95,15 @@ def _add_exception(
|
|||||||
return {"app_id": params["app_id"]}
|
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(
|
def _enforce_demo(
|
||||||
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
|
params: Mapping[str, ParamValue], _progress: Progress, _prompter: Prompter
|
||||||
) -> JobResult:
|
) -> JobResult:
|
||||||
@ -145,7 +154,7 @@ HANDLERS: dict[str, JobHandler] = {
|
|||||||
"check": _cli(do_check),
|
"check": _cli(do_check),
|
||||||
"scan": _cli(do_scan),
|
"scan": _cli(do_scan),
|
||||||
"done": _cli(cmd_done),
|
"done": _cli(cmd_done),
|
||||||
"pick": _cli(cmd_pick),
|
"pick": _pick,
|
||||||
"pick-manual": _with_args(cmd_pick_manual, "app_id"),
|
"pick-manual": _with_args(cmd_pick_manual, "app_id"),
|
||||||
"abandon-pick": _with_args(cmd_abandon_pick, "app_id"),
|
"abandon-pick": _with_args(cmd_abandon_pick, "app_id"),
|
||||||
"install": _cli(cmd_install),
|
"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),
|
"unblock": lambda: cmd_unblock(config, state, args),
|
||||||
"block-gaming": lambda: cmd_block_gaming(args),
|
"block-gaming": lambda: cmd_block_gaming(args),
|
||||||
"serve": lambda: cmd_serve(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),
|
"pick-manual": lambda: cmd_pick_manual(config, state, args),
|
||||||
"abandon-pick": lambda: cmd_abandon_pick(config, state, args),
|
"abandon-pick": lambda: cmd_abandon_pick(config, state, args),
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,14 +3,13 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import sys
|
import sys
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING, Any
|
||||||
|
|
||||||
from steam_backlog_enforcer._actions import (
|
from steam_backlog_enforcer._actions import (
|
||||||
ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS,
|
ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS,
|
||||||
)
|
)
|
||||||
from steam_backlog_enforcer._actions import (
|
from steam_backlog_enforcer._actions import (
|
||||||
active_manual_picks,
|
active_manual_picks,
|
||||||
find_manual_pick,
|
|
||||||
)
|
)
|
||||||
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
|
from steam_backlog_enforcer._manual_pick_lifecycle import abandon_manual_pick
|
||||||
from steam_backlog_enforcer._prompter import confirm_phrase
|
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]}'.")
|
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
|
||||||
return None
|
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:
|
if not picks:
|
||||||
_echo("No manual pick is active — nothing to abandon.")
|
_echo("No manual pick or kept game — nothing to abandon.")
|
||||||
return None
|
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)
|
listed = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in picks)
|
||||||
_echo(
|
_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 None
|
||||||
|
|
||||||
return app_id
|
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:
|
def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
|
||||||
"""Back out of a manual pick, whatever its age.
|
"""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:
|
if app_id is None:
|
||||||
sys.exit(1)
|
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 ""
|
game_name = str(pick["game_name"]) if pick else ""
|
||||||
|
|
||||||
others = [p for p in active_manual_picks(state) if p["app_id"] != app_id]
|
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:
|
if others:
|
||||||
kept = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in 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}")
|
_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:
|
else:
|
||||||
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
|
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
|
||||||
_echo()
|
_echo()
|
||||||
|
|||||||
@ -20,6 +20,11 @@ from steam_backlog_enforcer._pick_completion import (
|
|||||||
report_completion,
|
report_completion,
|
||||||
warn_stale_assignment,
|
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._progress import tracked
|
||||||
from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter
|
from steam_backlog_enforcer._prompter import confirm_phrase, current_prompter
|
||||||
from steam_backlog_enforcer._snapshot import load_snapshot, snapshot_game_name
|
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
|
from steam_backlog_enforcer.config import Config, State
|
||||||
|
|
||||||
|
|
||||||
def cmd_pick(config: Config, state: State) -> None:
|
def cmd_pick(config: Config, state: State, args: list[str] | None = None) -> None:
|
||||||
"""Manually pick a new game from the shortest-first candidate list."""
|
"""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()
|
snapshot_data = load_snapshot()
|
||||||
if not snapshot_data:
|
if not snapshot_data:
|
||||||
_echo("No snapshot found. Run 'scan' first.")
|
_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:
|
if game.app_id in hltb_cache:
|
||||||
game.completionist_hours = hltb_cache[game.app_id]
|
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:
|
if state.current_app_id is not None:
|
||||||
hide_others_and_report(config, state)
|
hide_others_and_report(config, state)
|
||||||
@ -84,12 +109,15 @@ def _resolve_game_name(config: Config, app_id: int) -> str | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
|
def _report_pick_slots(
|
||||||
"""Show which manual picks are already locked in; exit if the cap is full.
|
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:
|
Args:
|
||||||
config: Enforcer configuration (for ``max_manual_picks``).
|
config: Enforcer configuration (for ``max_manual_picks``).
|
||||||
state: Current enforcer state.
|
state: Current enforcer state.
|
||||||
|
app_id: The game about to be picked (an allowed one adds nothing).
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
The currently-active manual picks.
|
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."
|
f"\ncan be abandoned at any time."
|
||||||
)
|
)
|
||||||
sys.exit(1)
|
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
|
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.
|
"""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.
|
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:
|
Args:
|
||||||
config: Enforcer configuration.
|
config: Enforcer configuration.
|
||||||
@ -173,7 +205,7 @@ def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
|
|||||||
|
|
||||||
retired = report_completion(config, state)
|
retired = report_completion(config, state)
|
||||||
|
|
||||||
existing = _report_pick_slots(config, state)
|
existing = _report_pick_slots(config, state, app_id)
|
||||||
|
|
||||||
_echo(
|
_echo(
|
||||||
f"\nWARNING: Picking this game will:"
|
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 {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}"
|
||||||
f"\n - Stay undoable at any time via 'abandon-pick {app_id}'"
|
f"\n - Stay undoable at any time via 'abandon-pick {app_id}'"
|
||||||
)
|
)
|
||||||
active = active_manual_picks(state)
|
kept = kept_alongside(state, app_id)
|
||||||
for old_pick in (p for p in state.manual_picks if p not in active):
|
if kept:
|
||||||
_echo(
|
_echo(f" - Keep these allowed, they stay installed: {', '.join(kept)}")
|
||||||
f" - Replace {old_pick['game_name']} (released or expired; it stays"
|
|
||||||
" playable until you confirm)"
|
|
||||||
)
|
|
||||||
_echo()
|
_echo()
|
||||||
if not confirm_phrase(
|
if not confirm_phrase(
|
||||||
"pick-manual",
|
"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(f" {pick['game_name']} (AppID={pick['app_id']}){since}")
|
||||||
_echo("\n[MANUAL PICK LOCK is active — most commands are blocked]")
|
_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:
|
def cmd_list(_config: Config, state: State) -> None:
|
||||||
"""List games from the last snapshot."""
|
"""List games from the last snapshot."""
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import logging
|
|||||||
import time
|
import time
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
from steam_backlog_enforcer._actions import allowed_app_ids
|
||||||
from steam_backlog_enforcer._assignment_progress import (
|
from steam_backlog_enforcer._assignment_progress import (
|
||||||
release_game,
|
release_game,
|
||||||
release_verdict,
|
release_verdict,
|
||||||
@ -153,6 +154,7 @@ def pick_next_game(
|
|||||||
config: Config,
|
config: Config,
|
||||||
*,
|
*,
|
||||||
on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
|
on_select: Callable[[GameInfo], bool | GameInfo] | None = None,
|
||||||
|
keep: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Present a ranked list of eligible games and let the user pick one.
|
"""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
|
bypassed: the function instead presents the shortest playable
|
||||||
candidate to ``on_select`` (typically a yes/no prompt) and, if the
|
candidate to ``on_select`` (typically a yes/no prompt) and, if the
|
||||||
callback rejects it, records a 7-day skip and re-evaluates.
|
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:
|
if on_select is not None:
|
||||||
_pick_next_game_sequential(games, state, config, on_select)
|
_pick_next_game_sequential(games, state, config, on_select)
|
||||||
return
|
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:
|
if not candidates:
|
||||||
_clear_assignment(state, _NO_CONF_MSG)
|
_clear_assignment(state, _NO_CONF_MSG)
|
||||||
return
|
return
|
||||||
@ -182,8 +192,8 @@ def pick_next_game(
|
|||||||
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
|
_clear_assignment(state, _no_pick_message(confidence_skipped, linux_skipped))
|
||||||
return
|
return
|
||||||
|
|
||||||
chosen = _prompt_user_pick(qualified, games, state)
|
chosen = _prompt_user_pick(qualified, pool, state)
|
||||||
_assign_chosen_game(chosen, games, state, config)
|
_assign_chosen_game(chosen, games, state, config, keep=keep)
|
||||||
|
|
||||||
|
|
||||||
# ──────────────────────────────────────────────────────────────
|
# ──────────────────────────────────────────────────────────────
|
||||||
|
|||||||
@ -18,7 +18,7 @@ from unittest.mock import patch
|
|||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from steam_backlog_enforcer import _fast_install
|
from steam_backlog_enforcer import _fast_install, _fast_install_progress
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
@ -39,6 +39,7 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
|||||||
fake_steamapps.mkdir(exist_ok=True)
|
fake_steamapps.mkdir(exist_ok=True)
|
||||||
_fast_install._RUNS.clear()
|
_fast_install._RUNS.clear()
|
||||||
_fast_install._FAILED.clear()
|
_fast_install._FAILED.clear()
|
||||||
|
_fast_install_progress._TRACKERS.clear()
|
||||||
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
|
patches = [patch(name, return_value=False) for name in _RUNNING_BINDINGS]
|
||||||
patches += [
|
patches += [
|
||||||
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
|
# Absolute, so `home / _INSTALLER` resolves here and never under ~.
|
||||||
@ -54,3 +55,4 @@ def _no_fast_install(tmp_path: Path) -> Iterator[None]:
|
|||||||
p.stop()
|
p.stop()
|
||||||
_fast_install._RUNS.clear()
|
_fast_install._RUNS.clear()
|
||||||
_fast_install._FAILED.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 refused is not None
|
||||||
assert "already one of your manual picks" in refused
|
assert "already one of your manual picks" in refused
|
||||||
|
|
||||||
def test_prunes_finished_entries(self) -> None:
|
def test_keeps_finished_entries(self) -> None:
|
||||||
"""Test prunes finished entries."""
|
"""A finished pick stays allowed: a new pick no longer drops it."""
|
||||||
state = State(manual_picks=[_pick(440, "TF2")], finished_app_ids=[440])
|
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 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:
|
def test_slots_left(self) -> None:
|
||||||
"""Test slots left."""
|
"""Test slots left."""
|
||||||
|
|||||||
@ -22,6 +22,7 @@ from steam_backlog_enforcer._ctl_protocol import (
|
|||||||
from steam_backlog_enforcer._ctl_types import (
|
from steam_backlog_enforcer._ctl_types import (
|
||||||
BlockResult,
|
BlockResult,
|
||||||
DaemonInfo,
|
DaemonInfo,
|
||||||
|
FastInstallInfo,
|
||||||
PendingReset,
|
PendingReset,
|
||||||
ResetResult,
|
ResetResult,
|
||||||
StoreWindow,
|
StoreWindow,
|
||||||
@ -125,7 +126,24 @@ class TestTypedWrappers:
|
|||||||
def test_status_no_restart_time(self) -> None:
|
def test_status_no_restart_time(self) -> None:
|
||||||
data = {"state": "running", "started_at": "t", "pid": 4}
|
data = {"state": "running", "started_at": "t", "pid": 4}
|
||||||
with self._call(data):
|
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:
|
def test_journal_tail(self) -> None:
|
||||||
with self._call({"lines": ["a", 2]}) as call:
|
with self._call({"lines": ["a", 2]}) as call:
|
||||||
|
|||||||
@ -13,7 +13,7 @@ from unittest.mock import MagicMock, patch
|
|||||||
|
|
||||||
import pytest
|
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._ctl_protocol import CtlError
|
||||||
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
from steam_backlog_enforcer.tests._ctl_fixtures import make_ctx
|
||||||
|
|
||||||
@ -35,6 +35,24 @@ class TestSimpleOps:
|
|||||||
assert data["restart_available_at"] is None
|
assert data["restart_available_at"] is None
|
||||||
assert isinstance(data["pid"], int)
|
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:
|
def test_status_restarting(self) -> None:
|
||||||
ctx = make_ctx()
|
ctx = make_ctx()
|
||||||
ctx.restart_event.set()
|
ctx.restart_event.set()
|
||||||
|
|||||||
@ -12,7 +12,8 @@ from __future__ import annotations
|
|||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
from unittest.mock import MagicMock, patch
|
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:
|
if TYPE_CHECKING:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
@ -26,15 +27,21 @@ def _ready(tmp_path: Path) -> Path:
|
|||||||
return installer
|
return installer
|
||||||
|
|
||||||
|
|
||||||
def _fake_spawn(*returncodes: int | None) -> MagicMock:
|
def _fake_spawn(*returncodes: int | None, log_text: bytes | None = None) -> MagicMock:
|
||||||
"""A ``_spawn_detached`` that registers a run answering *returncodes*."""
|
"""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 = MagicMock(pid=4321)
|
||||||
proc.poll.side_effect = list(returncodes)
|
proc.poll.side_effect = list(returncodes)
|
||||||
_fast_install._RUNS.append(
|
log = config.CONFIG_DIR / "x.log"
|
||||||
_fast_install._DetachedRun(app_id, proc, 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)
|
return MagicMock(side_effect=spawn)
|
||||||
|
|
||||||
@ -62,6 +69,18 @@ class TestJobWaitsForInstaller:
|
|||||||
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
|
assert self._install(tmp_path, _fake_spawn(None, 3), 1000) is False
|
||||||
assert {7} == _fast_install._FAILED
|
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:
|
def test_daemon_returns_at_once(self, tmp_path: Path) -> None:
|
||||||
assert self._install(tmp_path, _fake_spawn(), 0) is True
|
assert self._install(tmp_path, _fake_spawn(), 0) is True
|
||||||
assert _fast_install.is_fast_installing(7)
|
assert _fast_install.is_fast_installing(7)
|
||||||
@ -82,3 +101,17 @@ class TestLogIsPerUser:
|
|||||||
_fast_install._spawn_detached(["run.sh"], 9)
|
_fast_install._spawn_detached(["run.sh"], 9)
|
||||||
popen.assert_called_once()
|
popen.assert_called_once()
|
||||||
assert _fast_install._RUNS[0].log_path != daemon_log
|
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:
|
class TestPickManualRetirementNotices:
|
||||||
"""A pick retired by the sweep is announced before the typed-phrase prompt."""
|
"""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 = locked_state(app_id=100)
|
||||||
state.current_app_id = 200
|
state.current_app_id = 200
|
||||||
state.current_game_name = "Finished"
|
state.current_game_name = "Finished"
|
||||||
@ -146,7 +146,8 @@ class TestPickManualRetirementNotices:
|
|||||||
):
|
):
|
||||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
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
|
assert "Aborted." in output
|
||||||
# Abort leaves the finished game as the assignment, so say so.
|
# Abort leaves the finished game as the assignment, so say so.
|
||||||
note = " ".join(str(c) for c in mock_note.call_args_list)
|
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")
|
config = Config(steam_api_key="k", steam_id="i")
|
||||||
state = State()
|
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))
|
captured_games.append(list(games))
|
||||||
|
|
||||||
with (
|
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 dropped] == [2]
|
||||||
assert [p["app_id"] for p in state.manual_picks] == [3]
|
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)])
|
state = State(current_app_id=2, manual_picks=[pick(2, released_at=STARTED_AT)])
|
||||||
with patch.object(State, "save"):
|
with patch.object(State, "save"):
|
||||||
assert apply_manual_pick(state, 4, "G4", max_picks=1) is None
|
assert apply_manual_pick(state, 4, "G4", max_picks=1) is None
|
||||||
assert allowed_app_ids(state) == {4}
|
assert allowed_app_ids(state) == {2, 4}
|
||||||
assert state.current_assigned_at == state.manual_picks[0]["started_at"]
|
assert state.current_assigned_at == state.manual_picks[-1]["started_at"]
|
||||||
|
|
||||||
def test_released_pick_can_be_abandoned(self) -> None:
|
def test_released_pick_can_be_abandoned(self) -> None:
|
||||||
state = State(
|
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 import _web_daemon
|
||||||
from steam_backlog_enforcer._ctl_protocol import CtlError, DaemonUnreachableError
|
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._web_errors import ApiError
|
||||||
from steam_backlog_enforcer.jobs._view import list_jobs
|
from steam_backlog_enforcer.jobs._view import list_jobs
|
||||||
from steam_backlog_enforcer.tests._web_request import make_request, payload_of
|
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):
|
with patch(f"{_CLIENT}.status", side_effect=DaemonUnreachableError):
|
||||||
reply = _web_daemon.daemon_view(make_request())
|
reply = _web_daemon.daemon_view(make_request())
|
||||||
assert payload_of(reply)["state"] == "unreachable"
|
assert payload_of(reply)["state"] == "unreachable"
|
||||||
|
assert payload_of(reply)["fast_installs"] == []
|
||||||
|
|
||||||
def test_status_refusal_is_an_error(self) -> None:
|
def test_status_refusal_is_an_error(self) -> None:
|
||||||
with (
|
with (
|
||||||
@ -67,8 +73,27 @@ class TestDaemonView:
|
|||||||
"pid": 77,
|
"pid": 77,
|
||||||
"journal_tail": ["a", "b"],
|
"journal_tail": ["a", "b"],
|
||||||
"restart_available_at": None,
|
"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(
|
@pytest.mark.parametrize(
|
||||||
"failure", [DaemonUnreachableError(), CtlError("busy", "m")]
|
"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('check', 'Check assigned game for a new achievement', 'backlog', 'job', { mutating: false }),
|
||||||
spec('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }),
|
spec('scan', 'Scan library & assign a game', 'backlog', 'job', { cancellable: true }),
|
||||||
spec('done', 'Move on after a new achievement, pick next', 'backlog', 'job'),
|
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', {
|
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}'),
|
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)
|
if (p.type === 'string') return String(raw)
|
||||||
const n = Number(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 (!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.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}.`)
|
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)))
|
ctx.progress(2, 2, 'Assigning', gameName(Number(chosen)))
|
||||||
await ctx.sleep(700)
|
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)
|
world.currentAppId = Number(chosen)
|
||||||
return { summary: `Assigned ${gameName(Number(chosen))}.` }
|
return { summary: `Assigned ${gameName(Number(chosen))}.` }
|
||||||
}
|
}
|
||||||
@ -177,6 +183,7 @@ export const SCRIPTS: Partial<Record<CommandName, Script>> = {
|
|||||||
'abandon-pick': async (ctx) => {
|
'abandon-pick': async (ctx) => {
|
||||||
await ctx.sleep(800)
|
await ctx.sleep(800)
|
||||||
world.manualPicks = world.manualPicks.filter((p) => p.app_id !== Number(ctx.params.app_id))
|
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))}.` }
|
return { summary: `Abandoned ${gameName(Number(ctx.params.app_id))}.` }
|
||||||
},
|
},
|
||||||
'add-exception': async (ctx) => {
|
'add-exception': async (ctx) => {
|
||||||
|
|||||||
@ -35,6 +35,8 @@ export interface World {
|
|||||||
storeBlockedUntil: number | null
|
storeBlockedUntil: number | null
|
||||||
totalBlockUntil: number | null
|
totalBlockUntil: number | null
|
||||||
manualPicks: { app_id: number; picked_at: number }[]
|
manualPicks: { app_id: number; picked_at: number }[]
|
||||||
|
/** Games `pick` kept: allowed, no lock. */
|
||||||
|
keptPicks: number[]
|
||||||
installed: InstalledGame[]
|
installed: InstalledGame[]
|
||||||
backups: StateBackup[]
|
backups: StateBackup[]
|
||||||
journal: string[]
|
journal: string[]
|
||||||
@ -57,6 +59,7 @@ export const world: World = {
|
|||||||
storeBlockedUntil: null,
|
storeBlockedUntil: null,
|
||||||
totalBlockUntil: null,
|
totalBlockUntil: null,
|
||||||
manualPicks: [],
|
manualPicks: [],
|
||||||
|
keptPicks: [],
|
||||||
installed: makeInstalled(1145360),
|
installed: makeInstalled(1145360),
|
||||||
backups: [
|
backups: [
|
||||||
{ id: '20261009-221504', created_at: iso(now - DAY), reason: 'before reset', size_bytes: 48_211 },
|
{ 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),
|
name: gameName(p.app_id),
|
||||||
age_days: Math.floor((t - p.picked_at) / DAY),
|
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) : [],
|
journal_tail: reachable ? world.journal.slice(-60) : [],
|
||||||
restart_available_at:
|
restart_available_at:
|
||||||
world.restartAvailableAt !== null && world.restartAvailableAt > t ? iso(world.restartAvailableAt) : null,
|
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",
|
"name": "web",
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.104.4",
|
"@tanstack/react-query": "5.104.5",
|
||||||
"@tanstack/react-router": "1.170.45",
|
"@tanstack/react-router": "1.170.45",
|
||||||
"react": "19.3.1",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.1"
|
"react-dom": "19.3.1"
|
||||||
@ -33,8 +33,8 @@
|
|||||||
"globals": "17.13.0",
|
"globals": "17.13.0",
|
||||||
"jsdom": "30.1.3",
|
"jsdom": "30.1.3",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.71.2",
|
"typescript-eslint": "8.72.0",
|
||||||
"vite": "8.3.4",
|
"vite": "8.3.5",
|
||||||
"vitest": "5.0.5"
|
"vitest": "5.0.5"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -1186,9 +1186,9 @@
|
|||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
"node_modules/@oxc-project/types": {
|
"node_modules/@oxc-project/types": {
|
||||||
"version": "0.153.0",
|
"version": "0.155.0",
|
||||||
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
|
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.155.0.tgz",
|
||||||
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
|
"integrity": "sha512-SeE8JPwW1xs1tHeNycjUtUBdhorRa8o4jlvjwk49R7/Z291i+4DQa7JJIgOm/TaYQZx+QrPQbVJ/9c8dMIZN8Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
@ -1196,9 +1196,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm-eabi": {
|
"node_modules/@rolldown/binding-android-arm-eabi": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.15.tgz",
|
||||||
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
|
"integrity": "sha512-aLcgK9gIJ3YRvrrtfSBbRa27F5WrpiTaPulnFpsHPxpUHgFlSgnU4854MHwoRGEBhfRJDWCS2WQ7lIjskE9Olw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1213,9 +1213,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-android-arm64": {
|
"node_modules/@rolldown/binding-android-arm64": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.15.tgz",
|
||||||
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
|
"integrity": "sha512-xD7DdZCrbThKuxNg7ooQmvp07ys4J/pnSEMTG3/OdOWl6daC67m/Q4fL78sRHMxTH/N4fGUbYhcPM0MF5WFHkg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1230,9 +1230,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-arm64": {
|
"node_modules/@rolldown/binding-darwin-arm64": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.15.tgz",
|
||||||
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
|
"integrity": "sha512-m2WuQ6r0B4OTYxyPpNLtXaMRbRDUbG8YQV2Q3KTdSSK8Th6KHCnC8jVCJt8bvYeSB5WivkkNbc85mKadaSdOxA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1247,9 +1247,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-darwin-x64": {
|
"node_modules/@rolldown/binding-darwin-x64": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.15.tgz",
|
||||||
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
|
"integrity": "sha512-5RtnsHM36nodM9WbMy+zip0LGS/G+y5wro5RHrpOlr4ac24+1zSi9kiV2MP1myZeZ/ifEHfulQcWjnAXzc2C2A==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1264,9 +1264,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-freebsd-x64": {
|
"node_modules/@rolldown/binding-freebsd-x64": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.15.tgz",
|
||||||
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
|
"integrity": "sha512-g3l09pyFqhu+135JKkbkwiKGQmwU5hUMUPJ4VdBADvuqR8d33L6H578rh8m7rtxbdp+VmX/+nzI7nvhSZlzpUQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1281,9 +1281,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.15.tgz",
|
||||||
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
|
"integrity": "sha512-nIe3VugI97nGQtspPxDqA4sXMUpeiocKIP/CqpY/u/vgbT+alX+cVxu1C/oVBPKg4feNTmYovE91v4DgXvXNwQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm"
|
"arm"
|
||||||
],
|
],
|
||||||
@ -1298,9 +1298,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
"node_modules/@rolldown/binding-linux-arm64-gnu": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.15.tgz",
|
||||||
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
|
"integrity": "sha512-iTBjXx+B5QSvgI89lJfmqh4wTh8NabR1YZIIN3hNx4zSG0EVfVqkOvLulNpG4251pbd6/IAmZ/LefVk1Y4rPhw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1318,9 +1318,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
"node_modules/@rolldown/binding-linux-arm64-musl": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.15.tgz",
|
||||||
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
|
"integrity": "sha512-uN26XOnVR7rqQupG3V9oAG2EGGatWjcJHOeDOHUFCh4TBWmP2tHTFoVN13aYbwfm0aHqJAEmcwhAu6sooIjfqQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1338,9 +1338,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.15.tgz",
|
||||||
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
|
"integrity": "sha512-9nWkcdd+H+XhaA3fQIwpHnKIrfx/PQ6rO0pivr78Og07tXlUlctBBJoEYLPfhTmCR/QxEVdn6uPjHV5SymPhdw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"ppc64"
|
"ppc64"
|
||||||
],
|
],
|
||||||
@ -1358,9 +1358,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
"node_modules/@rolldown/binding-linux-s390x-gnu": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.15.tgz",
|
||||||
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
|
"integrity": "sha512-Ggh0ciFjLwIusLSLz7LZYEaGdxOAUJXI+Qu5JCbhPJJHEt8vP+IjRATTJheiJsdzzTYgUiHUiw3X1Ea3ZpMBdA==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"s390x"
|
"s390x"
|
||||||
],
|
],
|
||||||
@ -1378,9 +1378,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
"node_modules/@rolldown/binding-linux-x64-gnu": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.15.tgz",
|
||||||
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
|
"integrity": "sha512-FHg60rS6DBePAYZ9X9F67x+JObFdmgn6tupA1C/a3B+CufFmTD4mX+e8M75tCiAO3foR/6xwVBI0KQiI7sn/rw==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1398,9 +1398,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-linux-x64-musl": {
|
"node_modules/@rolldown/binding-linux-x64-musl": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.15.tgz",
|
||||||
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
|
"integrity": "sha512-/cvV/7v5VGz/moiz0XKRj2dE8DzsKyhIu1KpOvJGPjRywg3yscON889UGvAe8zfiJLHup7VLjBduoGWi3HusWQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1418,9 +1418,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-openharmony-arm64": {
|
"node_modules/@rolldown/binding-openharmony-arm64": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.15.tgz",
|
||||||
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
|
"integrity": "sha512-MyzC5Hrv/sm+Z7TSGKP/j8001RO2xQm1zOpCNVL4j0cY/VhBZviludA2YFQ7i4lH6ZAXxQm0lcMdBzM+99mE7w==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1435,9 +1435,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
"node_modules/@rolldown/binding-win32-arm64-msvc": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.15.tgz",
|
||||||
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
|
"integrity": "sha512-+PgYiS+AH5omy6L45ZLxybasauJaj3M8buoT83D+nzPmCayXlxUV9hydhciCKVyIg0B/v0BtXPDE1uGqNwCPpg==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"arm64"
|
"arm64"
|
||||||
],
|
],
|
||||||
@ -1452,9 +1452,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
"node_modules/@rolldown/binding-win32-x64-msvc": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.15.tgz",
|
||||||
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
|
"integrity": "sha512-HFZd2QNQYn1JbIi+Hpd7H+r/lkWxcfIu28nirZ6eCn2LafuvmExMlb/P96Sfx5tf7pPArw39IdhmCF8vmV+NhQ==",
|
||||||
"cpu": [
|
"cpu": [
|
||||||
"x64"
|
"x64"
|
||||||
],
|
],
|
||||||
@ -1520,9 +1520,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/query-core": {
|
"node_modules/@tanstack/query-core": {
|
||||||
"version": "5.104.4",
|
"version": "5.104.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.104.5.tgz",
|
||||||
"integrity": "sha512-nU4Yrb8Sjn0DeVb7PKG9uiepNavys3yoxy1FX0hViY4/P6Ng/mBje2SMl7i9812rtyHKtHyqiaC/td6GkI8StA==",
|
"integrity": "sha512-Q94gI8Opd+CbdVNOdj8ZCyV8WqLYitrDq1t96Xc8WZT6lY0r5iRnJLXYDxMex7EEf2v+mXvn4FcjaBKSxXZQ9A==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -1530,12 +1530,12 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@tanstack/react-query": {
|
"node_modules/@tanstack/react-query": {
|
||||||
"version": "5.104.4",
|
"version": "5.104.5",
|
||||||
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.4.tgz",
|
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.104.5.tgz",
|
||||||
"integrity": "sha512-YD5QkvUNy8Ey3LbOy2vwL97JjoYycwDv++cWAibTe+9BLYvl3c09XpYp5dZ2VcxcguwoDhGF+utYJYb79RB36w==",
|
"integrity": "sha512-RORgFe052U+93weaNuqg7qHq7vNVNzXN/VrXqD4bbAuQCWUqOl2lV5Ui9flWk5CVvMyL/Bh27QWhzwH0aaOx9g==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/query-core": "5.104.4"
|
"@tanstack/query-core": "5.104.5"
|
||||||
},
|
},
|
||||||
"funding": {
|
"funding": {
|
||||||
"type": "github",
|
"type": "github",
|
||||||
@ -1810,17 +1810,17 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.72.0.tgz",
|
||||||
"integrity": "sha512-K72/pvT7qpm8q3k8G5u93NCSN1RcLxX5h7Gw0bQpbMXviBHeY6Nx1WEwmk9wP+eKTSQ8ecxvcgt/ZX/rB938xQ==",
|
"integrity": "sha512-aysKgysPX9XOv/dk95UkvUDe++Q3n3dJPG9CEWT3RUccxfkPJ+Qn6nhKPDhZXU6Ofo9kgwGWwKyQCnZIXTgngg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/regexpp": "^4.12.2",
|
"@eslint-community/regexpp": "^4.12.2",
|
||||||
"@typescript-eslint/scope-manager": "8.71.2",
|
"@typescript-eslint/scope-manager": "8.72.0",
|
||||||
"@typescript-eslint/type-utils": "8.71.2",
|
"@typescript-eslint/type-utils": "8.72.0",
|
||||||
"@typescript-eslint/utils": "8.71.2",
|
"@typescript-eslint/utils": "8.72.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||||
"ignore": "^7.0.5",
|
"ignore": "^7.0.5",
|
||||||
"natural-compare": "^1.4.0",
|
"natural-compare": "^1.4.0",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
@ -1833,15 +1833,15 @@
|
|||||||
"url": "https://opencollective.com/typescript-eslint"
|
"url": "https://opencollective.com/typescript-eslint"
|
||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@typescript-eslint/parser": "^8.71.2",
|
"@typescript-eslint/parser": "^8.72.0",
|
||||||
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
"eslint": "^8.57.0 || ^9.0.0 || ^10.0.0",
|
||||||
"typescript": ">=4.8.4 <6.1.0"
|
"typescript": ">=4.8.4 <6.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
"node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": {
|
||||||
"version": "7.0.12",
|
"version": "7.0.13",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.12.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.13.tgz",
|
||||||
"integrity": "sha512-/8UvqAPU9DGTI9k4mxtf49U37Isfwr8Uts96+SBHIkFxPJnHS0Ew4f00sM4Scd8V8EjM0jUNtVDdv1kPdt35lg==",
|
"integrity": "sha512-2E8Q6rh+1RRSq/YdaGsNfXsEZy9FVqv6BSSrkmZJP7BnaoPnRfKL+YaofPvP+5rzdk144qSm6i4+hvPgUcbV4Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1849,16 +1849,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/parser": {
|
"node_modules/@typescript-eslint/parser": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.72.0.tgz",
|
||||||
"integrity": "sha512-aTtmZjp/x3gsGHkDs04/1I8kNogBJkpQaeGaN0PfiG43NzDEmW2n+OP5im9wfsvyX0yP1UZWBrAGtDBfH9jEVw==",
|
"integrity": "sha512-0O+x3ynciD6zuXb1CKSu165haQV6ey53DKhHHUnujP7U+XlltJG+/keT0Xhd1HXCze+IHf7Tvt39ovyepX/LNw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/scope-manager": "8.71.2",
|
"@typescript-eslint/scope-manager": "8.72.0",
|
||||||
"@typescript-eslint/types": "8.71.2",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1874,14 +1874,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/project-service": {
|
"node_modules/@typescript-eslint/project-service": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.72.0.tgz",
|
||||||
"integrity": "sha512-Pd68GsLVh5ZXlcEccFPdhSOvtHZ/2WU0XsA8zmXOS3F158lVlcTLhfQ6qvtL1RzbMie0Kp8xtbVwAg4nejKPHw==",
|
"integrity": "sha512-bzHngBbmIgzj6TsTjVUCntGx6FFLgE7mPjpO0PzHycOaZHXqS43XnrkFIHRS16LLr/xO6FHSXG3GJFo/F4bSBQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/tsconfig-utils": "^8.71.2",
|
"@typescript-eslint/tsconfig-utils": "^8.72.0",
|
||||||
"@typescript-eslint/types": "^8.71.2",
|
"@typescript-eslint/types": "^8.72.0",
|
||||||
"debug": "^4.4.3"
|
"debug": "^4.4.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1896,14 +1896,14 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/scope-manager": {
|
"node_modules/@typescript-eslint/scope-manager": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.72.0.tgz",
|
||||||
"integrity": "sha512-YF4wgg5iUX5Obywoac27B56FhozMNFhdJSbh11H5GBAwmBtfyRhmgTQqW9tRNNmHDvsZ2CX6UGzoz+YfYn2Leg==",
|
"integrity": "sha512-JewPyJOYcXBSAS7PIgufscHOwZqhTGyBRiQJhhuDln66tMchxvvt7nJ90JKKIoOelmdHA3kAQbm9cEUHc4JSEA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.71.2",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.71.2"
|
"@typescript-eslint/visitor-keys": "8.72.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@ -1914,9 +1914,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/tsconfig-utils": {
|
"node_modules/@typescript-eslint/tsconfig-utils": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.72.0.tgz",
|
||||||
"integrity": "sha512-jz49TAWokm/7yWD9/o837nYax9DyALxIPOImdImvVzo6canBCZYzin8zJyqNR5k1j9K1DE0bVvr0XYB+99uAdg==",
|
"integrity": "sha512-0vbxCf7xLVPSjcOdm6eQbgViH8EDJ8xnl1laLT9yoqSz2YmcSfSg5Vdl0JUvZf6rcuJ0ZivJIuMD4edTg8obaA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1931,15 +1931,15 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/type-utils": {
|
"node_modules/@typescript-eslint/type-utils": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.72.0.tgz",
|
||||||
"integrity": "sha512-cC8UaalCViJkb3rH/xLIvidEwoFRtMZMUqDjjJMGEKaTFwYRHYBcoczlEGHuvkHEO8svRDrgZ0yQgOArFmwang==",
|
"integrity": "sha512-gEXxmmbSTSOVTC37IYYvremOM31v+UN2W772bJxmVYPhAOPSUxtacyb7wkm1EhkYKU6KzFJsdU44DCV4to0LWw==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.71.2",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||||
"@typescript-eslint/utils": "8.71.2",
|
"@typescript-eslint/utils": "8.72.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"ts-api-utils": "^2.5.0"
|
"ts-api-utils": "^2.5.0"
|
||||||
},
|
},
|
||||||
@ -1956,9 +1956,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/types": {
|
"node_modules/@typescript-eslint/types": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.72.0.tgz",
|
||||||
"integrity": "sha512-y5Ukl3oVp7e/VGxqIoEXoeb/Roj71PAJ4WG/N/RGj+eeDvKRLM15kGr9MijLbN0i03S52zxu31Z98E5gWChJKw==",
|
"integrity": "sha512-Adfoy59kxNEjT5E5fnyrp7GpHYuWs8ONbdohu4ez6IU46qDq8LltG90tLmkucZB2uhYL8mqBjtXqGd8yhfjXqQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -1970,16 +1970,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/typescript-estree": {
|
"node_modules/@typescript-eslint/typescript-estree": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.72.0.tgz",
|
||||||
"integrity": "sha512-DgNOEsl4pdYzwSL+N/Cl33tX1Hlw3FvId73LnKXfq7jnrF2viOEn7X+P7RBkkSWCVm92gnWd28GIC5vJuLREeA==",
|
"integrity": "sha512-gwLfFgWcw15tN2KXDCJvxEOQjPn+P3j/n3eSqkZ5sX4VpW9Cosodr+tWwBZRvJT3TcKx6HnPRGZMCNAiP1Xhug==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/project-service": "8.71.2",
|
"@typescript-eslint/project-service": "8.72.0",
|
||||||
"@typescript-eslint/tsconfig-utils": "8.71.2",
|
"@typescript-eslint/tsconfig-utils": "8.72.0",
|
||||||
"@typescript-eslint/types": "8.71.2",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/visitor-keys": "8.71.2",
|
"@typescript-eslint/visitor-keys": "8.72.0",
|
||||||
"debug": "^4.4.3",
|
"debug": "^4.4.3",
|
||||||
"minimatch": "^10.2.2",
|
"minimatch": "^10.2.2",
|
||||||
"semver": "^7.7.3",
|
"semver": "^7.7.3",
|
||||||
@ -2011,16 +2011,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/utils": {
|
"node_modules/@typescript-eslint/utils": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.72.0.tgz",
|
||||||
"integrity": "sha512-RyKa1G1M9rKHDhrk+HJtiF1MvLD5Ux2JW3TSkzw+02pRALdpJb+QzG1Y5em+LvCgzGwgg8H7mBRFKljNz7ImPw==",
|
"integrity": "sha512-0up3oArJZBYnzOaEXKo8DmI43QvMZyPoFEt9LWzRJcyl/RcJjfKKPebknN1VbTgXBf3M/KggUh5uUMdURLnseg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.9.1",
|
"@eslint-community/eslint-utils": "^4.9.1",
|
||||||
"@typescript-eslint/scope-manager": "8.71.2",
|
"@typescript-eslint/scope-manager": "8.72.0",
|
||||||
"@typescript-eslint/types": "8.71.2",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.71.2"
|
"@typescript-eslint/typescript-estree": "8.72.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@ -2035,13 +2035,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/@typescript-eslint/visitor-keys": {
|
"node_modules/@typescript-eslint/visitor-keys": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.72.0.tgz",
|
||||||
"integrity": "sha512-zozlN0UA32bSG2zkKAgQyRYSbEISVoyU6vvkKYo2aSny74D8Jh4e+POkJ6Lk0dlezZi5x7TtD+88Xb06IB8nhg==",
|
"integrity": "sha512-E2mroviasaWecDzTGiGMtuEguuLVOeRggnrIn93CJuxYJzQBHbyymlT08aVQydbBal5538o+JkBKXh2sRuCyIA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/types": "8.71.2",
|
"@typescript-eslint/types": "8.72.0",
|
||||||
"eslint-visitor-keys": "^5.0.0"
|
"eslint-visitor-keys": "^5.0.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
@ -3892,9 +3892,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/postcss": {
|
"node_modules/postcss": {
|
||||||
"version": "8.5.29",
|
"version": "8.5.31",
|
||||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
|
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.31.tgz",
|
||||||
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
|
"integrity": "sha512-YUbWYCgErwnMAVkjUnYa+KlMBzGpHwiIt/DE7f0G33AuX2LH9UcTk6KgWxd3zRU+pN3u/C+Xl3EnMLM4Hea7Sg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@ -3912,7 +3912,7 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.19",
|
"nanoid": "^3.3.20",
|
||||||
"picocolors": "^1.1.1",
|
"picocolors": "^1.1.1",
|
||||||
"source-map-js": "^1.2.2"
|
"source-map-js": "^1.2.2"
|
||||||
},
|
},
|
||||||
@ -4028,13 +4028,13 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/rolldown": {
|
"node_modules/rolldown": {
|
||||||
"version": "1.2.13",
|
"version": "1.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
|
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.15.tgz",
|
||||||
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
|
"integrity": "sha512-6odND+fel5zVw3I4Yc2e69gvguDnX0CAAGiNDZSq4ik5WUcaLrkD2GLuog0XcODbor31hZ5ayEMgVDno+KPV5g==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@oxc-project/types": "=0.153.0",
|
"@oxc-project/types": "=0.155.0",
|
||||||
"@rolldown/pluginutils": "^1.0.0"
|
"@rolldown/pluginutils": "^1.0.0"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@ -4044,21 +4044,21 @@
|
|||||||
"node": "^20.19.0 || >=22.12.0"
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
},
|
},
|
||||||
"optionalDependencies": {
|
"optionalDependencies": {
|
||||||
"@rolldown/binding-android-arm-eabi": "1.2.13",
|
"@rolldown/binding-android-arm-eabi": "1.2.15",
|
||||||
"@rolldown/binding-android-arm64": "1.2.13",
|
"@rolldown/binding-android-arm64": "1.2.15",
|
||||||
"@rolldown/binding-darwin-arm64": "1.2.13",
|
"@rolldown/binding-darwin-arm64": "1.2.15",
|
||||||
"@rolldown/binding-darwin-x64": "1.2.13",
|
"@rolldown/binding-darwin-x64": "1.2.15",
|
||||||
"@rolldown/binding-freebsd-x64": "1.2.13",
|
"@rolldown/binding-freebsd-x64": "1.2.15",
|
||||||
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
|
"@rolldown/binding-linux-arm-gnueabihf": "1.2.15",
|
||||||
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
|
"@rolldown/binding-linux-arm64-gnu": "1.2.15",
|
||||||
"@rolldown/binding-linux-arm64-musl": "1.2.13",
|
"@rolldown/binding-linux-arm64-musl": "1.2.15",
|
||||||
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
|
"@rolldown/binding-linux-ppc64-gnu": "1.2.15",
|
||||||
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
|
"@rolldown/binding-linux-s390x-gnu": "1.2.15",
|
||||||
"@rolldown/binding-linux-x64-gnu": "1.2.13",
|
"@rolldown/binding-linux-x64-gnu": "1.2.15",
|
||||||
"@rolldown/binding-linux-x64-musl": "1.2.13",
|
"@rolldown/binding-linux-x64-musl": "1.2.15",
|
||||||
"@rolldown/binding-openharmony-arm64": "1.2.13",
|
"@rolldown/binding-openharmony-arm64": "1.2.15",
|
||||||
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
|
"@rolldown/binding-win32-arm64-msvc": "1.2.15",
|
||||||
"@rolldown/binding-win32-x64-msvc": "1.2.13"
|
"@rolldown/binding-win32-x64-msvc": "1.2.15"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/saxes": {
|
"node_modules/saxes": {
|
||||||
@ -4298,16 +4298,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/typescript-eslint": {
|
"node_modules/typescript-eslint": {
|
||||||
"version": "8.71.2",
|
"version": "8.72.0",
|
||||||
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.71.2.tgz",
|
"resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.72.0.tgz",
|
||||||
"integrity": "sha512-Rz4effbMy2g9wnvOJZG6SbVavX313Lap1vNEbk1RUochzickvzP8P4HB+bkNg5e50eVz1hQGjh9K1omI1xB4Bw==",
|
"integrity": "sha512-r26Fkc8As+mZvqp7SLsMmNFyYUup/6CjK7/eHBHmhOw6GH6n6YFxHVFrG0jN1c5CMpDzelLPxvgeCOzx7BjN+A==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@typescript-eslint/eslint-plugin": "8.71.2",
|
"@typescript-eslint/eslint-plugin": "8.72.0",
|
||||||
"@typescript-eslint/parser": "8.71.2",
|
"@typescript-eslint/parser": "8.72.0",
|
||||||
"@typescript-eslint/typescript-estree": "8.71.2",
|
"@typescript-eslint/typescript-estree": "8.72.0",
|
||||||
"@typescript-eslint/utils": "8.71.2"
|
"@typescript-eslint/utils": "8.72.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||||
@ -4402,16 +4402,16 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "8.3.4",
|
"version": "8.3.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.5.tgz",
|
||||||
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
|
"integrity": "sha512-z1YpjieJ9g0ebTmQpLBv/oq9E3F9EIlOe+7LahW8SrE3LTiG3udBRUXQOlYXP9b+IC2q2phHeVxj9xGlw34OEQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"lightningcss": "^1.33.0",
|
"lightningcss": "^1.33.0",
|
||||||
"picomatch": "^4.0.7",
|
"picomatch": "^4.0.7",
|
||||||
"postcss": "^8.5.29",
|
"postcss": "^8.5.30",
|
||||||
"rolldown": "~1.2.12",
|
"rolldown": "~1.2.14",
|
||||||
"tinyglobby": "^0.2.17"
|
"tinyglobby": "^0.2.17"
|
||||||
},
|
},
|
||||||
"bin": {
|
"bin": {
|
||||||
@ -4428,7 +4428,7 @@
|
|||||||
},
|
},
|
||||||
"peerDependencies": {
|
"peerDependencies": {
|
||||||
"@types/node": "^20.19.0 || >=22.12.0",
|
"@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",
|
"esbuild": "^0.27.0 || ^0.28.0",
|
||||||
"jiti": ">=1.21.0",
|
"jiti": ">=1.21.0",
|
||||||
"less": "^4.0.0",
|
"less": "^4.0.0",
|
||||||
|
|||||||
@ -14,7 +14,7 @@
|
|||||||
"coverage": "vitest run --coverage"
|
"coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tanstack/react-query": "5.104.4",
|
"@tanstack/react-query": "5.104.5",
|
||||||
"@tanstack/react-router": "1.170.45",
|
"@tanstack/react-router": "1.170.45",
|
||||||
"react": "19.3.1",
|
"react": "19.3.1",
|
||||||
"react-dom": "19.3.1"
|
"react-dom": "19.3.1"
|
||||||
@ -39,8 +39,8 @@
|
|||||||
"globals": "17.13.0",
|
"globals": "17.13.0",
|
||||||
"jsdom": "30.1.3",
|
"jsdom": "30.1.3",
|
||||||
"typescript": "6.0.3",
|
"typescript": "6.0.3",
|
||||||
"typescript-eslint": "8.71.2",
|
"typescript-eslint": "8.72.0",
|
||||||
"vite": "8.3.4",
|
"vite": "8.3.5",
|
||||||
"vitest": "5.0.5"
|
"vitest": "5.0.5"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,7 +47,8 @@ export type CommandCategory = 'backlog' | 'library' | 'gaming' | 'store' | 'syst
|
|||||||
export interface ParamSpec {
|
export interface ParamSpec {
|
||||||
name: string
|
name: string
|
||||||
label: string
|
label: string
|
||||||
type: 'int' | 'string' | 'app_id'
|
/** `bool` is a checkbox, sent as 0/1. */
|
||||||
|
type: 'int' | 'string' | 'app_id' | 'bool'
|
||||||
required: boolean
|
required: boolean
|
||||||
min?: number
|
min?: number
|
||||||
max?: number
|
max?: number
|
||||||
@ -101,6 +102,13 @@ export interface StatusPayload {
|
|||||||
total_block: { active: boolean; days_remaining: number; until: string | null }
|
total_block: { active: boolean; days_remaining: number; until: string | null }
|
||||||
manual_pick_locked: boolean
|
manual_pick_locked: boolean
|
||||||
manual_picks: ManualPickInfo[]
|
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 {
|
export interface StatsPayload {
|
||||||
@ -156,6 +164,17 @@ export interface SetupRequest {
|
|||||||
|
|
||||||
export type DaemonState = 'running' | 'restarting' | 'unreachable'
|
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 {
|
export interface DaemonStatus {
|
||||||
state: DaemonState
|
state: DaemonState
|
||||||
started_at: string | null
|
started_at: string | null
|
||||||
@ -163,6 +182,11 @@ export interface DaemonStatus {
|
|||||||
journal_tail: string[]
|
journal_tail: string[]
|
||||||
/** ISO time before which `restart` is rate-limited, or null. */
|
/** ISO time before which `restart` is rate-limited, or null. */
|
||||||
restart_available_at: string | 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 {
|
export interface ServerHealth {
|
||||||
|
|||||||
@ -56,4 +56,20 @@ describe('ParamsForm', () => {
|
|||||||
render(<Harness disabled />)
|
render(<Harness disabled />)
|
||||||
expect(screen.getByLabelText('Note')).toBeDisabled()
|
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) => {
|
{specs.map((p, i) => {
|
||||||
const id = `${base}-${p.name}`
|
const id = `${base}-${p.name}`
|
||||||
const raw = values[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 err = paramError(p, raw, games)
|
||||||
const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined
|
const game = p.type === 'app_id' ? games.find((g) => g.app_id === Number(raw)) : undefined
|
||||||
return (
|
return (
|
||||||
|
|||||||
@ -172,7 +172,7 @@ function Runner({ spec, fixed, preset, onClose, onArmed }: RunnerProps) {
|
|||||||
</ErrorNotice>
|
</ErrorNotice>
|
||||||
)}
|
)}
|
||||||
<div className="actions">
|
<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}`}
|
{start.isPending ? 'Starting…' : countdown ? 'Arm countdown' : `Run ${spec.name}`}
|
||||||
</button>
|
</button>
|
||||||
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
<button type="button" className="btn btn-ghost" onClick={onClose}>
|
||||||
|
|||||||
@ -14,6 +14,10 @@ describe('paramError', () => {
|
|||||||
expect(paramError(spec({ required: false }), '', [])).toBeNull()
|
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', () => {
|
it('accepts any text for a string', () => {
|
||||||
expect(paramError(spec({ type: 'string' }), 'anything', [])).toBeNull()
|
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 })
|
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', () => {
|
it('keeps a typed int', () => {
|
||||||
expect(toParams(specs, { a: '2', d: '9' })).toEqual({ a: 2, d: 9 })
|
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. */
|
/** Error text for one field, or null when the value is acceptable. */
|
||||||
export function paramError(p: ParamSpec, raw: string, games: GameOption[]): string | null {
|
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()
|
const v = raw.trim()
|
||||||
if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : null
|
if (v === '') return p.required && p.default === undefined ? `${p.label} is required.` : null
|
||||||
if (p.type === 'string') return 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 { apiError, renderRouted, stubApi } from '../test/harness'
|
||||||
import { Dashboard } from './Dashboard'
|
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> = {}) => ({
|
const base = (over: Record<string, unknown> = {}) => ({
|
||||||
'GET /api/status': makeStatus(),
|
'GET /api/status': makeStatus(),
|
||||||
|
'GET /api/daemon': daemon(),
|
||||||
'GET /api/budget': makeBudget(),
|
'GET /api/budget': makeBudget(),
|
||||||
'GET /api/jobs': [],
|
'GET /api/jobs': [],
|
||||||
'GET /api/commands': [makeSpec('done'), makeSpec('install'), makeSpec('pick'), makeSpec('scan'), makeSpec('check')],
|
'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', () => {
|
describe('gaming today', () => {
|
||||||
it('shows time left and what is billing', async () => {
|
it('shows time left and what is billing', async () => {
|
||||||
show()
|
show()
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { useQuery } from '@tanstack/react-query'
|
import { useQuery } from '@tanstack/react-query'
|
||||||
import { Link } from '@tanstack/react-router'
|
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 { q } from '../api/queries'
|
||||||
import { CommandButton } from '../commands/CommandCard'
|
import { CommandButton } from '../commands/CommandCard'
|
||||||
import { commandLabel } from '../commands/catalog'
|
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() {
|
function BudgetGlance() {
|
||||||
const { data, error } = useQuery(q.budget)
|
const { data, error } = useQuery(q.budget)
|
||||||
if (error) return <Card title="Gaming today"><ErrorNotice error={error} /></Card>
|
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>}
|
{error ? <Card title="Assignment"><ErrorNotice error={error} /></Card> : data ? <StatusCard s={data} /> : <Card title="Assignment"><Loading what="status" /></Card>}
|
||||||
<BudgetGlance />
|
<BudgetGlance />
|
||||||
</div>
|
</div>
|
||||||
|
<FastInstalls />
|
||||||
<RecentJobs />
|
<RecentJobs />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
|
|||||||
@ -7,6 +7,7 @@ import { Actions, Card, PageHead } from '../ui/Page'
|
|||||||
/** Everything that changes which game is assigned. */
|
/** Everything that changes which game is assigned. */
|
||||||
export function Picks() {
|
export function Picks() {
|
||||||
const { data, error } = useQuery(q.status)
|
const { data, error } = useQuery(q.status)
|
||||||
|
const none = data !== undefined && data.manual_picks.length === 0 && data.kept_picks.length === 0
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<PageHead
|
<PageHead
|
||||||
@ -23,7 +24,7 @@ export function Picks() {
|
|||||||
<Card title="Manual picks">
|
<Card title="Manual picks">
|
||||||
{error && <ErrorNotice error={error} />}
|
{error && <ErrorNotice error={error} />}
|
||||||
{!data && !error && <Loading what="status" />}
|
{!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>
|
<Empty>No manual picks. "Lock in a game" adds one and locks most commands for 14 days.</Empty>
|
||||||
)}
|
)}
|
||||||
<ul className="rows">
|
<ul className="rows">
|
||||||
@ -35,8 +36,17 @@ export function Picks() {
|
|||||||
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
|
<CommandButton name="abandon-pick" preset={{ app_id: p.app_id }} label="Abandon…" tone="danger" />
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
{data && data.manual_picks.length === 0 && (
|
{none && (
|
||||||
<div className="actions">
|
<div className="actions">
|
||||||
<CommandButton name="abandon-pick" label="Abandon a pick by app id…" />
|
<CommandButton name="abandon-pick" label="Abandon a pick by app id…" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -37,6 +37,21 @@ describe('Picks', () => {
|
|||||||
expect(screen.getByText('Hollow Knight', { selector: 'strong' })).toBeInTheDocument()
|
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 () => {
|
it('invites locking in a game when there are none', async () => {
|
||||||
stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) })
|
stubApi({ 'GET /api/commands': SPECS, 'GET /api/status': makeStatus({ current_game_name: null }) })
|
||||||
renderRouted(<Picks />)
|
renderRouted(<Picks />)
|
||||||
|
|||||||
@ -57,6 +57,7 @@ export function makeStatus(over: Partial<StatusPayload> = {}): StatusPayload {
|
|||||||
total_block: { active: false, days_remaining: 0, until: null },
|
total_block: { active: false, days_remaining: 0, until: null },
|
||||||
manual_pick_locked: false,
|
manual_pick_locked: false,
|
||||||
manual_picks: [],
|
manual_picks: [],
|
||||||
|
kept_picks: [],
|
||||||
...over,
|
...over,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user