mirror of
https://github.com/kuhyx/steam-backlog-enforcer.git
synced 2026-11-11 12:47:32 +01:00
Split main.py (1076 lines) into a main/ package
main.py became main/ with __main__.py, so the invocation string baked into
run.sh and the systemd unit (`python -m steam_backlog_enforcer.main`) stays
byte-identical and nothing needs redeploying. Commands are grouped into
sibling modules (status, install, picks, abandon, misc), with _shared.py as
a leaf holding the lock helpers and constants both need, and _registry.py
holding the command tables. main/__init__.py keeps every public and private
name importable, so `from steam_backlog_enforcer.main import X` is unchanged.
The subtle part is patch targets. The test suite builds them as
f"{PKG}.{name}", so a grep for the literal module path found nothing -- but
once a command moves to a submodule it resolves its dependencies from that
module's globals, and patching the main package's re-export silently stops
biting. Measured which of the 36 patched symbols moved: 8 could have passed
vacuously. Rather than repair PKG per class, the five test_main*.py files
(all cap violators anyway) were regrouped to mirror the production modules,
so each file has exactly one correct PKG. Verified by mutation: breaking a
line in status/misc/picks/_shared each makes the corresponding tests fail.
test_web_server.py patched main_mod.serve, which no longer intercepts
cmd_serve; repointed at main.misc and confirmed it fails when the call is
removed. Added tests for the __main__ shim, which coverage would otherwise
report at 0% under fail_under = 100.
Shared test builders live in tests/_main_helpers.py, reachable because the
Phase 0 name-tests-test exclude allows tests/_*.py.
1290 tests pass; every main/ module reports 100% coverage; `main`, `status`,
`bogus` and `--status` produce byte-identical output and exit codes to
pre-refactor. 55 -> 49 files over the cap.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PPpmMpTSkp8UziRc5u8B7P
This commit is contained in:
parent
10c33690b9
commit
45ff4eb7c2
File diff suppressed because it is too large
Load Diff
239
steam_backlog_enforcer/main/__init__.py
Normal file
239
steam_backlog_enforcer/main/__init__.py
Normal file
@ -0,0 +1,239 @@
|
||||
"""Main CLI for Steam Backlog Enforcer.
|
||||
|
||||
The command implementations live in sibling modules (``status``, ``install``,
|
||||
``picks``, ``abandon``, ``misc``) and are re-exported here, so
|
||||
``steam_backlog_enforcer.main`` stays the single import surface it was when
|
||||
this was one module. ``python -m steam_backlog_enforcer.main`` still works via
|
||||
:mod:`steam_backlog_enforcer.main.__main__`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS
|
||||
from steam_backlog_enforcer._allowed_games import (
|
||||
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._cmd_done import cmd_done
|
||||
from steam_backlog_enforcer._cmd_playtime import (
|
||||
cmd_enforce,
|
||||
cmd_gaming_reset,
|
||||
cmd_gaming_status,
|
||||
cmd_gaming_unblock,
|
||||
)
|
||||
from steam_backlog_enforcer._stats import cmd_stats
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.game_install import _echo
|
||||
from steam_backlog_enforcer.main._registry import _make_all_commands
|
||||
from steam_backlog_enforcer.main._registry import (
|
||||
_print_usage as _print_usage_for,
|
||||
)
|
||||
from steam_backlog_enforcer.main._registry import (
|
||||
_resolve_command as _resolve_command_in,
|
||||
)
|
||||
from steam_backlog_enforcer.main._shared import (
|
||||
_MANUAL_LOCK_EXEMPT_COMMANDS,
|
||||
_MIN_CLI_ARGS,
|
||||
_TOTAL_BLOCK_EXEMPT_COMMANDS,
|
||||
_describe_pick,
|
||||
_enforce_manual_pick_lock,
|
||||
_enforce_total_block_lock,
|
||||
_is_manual_pick_locked,
|
||||
_show_manual_pick_lock_message,
|
||||
_show_total_block_lock_message,
|
||||
)
|
||||
from steam_backlog_enforcer.main.abandon import cmd_abandon_pick
|
||||
from steam_backlog_enforcer.main.install import (
|
||||
cmd_hide,
|
||||
cmd_install,
|
||||
cmd_installed,
|
||||
cmd_unhide,
|
||||
cmd_uninstall,
|
||||
)
|
||||
from steam_backlog_enforcer.main.misc import (
|
||||
cmd_add_exception,
|
||||
cmd_block_gaming,
|
||||
cmd_buy_dlc,
|
||||
cmd_reset,
|
||||
cmd_serve,
|
||||
cmd_setup,
|
||||
cmd_unblock,
|
||||
)
|
||||
from steam_backlog_enforcer.main.picks import (
|
||||
_resolve_game_name,
|
||||
cmd_pick,
|
||||
cmd_pick_manual,
|
||||
)
|
||||
from steam_backlog_enforcer.main.status import cmd_list, cmd_status
|
||||
from steam_backlog_enforcer.scanning import do_check, do_scan
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s [%(levelname)s] %(message)s",
|
||||
datefmt="%Y-%m-%d %H:%M:%S",
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Private helpers are re-exported too: they were importable from this module
|
||||
# before it became a package, and the test-suite reaches for them by name.
|
||||
__all__ = [
|
||||
"COMMANDS",
|
||||
"_MANUAL_GRACE_DAYS",
|
||||
"_MANUAL_LOCK_DAYS",
|
||||
"_MANUAL_LOCK_EXEMPT_COMMANDS",
|
||||
"_TOTAL_BLOCK_EXEMPT_COMMANDS",
|
||||
"_describe_pick",
|
||||
"_enforce_manual_pick_lock",
|
||||
"_enforce_total_block_lock",
|
||||
"_is_manual_pick_locked",
|
||||
"_print_usage",
|
||||
"_resolve_command",
|
||||
"_resolve_game_name",
|
||||
"_show_manual_pick_lock_message",
|
||||
"_show_total_block_lock_message",
|
||||
"cmd_abandon_pick",
|
||||
"cmd_add_exception",
|
||||
"cmd_block_gaming",
|
||||
"cmd_buy_dlc",
|
||||
"cmd_done",
|
||||
"cmd_hide",
|
||||
"cmd_install",
|
||||
"cmd_installed",
|
||||
"cmd_list",
|
||||
"cmd_pick",
|
||||
"cmd_pick_manual",
|
||||
"cmd_reset",
|
||||
"cmd_serve",
|
||||
"cmd_setup",
|
||||
"cmd_stats",
|
||||
"cmd_status",
|
||||
"cmd_unblock",
|
||||
"cmd_unhide",
|
||||
"cmd_uninstall",
|
||||
"main",
|
||||
]
|
||||
|
||||
COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
|
||||
"scan": ("Scan library & assign a game", do_scan),
|
||||
"check": ("Check assigned game completion", do_check),
|
||||
"status": ("Show current status", cmd_status),
|
||||
"list": ("List games from snapshot", cmd_list),
|
||||
"install": ("Install the assigned game", cmd_install),
|
||||
"hide": ("Hide all non-assigned games in library", cmd_hide),
|
||||
"unhide": ("Unhide all games in library", cmd_unhide),
|
||||
"unblock": ("Remove store blocking", cmd_unblock),
|
||||
"buy-dlc": ("Temporarily unblock store to buy DLC", cmd_buy_dlc),
|
||||
"reset": ("Reset all state", cmd_reset),
|
||||
"installed": ("List installed games", cmd_installed),
|
||||
"uninstall": ("Uninstall all non-assigned games", cmd_uninstall),
|
||||
"setup": ("Run first-time setup", cmd_setup),
|
||||
"done": ("Finish game, open HLTB, pick next", cmd_done),
|
||||
"pick": ("Manually pick your next game from candidates", cmd_pick),
|
||||
"stats": ("Show backlog completion-time estimates", cmd_stats),
|
||||
"serve": ("Start the interactive web UI (browser) server", cmd_serve),
|
||||
"gaming-status": ("Show today's gaming time and block state", cmd_gaming_status),
|
||||
"gaming-reset": ("Reset today's gaming counter (root + YES)", cmd_gaming_reset),
|
||||
}
|
||||
|
||||
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS).
|
||||
_EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = {
|
||||
"add-exception": "Request 24h-locked whitelist exception (use --reason)",
|
||||
"pick-manual": f"Pick a game by app_id, lock enforcer for {_MANUAL_LOCK_DAYS} days",
|
||||
"abandon-pick": (
|
||||
f"Undo a manual pick within {_MANUAL_GRACE_DAYS} days (needs app_id)"
|
||||
),
|
||||
"block-gaming": "Block ALL gaming for <days> days, no in-app undo",
|
||||
"enforce": "Run enforcer: block, uninstall, kill, hide (--demo for a 60s budget)",
|
||||
"gaming-unblock": "Force-release playtime bind mounts (root; recovery hatch)",
|
||||
}
|
||||
|
||||
_ALL_COMMANDS: dict[str, str] = _make_all_commands(
|
||||
COMMANDS, _EXTRA_COMMAND_DESCRIPTIONS
|
||||
)
|
||||
|
||||
|
||||
def _resolve_command(raw: str) -> str | None:
|
||||
"""Map a raw argv[1] onto a known command name (see :mod:`._registry`)."""
|
||||
return _resolve_command_in(raw, _ALL_COMMANDS)
|
||||
|
||||
|
||||
def _print_usage(unknown: str | None = None) -> None:
|
||||
"""Print the command list, optionally explaining a bad command."""
|
||||
_print_usage_for(_ALL_COMMANDS, unknown)
|
||||
|
||||
|
||||
def _dispatch_extra_command(command: str, config: Config, state: State) -> bool:
|
||||
"""Dispatch commands whose arguments do not fit the ``COMMANDS`` signature.
|
||||
|
||||
Split out of :func:`main` to keep its branch count within the complexity
|
||||
limit; every entry here takes raw ``sys.argv`` tail arguments rather than
|
||||
the ``(config, state)`` pair ``COMMANDS`` callables receive.
|
||||
|
||||
Args:
|
||||
command: Canonical command name.
|
||||
config: Loaded configuration.
|
||||
state: Loaded state.
|
||||
|
||||
Returns:
|
||||
True if *command* was handled here.
|
||||
"""
|
||||
if command == "add-exception":
|
||||
cmd_add_exception(sys.argv[2:])
|
||||
return True
|
||||
if command == "block-gaming":
|
||||
cmd_block_gaming(sys.argv[2:])
|
||||
return True
|
||||
if command == "pick-manual":
|
||||
cmd_pick_manual(config, state, sys.argv[2:])
|
||||
return True
|
||||
if command == "abandon-pick":
|
||||
cmd_abandon_pick(config, state, sys.argv[2:])
|
||||
return True
|
||||
if command == "enforce":
|
||||
sys.exit(cmd_enforce(config, state, sys.argv[2:]))
|
||||
if command == "gaming-unblock":
|
||||
sys.exit(cmd_gaming_unblock(sys.argv[2:]))
|
||||
return False
|
||||
|
||||
|
||||
def main() -> None:
|
||||
"""CLI entry point."""
|
||||
if len(sys.argv) < _MIN_CLI_ARGS:
|
||||
_print_usage()
|
||||
sys.exit(1)
|
||||
|
||||
# Locks below are always given the canonical name, never raw argv,
|
||||
# so a dashed spelling can never be used to dodge one.
|
||||
command = _resolve_command(sys.argv[1])
|
||||
if command is None:
|
||||
_print_usage(sys.argv[1])
|
||||
sys.exit(1)
|
||||
if command != sys.argv[1]:
|
||||
_echo(f"Note: treating '{sys.argv[1]}' as '{command}'.")
|
||||
|
||||
config = Config.load()
|
||||
|
||||
if command not in {"setup", "add-exception"} and not config.steam_api_key:
|
||||
_echo("Not configured. Run 'setup' first.")
|
||||
sys.exit(1)
|
||||
|
||||
state = State.load()
|
||||
|
||||
# Total block is the most restrictive lock - check it first.
|
||||
_enforce_total_block_lock(command)
|
||||
|
||||
# Enforce the manual-pick lock before dispatching any command.
|
||||
# This also covers add-exception (previously dispatched before state load).
|
||||
_enforce_manual_pick_lock(command, state)
|
||||
|
||||
if _dispatch_extra_command(command, config, state):
|
||||
return
|
||||
|
||||
_, func = COMMANDS[command]
|
||||
func(config, state)
|
||||
12
steam_backlog_enforcer/main/__main__.py
Normal file
12
steam_backlog_enforcer/main/__main__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""``python -m steam_backlog_enforcer.main`` entry point.
|
||||
|
||||
Kept as a separate module so the invocation string baked into ``run.sh`` and
|
||||
the systemd unit stays byte-identical now that ``main`` is a package.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from steam_backlog_enforcer.main import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
64
steam_backlog_enforcer/main/_registry.py
Normal file
64
steam_backlog_enforcer/main/_registry.py
Normal file
@ -0,0 +1,64 @@
|
||||
"""Command registry and argv resolution.
|
||||
|
||||
Split out of :mod:`steam_backlog_enforcer.main` to keep every module inside the
|
||||
250-line cap; the tables here are what ``main()`` dispatches on.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import difflib
|
||||
|
||||
from steam_backlog_enforcer.game_install import _echo
|
||||
|
||||
|
||||
def _make_all_commands(
|
||||
commands: dict[str, tuple[str, object]],
|
||||
extra: dict[str, str],
|
||||
) -> dict[str, str]:
|
||||
"""Merge the dispatchable commands with the raw-argv ones for help/lookup."""
|
||||
return {name: desc for name, (desc, _) in commands.items()} | extra
|
||||
|
||||
|
||||
def _resolve_command(raw: str, all_commands: dict[str, str]) -> str | None:
|
||||
"""Map a raw argv[1] onto a known command name.
|
||||
|
||||
Subcommands are bare words, but the CLI does use flags elsewhere
|
||||
(``add-exception --reason``), so ``--abandon-pick`` is the natural
|
||||
muscle-memory guess. Leading dashes carry no meaning in this slot,
|
||||
so they are simply stripped rather than rejected.
|
||||
|
||||
Parameters:
|
||||
raw (str): The first CLI argument, exactly as the user typed it.
|
||||
|
||||
Returns:
|
||||
str | None: The canonical command name, or None if unrecognised.
|
||||
"""
|
||||
if raw in all_commands:
|
||||
return raw
|
||||
if raw.startswith("-"):
|
||||
stripped = raw.lstrip("-")
|
||||
if stripped in all_commands:
|
||||
return stripped
|
||||
return None
|
||||
|
||||
|
||||
def _print_usage(all_commands: dict[str, str], unknown: str | None = None) -> None:
|
||||
"""Print the command list, optionally explaining a bad command.
|
||||
|
||||
Parameters:
|
||||
unknown (str | None): The unrecognised argument to report. When None
|
||||
(the no-arguments case) only the usage block is printed.
|
||||
"""
|
||||
if unknown is not None:
|
||||
_echo(f"Unknown command: {unknown}")
|
||||
close = difflib.get_close_matches(
|
||||
unknown.lstrip("-"), all_commands, n=1, cutoff=0.6
|
||||
)
|
||||
if close:
|
||||
_echo(f"Did you mean '{close[0]}'?")
|
||||
_echo("")
|
||||
_echo("Steam Backlog Enforcer\n")
|
||||
_echo("Usage: python -m steam_backlog_enforcer.main <command> [args]\n")
|
||||
_echo("Commands:")
|
||||
for name, desc in all_commands.items():
|
||||
_echo(f" {name:<14s} {desc}")
|
||||
187
steam_backlog_enforcer/main/_shared.py
Normal file
187
steam_backlog_enforcer/main/_shared.py
Normal file
@ -0,0 +1,187 @@
|
||||
"""Constants and lock helpers shared by the CLI command modules.
|
||||
|
||||
Leaf module: it imports from the wider package but never from a sibling in
|
||||
``main``, so the command modules can all depend on it without creating an
|
||||
import cycle.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import (
|
||||
MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._actions import (
|
||||
active_manual_picks,
|
||||
manual_pick_grace_remaining,
|
||||
)
|
||||
from steam_backlog_enforcer._actions import (
|
||||
is_manual_pick_locked as _is_manual_pick_locked,
|
||||
)
|
||||
from steam_backlog_enforcer._allowed_games import (
|
||||
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._total_block import (
|
||||
TotalBlockStatus,
|
||||
get_total_block_status,
|
||||
is_total_block_active,
|
||||
)
|
||||
from steam_backlog_enforcer.game_install import _echo
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import State
|
||||
|
||||
_LIST_DISPLAY_LIMIT = 50
|
||||
_MIN_CLI_ARGS = 2
|
||||
|
||||
# Commands that remain usable while the manual pick lock is active.
|
||||
# Principle: only what is needed to release the lock (done/check) or
|
||||
# that cannot change the game assignment (status, enforce, setup, serve).
|
||||
_MANUAL_LOCK_EXEMPT_COMMANDS = frozenset(
|
||||
{
|
||||
"done",
|
||||
"check",
|
||||
"status",
|
||||
"enforce",
|
||||
"setup",
|
||||
"serve",
|
||||
"abandon-pick",
|
||||
# Allowed so a second game can be locked in alongside the first; the
|
||||
# cap inside cmd_pick_manual is what stops this being a way out.
|
||||
"pick-manual",
|
||||
# The daily gaming budget is orthogonal to which game is assigned, and
|
||||
# gaming-unblock is a recovery hatch for a stuck bind mount - locking
|
||||
# it behind a manual pick would leave Steam masked with no way back.
|
||||
"gaming-status",
|
||||
"gaming-unblock",
|
||||
"gaming-reset",
|
||||
}
|
||||
)
|
||||
|
||||
# Commands that remain usable while a total gaming block is active. Far
|
||||
# stricter than _MANUAL_LOCK_EXEMPT_COMMANDS: no done/pick/reset/
|
||||
# add-exception - there is no in-app way to shorten a total block.
|
||||
#
|
||||
# gaming-unblock is included because a playtime bind mount makes the total
|
||||
# block's own `pacman -R steam` fail EBUSY - it must stay reachable exactly
|
||||
# when the two collide. gaming-reset is NOT included: it shortens enforcement.
|
||||
_TOTAL_BLOCK_EXEMPT_COMMANDS = frozenset(
|
||||
{"status", "enforce", "gaming-status", "gaming-unblock"}
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Total gaming block lock helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _show_total_block_lock_message(status: TotalBlockStatus) -> None:
|
||||
"""Print the total-gaming-block-active message to stdout."""
|
||||
_echo("\n" + "=" * 60)
|
||||
_echo(" *** TOTAL GAMING BLOCK ACTIVE ***")
|
||||
_echo("=" * 60)
|
||||
|
||||
if status.until is not None:
|
||||
_echo(f"\nBlocked until: {status.until.strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
_echo(f"Days remaining: {status.days_remaining:.1f}")
|
||||
|
||||
_echo(
|
||||
"\nSteam has been uninstalled, all known game/launcher processes are"
|
||||
"\nbeing killed on sight, and Steam + game-website domains are blocked."
|
||||
"\nThere is NO in-app command to lift this early."
|
||||
f"\n\nAllowed commands: {', '.join(sorted(_TOTAL_BLOCK_EXEMPT_COMMANDS))}"
|
||||
)
|
||||
_echo("=" * 60 + "\n")
|
||||
|
||||
|
||||
def _enforce_total_block_lock(command: str) -> None:
|
||||
"""Exit with a lock message if command is blocked by an active total block."""
|
||||
if not is_total_block_active():
|
||||
return
|
||||
if command in _TOTAL_BLOCK_EXEMPT_COMMANDS:
|
||||
return
|
||||
_show_total_block_lock_message(get_total_block_status())
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Manual pick lock helpers
|
||||
# (the predicate itself lives in _actions so the MCP server can reuse it)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _describe_pick(state: State, pick: dict[str, object]) -> bool:
|
||||
"""Print one manual pick's deadline and grace status; return if abandonable.
|
||||
|
||||
Args:
|
||||
state: Current enforcer state.
|
||||
pick: One active entry from ``state.manual_picks``.
|
||||
|
||||
Returns:
|
||||
Whether this pick is still inside its grace window.
|
||||
"""
|
||||
app_id = pick["app_id"]
|
||||
_echo(f"\n {pick['game_name']} (AppID={app_id})")
|
||||
|
||||
started_at = str(pick.get("started_at") or "")
|
||||
if started_at:
|
||||
try:
|
||||
started = datetime.fromisoformat(started_at)
|
||||
deadline = started + timedelta(days=_MANUAL_LOCK_DAYS)
|
||||
days_left = (deadline - datetime.now(timezone.utc)).days
|
||||
_echo(f" Locked since: {started.strftime('%Y-%m-%d')}")
|
||||
_echo(
|
||||
f" Deadline: {deadline.strftime('%Y-%m-%d')}"
|
||||
f" ({max(0, days_left)} day(s) remaining)"
|
||||
)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
grace_left = manual_pick_grace_remaining(state, int(str(app_id)))
|
||||
if grace_left is not None and grace_left > 0:
|
||||
_echo(
|
||||
f" Undo: abandon-pick {app_id}"
|
||||
f" ({grace_left:.1f} of {_MANUAL_GRACE_DAYS} grace day(s) left)"
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _show_manual_pick_lock_message(state: State) -> None:
|
||||
"""Print the aggressive lock-active message to stdout."""
|
||||
picks = active_manual_picks(state)
|
||||
_echo("\n" + "=" * 60)
|
||||
_echo(" *** MANUAL PICK LOCK ACTIVE ***")
|
||||
_echo("=" * 60)
|
||||
_echo(f"\nYou manually picked {len(picks)} game(s):")
|
||||
|
||||
any_in_grace = False
|
||||
for pick in picks:
|
||||
any_in_grace |= _describe_pick(state, pick)
|
||||
|
||||
_echo(
|
||||
"\nYou CANNOT use any other feature until you finish these games"
|
||||
"\n(100% achievements) or their 2-week deadlines pass."
|
||||
"\n\nTo release the lock: finish them, then run 'done' or 'check'."
|
||||
)
|
||||
|
||||
# 'abandon-pick' is dropped from the allowed list once no pick is still
|
||||
# inside its window, rather than offered as a command that would refuse.
|
||||
usable = set(_MANUAL_LOCK_EXEMPT_COMMANDS)
|
||||
if not any_in_grace:
|
||||
usable.discard("abandon-pick")
|
||||
_echo(f"\nAllowed commands: {', '.join(sorted(usable))}")
|
||||
_echo("=" * 60 + "\n")
|
||||
|
||||
|
||||
def _enforce_manual_pick_lock(command: str, state: State) -> None:
|
||||
"""Exit with a lock message if command is blocked by the manual pick."""
|
||||
if not _is_manual_pick_locked(state):
|
||||
return
|
||||
if command in _MANUAL_LOCK_EXEMPT_COMMANDS:
|
||||
return
|
||||
_show_manual_pick_lock_message(state)
|
||||
sys.exit(1)
|
||||
145
steam_backlog_enforcer/main/abandon.py
Normal file
145
steam_backlog_enforcer/main/abandon.py
Normal file
@ -0,0 +1,145 @@
|
||||
"""``abandon-pick``: back out of a manual pick inside its grace window."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import (
|
||||
ABANDON_COOLDOWN_DAYS as _ABANDON_COOLDOWN_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._actions import (
|
||||
MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._actions import (
|
||||
abandon_manual_pick,
|
||||
active_manual_picks,
|
||||
can_abandon_manual_pick,
|
||||
find_manual_pick,
|
||||
manual_pick_grace_remaining,
|
||||
)
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
is_game_installed,
|
||||
uninstall_game,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
_ABANDON_PICK_USAGE = (
|
||||
"Usage: abandon-pick <app_id>\n"
|
||||
" app_id : the manually-picked game you want to back out of.\n\n"
|
||||
f"Only works within {_MANUAL_GRACE_DAYS} days of the pick."
|
||||
)
|
||||
|
||||
|
||||
def _abandon_pick_target(state: State, args: list[str]) -> int | None:
|
||||
"""Validate the abandon-pick argument against the active manual pick.
|
||||
|
||||
Requiring the explicit app_id (rather than defaulting to the current pick)
|
||||
makes an accidental abandon impossible to trigger by muscle memory.
|
||||
|
||||
Args:
|
||||
state: Current enforcer state.
|
||||
args: Remaining CLI args (first element should be the app_id).
|
||||
|
||||
Returns:
|
||||
The validated app id, or ``None`` if the input was unusable (a message
|
||||
has already been printed in that case).
|
||||
"""
|
||||
if not args:
|
||||
_echo(_ABANDON_PICK_USAGE)
|
||||
return None
|
||||
|
||||
try:
|
||||
app_id = int(args[0])
|
||||
except ValueError:
|
||||
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
|
||||
return None
|
||||
|
||||
picks = active_manual_picks(state)
|
||||
if not picks:
|
||||
_echo("No manual pick is active — nothing to abandon.")
|
||||
return None
|
||||
|
||||
if find_manual_pick(state, app_id) is None:
|
||||
listed = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in picks)
|
||||
_echo(
|
||||
f"Error: AppID={app_id} is not one of your manual picks.\nActive: {listed}."
|
||||
)
|
||||
return None
|
||||
|
||||
return app_id
|
||||
|
||||
|
||||
def cmd_abandon_pick(_config: Config, state: State, args: list[str]) -> None:
|
||||
"""Back out of a manual pick while still inside the grace period.
|
||||
|
||||
Args:
|
||||
_config: Enforcer configuration (unused, kept for dispatch symmetry).
|
||||
state: Current enforcer state.
|
||||
args: Remaining CLI args (first element should be the app_id).
|
||||
"""
|
||||
app_id = _abandon_pick_target(state, args)
|
||||
if app_id is None:
|
||||
sys.exit(1)
|
||||
|
||||
pick = find_manual_pick(state, app_id)
|
||||
game_name = str(pick["game_name"]) if pick else ""
|
||||
|
||||
if not can_abandon_manual_pick(state, app_id):
|
||||
# ``remaining`` is negative once the window has closed, so its
|
||||
# magnitude is how long ago that happened.
|
||||
remaining = manual_pick_grace_remaining(state, app_id)
|
||||
elapsed = (
|
||||
f"{abs(remaining):.1f} day(s) ago"
|
||||
if remaining is not None
|
||||
else "at an unknown time"
|
||||
)
|
||||
_echo(
|
||||
f"\nGrace period EXPIRED for {game_name} (AppID={app_id}).\n"
|
||||
f"The {_MANUAL_GRACE_DAYS}-day window closed {elapsed}.\n"
|
||||
"Finish the game (100% achievements) or wait out the lock."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
others = [p for p in active_manual_picks(state) if p["app_id"] != app_id]
|
||||
_echo(f"\nAbandoning manual pick: {game_name} (AppID={app_id})")
|
||||
_echo(
|
||||
f"\nThis will:"
|
||||
f"\n - Drop this pick from your allowed games"
|
||||
f"\n - Uninstall {game_name}"
|
||||
f"\n - Keep it out of auto-assignment for"
|
||||
f" {_ABANDON_COOLDOWN_DAYS} days"
|
||||
)
|
||||
if others:
|
||||
kept = ", ".join(f"{p['game_name']} (AppID={p['app_id']})" for p in others)
|
||||
_echo(f"\n Your other pick(s) stay locked in: {kept}")
|
||||
else:
|
||||
_echo("\n - Leaves you with no assigned game (run 'scan' to get one)")
|
||||
_echo()
|
||||
confirm = input(f"Type YES to abandon {game_name}: ").strip()
|
||||
if confirm != "YES":
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
if not abandon_manual_pick(state, app_id):
|
||||
# Should be unreachable: the grace check above already passed.
|
||||
_echo("Error: the grace period closed before the pick could be abandoned.")
|
||||
sys.exit(1)
|
||||
|
||||
_echo(f"\nManual pick abandoned: {game_name}")
|
||||
|
||||
if is_game_installed(app_id):
|
||||
_echo(f" Uninstalling {game_name}...")
|
||||
if uninstall_game(app_id, game_name):
|
||||
_echo(" Uninstalled.")
|
||||
else:
|
||||
_echo(" Warning: could not uninstall — remove it from Steam manually.")
|
||||
|
||||
if state.current_app_id is None:
|
||||
_echo("\nNo game is assigned now. Run 'scan' to get a new assignment,")
|
||||
_echo("or 'pick-manual <app_id>' to choose one yourself.\n")
|
||||
else:
|
||||
_echo(f"\nStill assigned: {state.current_game_name}\n")
|
||||
128
steam_backlog_enforcer/main/install.py
Normal file
128
steam_backlog_enforcer/main/install.py
Normal file
@ -0,0 +1,128 @@
|
||||
"""Install, uninstall and library-visibility commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import allowed_app_ids, allowed_games
|
||||
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
get_installed_games,
|
||||
install_game,
|
||||
is_game_installed,
|
||||
is_protected_app,
|
||||
uninstall_other_games,
|
||||
)
|
||||
from steam_backlog_enforcer.library_hider import (
|
||||
try_hide_other_games,
|
||||
unhide_all_games,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
|
||||
def cmd_installed(_config: Config, state: State) -> None:
|
||||
"""Show installed games."""
|
||||
installed = get_installed_games()
|
||||
_echo(f"\nInstalled games ({len(installed)}):\n")
|
||||
for app_id, name in installed:
|
||||
protected = " [PROTECTED]" if is_protected_app(app_id) else ""
|
||||
assigned = " <<< ASSIGNED" if app_id == state.current_app_id else ""
|
||||
_echo(f" {app_id:>8d} {name}{protected}{assigned}")
|
||||
|
||||
|
||||
def cmd_uninstall(_config: Config, state: State) -> None:
|
||||
"""Uninstall all games except the assigned one."""
|
||||
if state.current_app_id is None:
|
||||
_echo("No game assigned. Run 'scan' first.")
|
||||
return
|
||||
|
||||
allowed = allowed_app_ids(state)
|
||||
installed = get_installed_games()
|
||||
to_remove = [
|
||||
(aid, n)
|
||||
for aid, n in installed
|
||||
if aid not in allowed and not is_protected_app(aid)
|
||||
]
|
||||
|
||||
if not to_remove:
|
||||
_echo("No games to uninstall (only allowed games and runtimes installed).")
|
||||
return
|
||||
|
||||
_echo(f"\nWill uninstall {len(to_remove)} games, keeping:")
|
||||
for aid, name in allowed_games(state):
|
||||
_echo(f" - {name} (AppID={aid})")
|
||||
_echo(" - Steam runtimes and Proton versions\n")
|
||||
_echo("Games to remove:")
|
||||
for aid, name in to_remove:
|
||||
_echo(f" - {name} (AppID={aid})")
|
||||
|
||||
_echo()
|
||||
confirm = input("Type YES to confirm: ").strip()
|
||||
if confirm != "YES":
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
count = uninstall_other_games(allowed_app_ids(state))
|
||||
_echo(f"\nUninstalled {count} games.")
|
||||
|
||||
|
||||
def cmd_install(config: Config, state: State) -> None:
|
||||
"""Manually trigger install of the assigned game."""
|
||||
if state.current_app_id is None:
|
||||
_echo("No game currently assigned. Run 'scan' first.")
|
||||
return
|
||||
|
||||
if is_game_installed(state.current_app_id):
|
||||
_echo(f"{state.current_game_name} is already installed.")
|
||||
return
|
||||
|
||||
_echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...")
|
||||
if install_game(
|
||||
state.current_app_id,
|
||||
state.current_game_name,
|
||||
config.steam_id,
|
||||
use_steam_protocol=True,
|
||||
):
|
||||
_echo("Done!")
|
||||
else:
|
||||
_echo("Failed to create install manifest.")
|
||||
|
||||
|
||||
def cmd_hide(config: Config, state: State) -> None:
|
||||
"""Hide all non-assigned games in the Steam library."""
|
||||
if state.current_app_id is None:
|
||||
_echo("No game assigned. Run 'scan' first.")
|
||||
return
|
||||
|
||||
owned_ids = get_all_owned_app_ids(config)
|
||||
if not owned_ids:
|
||||
_echo("No owned game list available. Run 'scan' first.")
|
||||
return
|
||||
|
||||
_echo(f"Hiding all games except {state.current_game_name}...")
|
||||
hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state))
|
||||
if skipped is not None:
|
||||
_echo(f"Library hiding: skipped ({skipped})")
|
||||
return
|
||||
_echo(f"Hidden {hidden} games.")
|
||||
|
||||
if hidden > 0:
|
||||
_echo("Done! Only the assigned game should be visible in your library.")
|
||||
|
||||
|
||||
def cmd_unhide(config: Config, _state: State) -> None:
|
||||
"""Unhide all games in the Steam library."""
|
||||
owned_ids = get_all_owned_app_ids(config)
|
||||
if not owned_ids:
|
||||
_echo("No owned game list available. Run 'scan' first.")
|
||||
return
|
||||
|
||||
_echo("Unhiding all games...")
|
||||
count = unhide_all_games(owned_ids)
|
||||
_echo(f"Unhidden {count} games.")
|
||||
|
||||
if count > 0:
|
||||
_echo("Done!")
|
||||
203
steam_backlog_enforcer/main/misc.py
Normal file
203
steam_backlog_enforcer/main/misc.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""Commands that do not belong to a larger group.
|
||||
|
||||
Store blocking, state reset, setup, whitelist exceptions, the web UI and the
|
||||
total gaming block.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._total_block import start_total_block
|
||||
from steam_backlog_enforcer._web_server import serve
|
||||
from steam_backlog_enforcer._whitelist import (
|
||||
add_pending_exception,
|
||||
validate_reason,
|
||||
)
|
||||
from steam_backlog_enforcer.config import interactive_setup
|
||||
from steam_backlog_enforcer.game_install import _echo
|
||||
from steam_backlog_enforcer.library_hider import restart_steam, unhide_all_games
|
||||
from steam_backlog_enforcer.store_blocker import block_store, unblock_store
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
_MIN_ADD_EXCEPTION_ARGS = 3
|
||||
_ADD_EXCEPTION_USAGE = (
|
||||
'Usage: add-exception <app_id> --reason "<justification>"\n'
|
||||
" app_id : numeric Steam application ID\n"
|
||||
" --reason : genuine justification (>= 5 words)\n\n"
|
||||
"Example:\n"
|
||||
" add-exception 440 --reason "
|
||||
'"TF2 is needed for a community event this weekend"\n\n'
|
||||
"Exceptions become active immediately."
|
||||
)
|
||||
|
||||
_BLOCK_GAMING_USAGE = (
|
||||
"Usage: block-gaming <days>\n"
|
||||
" days : whole number of days to block ALL gaming:\n"
|
||||
" Steam uninstalled, all known game/launcher processes killed,\n"
|
||||
" Steam + game-website domains blocked.\n\n"
|
||||
"There is NO in-app command to undo this early once confirmed."
|
||||
)
|
||||
|
||||
|
||||
def cmd_unblock(_config: Config, _state: State) -> None:
|
||||
"""Remove store blocking."""
|
||||
if unblock_store():
|
||||
_echo("Steam store unblocked.")
|
||||
else:
|
||||
_echo("Failed to unblock. Run with sudo.")
|
||||
|
||||
|
||||
def cmd_buy_dlc(config: Config, state: State) -> None:
|
||||
"""Temporarily unblock the store so the user can buy DLC."""
|
||||
if state.current_app_id is None:
|
||||
_echo("No game currently assigned.")
|
||||
return
|
||||
|
||||
_echo(f"Current game: {state.current_game_name} (AppID={state.current_app_id})")
|
||||
_echo("Unblocking Steam store for DLC purchase...")
|
||||
|
||||
if not unblock_store():
|
||||
_echo("Failed to unblock store. Run with sudo.")
|
||||
return
|
||||
|
||||
_echo("\nStore UNBLOCKED — buy your DLC now.")
|
||||
_echo("Press Enter when you're done to re-block the store...")
|
||||
input()
|
||||
|
||||
if config.block_store:
|
||||
if block_store():
|
||||
_echo("Store re-blocked. Restarting Steam to clear DNS cache...")
|
||||
restart_steam()
|
||||
_echo("Done.")
|
||||
else:
|
||||
_echo("Warning: failed to re-block store.")
|
||||
|
||||
|
||||
def cmd_reset(config: Config, state: State) -> None:
|
||||
"""Reset all state (unblock, unhide, clear assignment)."""
|
||||
unblock_store()
|
||||
|
||||
# Unhide all games in the library.
|
||||
try:
|
||||
owned = get_all_owned_app_ids(config)
|
||||
if owned:
|
||||
count = unhide_all_games(owned)
|
||||
if count:
|
||||
_echo(f"Unhidden {count} games.")
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
_echo(f"Warning: could not unhide games: {exc}")
|
||||
|
||||
state.current_app_id = None
|
||||
state.current_game_name = ""
|
||||
state.finished_app_ids = []
|
||||
state.manual_pick_app_id = None
|
||||
state.manual_pick_game_name = ""
|
||||
state.manual_pick_started_at = ""
|
||||
state.manual_picks = []
|
||||
state.save()
|
||||
_echo("State reset. Store unblocked.")
|
||||
|
||||
|
||||
def cmd_setup(_config: Config, _state: State) -> None:
|
||||
"""Run interactive setup."""
|
||||
interactive_setup()
|
||||
|
||||
|
||||
def cmd_serve(_config: Config, _state: State) -> None:
|
||||
"""Start the interactive web UI server (read-only, localhost only)."""
|
||||
serve()
|
||||
|
||||
|
||||
def cmd_add_exception(args: list[str]) -> None:
|
||||
"""Add a whitelist exception, active immediately.
|
||||
|
||||
Usage: add-exception <app_id> --reason "<text>"
|
||||
|
||||
The exception becomes active right away (no cooldown). The reason must be
|
||||
a genuine justification of at least 5 words with sufficient entropy.
|
||||
|
||||
Args:
|
||||
args: CLI argument list after the command name.
|
||||
"""
|
||||
if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args:
|
||||
_echo(_ADD_EXCEPTION_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
app_id = int(args[0])
|
||||
except ValueError:
|
||||
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
|
||||
sys.exit(1)
|
||||
|
||||
reason_idx = args.index("--reason")
|
||||
reason_parts = args[reason_idx + 1 :]
|
||||
if not reason_parts:
|
||||
_echo("Error: --reason requires a value.")
|
||||
sys.exit(1)
|
||||
reason = " ".join(reason_parts)
|
||||
|
||||
# Show validation feedback before attempting to add.
|
||||
err = validate_reason(reason)
|
||||
if err is not None:
|
||||
_echo(f"Invalid reason: {err}")
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
msg = add_pending_exception(app_id, reason)
|
||||
except ValueError as exc:
|
||||
_echo(f"Error: {exc}")
|
||||
sys.exit(1)
|
||||
|
||||
_echo(msg)
|
||||
|
||||
|
||||
def cmd_block_gaming(args: list[str]) -> None:
|
||||
"""Start a total gaming block for a fixed number of days.
|
||||
|
||||
Usage: block-gaming <days>
|
||||
|
||||
Args:
|
||||
args: Remaining CLI args (first element should be the day count).
|
||||
"""
|
||||
if not args:
|
||||
_echo(_BLOCK_GAMING_USAGE)
|
||||
sys.exit(1)
|
||||
|
||||
try:
|
||||
days = int(args[0])
|
||||
except ValueError:
|
||||
_echo(f"Error: days must be a whole number, got '{args[0]}'.")
|
||||
sys.exit(1)
|
||||
|
||||
if days < 1:
|
||||
_echo("Error: days must be at least 1.")
|
||||
sys.exit(1)
|
||||
|
||||
_echo(
|
||||
f"\nWARNING: This will, for the next {days} day(s):"
|
||||
f"\n - Uninstall Steam"
|
||||
f"\n - Kill Steam and all known game-launcher processes on sight"
|
||||
f"\n - Block all Steam network domains AND known browser/flash"
|
||||
f"\n game websites"
|
||||
f"\n\nThere is NO in-app command to undo this early. It can only be"
|
||||
f"\nlifted by waiting out the {days} day(s), or by manual root-level"
|
||||
f"\nsystem administration outside this tool."
|
||||
)
|
||||
_echo()
|
||||
confirm = input(f"Type YES to confirm a {days}-day total gaming block: ").strip()
|
||||
if confirm != "YES":
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
_echo("\nStarting total gaming block...")
|
||||
if start_total_block(days):
|
||||
_echo(f"Total gaming block ACTIVE for {days} day(s).")
|
||||
_echo("Run 'status' to check remaining time.")
|
||||
else:
|
||||
_echo("Error: failed to engage the block (see logs). Run with sudo?")
|
||||
sys.exit(1)
|
||||
222
steam_backlog_enforcer/main/picks.py
Normal file
222
steam_backlog_enforcer/main/picks.py
Normal file
@ -0,0 +1,222 @@
|
||||
"""Game-selection commands: ``pick``, ``pick-manual`` and ``abandon-pick``."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import (
|
||||
MANUAL_GRACE_DAYS as _MANUAL_GRACE_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._actions import (
|
||||
active_manual_picks,
|
||||
allowed_app_ids,
|
||||
allowed_games,
|
||||
apply_manual_pick,
|
||||
manual_pick_slots_left,
|
||||
)
|
||||
from steam_backlog_enforcer._allowed_games import (
|
||||
MANUAL_LOCK_DAYS as _MANUAL_LOCK_DAYS,
|
||||
)
|
||||
from steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
|
||||
from steam_backlog_enforcer._hltb_types import load_hltb_cache
|
||||
from steam_backlog_enforcer.config import load_snapshot
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
install_game,
|
||||
is_game_installed,
|
||||
uninstall_other_games,
|
||||
)
|
||||
from steam_backlog_enforcer.library_hider import try_hide_other_games
|
||||
from steam_backlog_enforcer.main._shared import _MANUAL_LOCK_EXEMPT_COMMANDS
|
||||
from steam_backlog_enforcer.scanning import pick_next_game
|
||||
from steam_backlog_enforcer.steam_api import (
|
||||
GameInfo,
|
||||
SteamAPIClient,
|
||||
SteamAPIError,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
|
||||
def cmd_pick(config: Config, state: State) -> None:
|
||||
"""Manually pick a new game from the shortest-first candidate list."""
|
||||
snapshot_data = load_snapshot()
|
||||
if not snapshot_data:
|
||||
_echo("No snapshot found. Run 'scan' first.")
|
||||
return
|
||||
|
||||
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
|
||||
hltb_cache = load_hltb_cache()
|
||||
for game in games:
|
||||
if game.app_id in hltb_cache:
|
||||
game.completionist_hours = hltb_cache[game.app_id]
|
||||
|
||||
pick_next_game(games, state, config)
|
||||
|
||||
if state.current_app_id is not None:
|
||||
owned_ids = get_all_owned_app_ids(config)
|
||||
if owned_ids:
|
||||
hidden, skipped = try_hide_other_games(owned_ids, allowed_app_ids(state))
|
||||
if skipped is not None:
|
||||
_echo(f"\n Library hiding: skipped ({skipped})")
|
||||
elif hidden > 0:
|
||||
_echo(f"\n Library: hid {hidden} games")
|
||||
|
||||
|
||||
def _resolve_game_name(config: Config, app_id: int) -> str | None:
|
||||
"""Look up a game name by app_id, checking snapshot then Steam API.
|
||||
|
||||
Returns the game name, or None if not found.
|
||||
"""
|
||||
# Fast path: snapshot already on disk.
|
||||
snapshot = load_snapshot()
|
||||
if snapshot:
|
||||
for entry in snapshot:
|
||||
if entry.get("app_id") == app_id:
|
||||
return str(entry["name"])
|
||||
|
||||
# Slower path: owned games API.
|
||||
try:
|
||||
client = SteamAPIClient(config.steam_api_key, config.steam_id)
|
||||
owned = client.get_owned_games()
|
||||
for g in owned:
|
||||
if g.get("appid") == app_id:
|
||||
return str(g.get("name", f"Unknown ({app_id})"))
|
||||
except (SteamAPIError, OSError, RuntimeError, ValueError):
|
||||
return None
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _report_pick_slots(config: Config, state: State) -> list[dict[str, object]]:
|
||||
"""Show which manual picks are already locked in; exit if the cap is full.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration (for ``max_manual_picks``).
|
||||
state: Current enforcer state.
|
||||
|
||||
Returns:
|
||||
The currently-active manual picks.
|
||||
"""
|
||||
existing = active_manual_picks(state)
|
||||
if existing:
|
||||
_echo(f"\nAlready locked in ({len(existing)}/{config.max_manual_picks}):")
|
||||
for pick in existing:
|
||||
_echo(f" - {pick['game_name']} (AppID={pick['app_id']})")
|
||||
|
||||
if manual_pick_slots_left(state, config.max_manual_picks) == 0:
|
||||
_echo(
|
||||
f"\nError: you already have {config.max_manual_picks} manual pick(s)."
|
||||
f"\nFinish one, or undo one with 'abandon-pick <app_id>' while it is"
|
||||
f"\nstill inside its {_MANUAL_GRACE_DAYS}-day grace window."
|
||||
)
|
||||
sys.exit(1)
|
||||
|
||||
return existing
|
||||
|
||||
|
||||
def _apply_allowed_set(config: Config, state: State) -> None:
|
||||
"""Make the filesystem and library match the allowed set.
|
||||
|
||||
Uninstalls everything outside it, installs every allowed game that is
|
||||
missing, and hides the rest of the library. Operating on the whole set
|
||||
(rather than one app id) is what lets a second manual pick coexist with
|
||||
the first instead of tearing it down.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration.
|
||||
state: Current enforcer state.
|
||||
"""
|
||||
allowed = allowed_app_ids(state)
|
||||
|
||||
if config.uninstall_other_games:
|
||||
_echo(" Uninstalling non-allowed games...")
|
||||
count = uninstall_other_games(allowed)
|
||||
if count:
|
||||
_echo(f" Uninstalled {count} non-allowed game(s)")
|
||||
|
||||
for app_id, name in allowed_games(state):
|
||||
if is_game_installed(app_id):
|
||||
_echo(f" {name} is already installed.")
|
||||
continue
|
||||
_echo(f" Installing {name}...")
|
||||
install_game(app_id, name, config.steam_id, use_steam_protocol=True)
|
||||
|
||||
owned_ids = get_all_owned_app_ids(config)
|
||||
if owned_ids:
|
||||
hidden, skipped = try_hide_other_games(owned_ids, allowed)
|
||||
if skipped is not None:
|
||||
_echo(f" Library hiding: skipped ({skipped})")
|
||||
elif hidden > 0:
|
||||
_echo(f" Library: hid {hidden} games")
|
||||
|
||||
|
||||
def cmd_pick_manual(config: Config, state: State, args: list[str]) -> None:
|
||||
"""Manually pick a game by Steam app_id, locking the enforcer for 2 weeks.
|
||||
|
||||
Args:
|
||||
config: Enforcer configuration.
|
||||
state: Current enforcer state.
|
||||
args: Remaining CLI args (first element should be the app_id).
|
||||
"""
|
||||
raw_id = args[0] if args else input("Enter Steam app_id: ").strip()
|
||||
|
||||
try:
|
||||
app_id = int(raw_id)
|
||||
except ValueError:
|
||||
_echo(f"Error: app_id must be a number, got '{raw_id}'.")
|
||||
return
|
||||
|
||||
_echo(f"Looking up AppID={app_id}...")
|
||||
game_name = _resolve_game_name(config, app_id)
|
||||
if game_name is None:
|
||||
_echo(
|
||||
f"Error: AppID={app_id} not found in your Steam library or snapshot.\n"
|
||||
"Run 'scan' first, or verify the app_id is correct."
|
||||
)
|
||||
return
|
||||
|
||||
_echo(f"\nGame found: {game_name} (AppID={app_id})")
|
||||
|
||||
existing = _report_pick_slots(config, state)
|
||||
|
||||
_echo(
|
||||
f"\nWARNING: Picking this game will:"
|
||||
f"\n - Add it to your allowed games ({len(existing) + 1} of"
|
||||
f" {config.max_manual_picks} slot(s) used)"
|
||||
f"\n - Lock ALL other commands for {_MANUAL_LOCK_DAYS} DAYS or until"
|
||||
f"\n you reach 100% achievements on every pick"
|
||||
f"\n - Leave only these commands usable:"
|
||||
f"\n {', '.join(sorted(_MANUAL_LOCK_EXEMPT_COMMANDS))}"
|
||||
f"\n - Stay undoable via 'abandon-pick {app_id}' for the first"
|
||||
f"\n {_MANUAL_GRACE_DAYS} days"
|
||||
)
|
||||
_echo()
|
||||
confirm = input(
|
||||
f"Type YES to confirm you will play {game_name} until completion: "
|
||||
).strip()
|
||||
if confirm != "YES":
|
||||
_echo("Aborted.")
|
||||
return
|
||||
|
||||
# State mutation is the shared, stdout-free core (also used by the MCP
|
||||
# server); the destructive post-assignment cascade below stays CLI-only.
|
||||
refused = apply_manual_pick(
|
||||
state,
|
||||
app_id,
|
||||
game_name,
|
||||
max_picks=config.max_manual_picks,
|
||||
)
|
||||
if refused is not None:
|
||||
_echo(f"\nError: {refused}")
|
||||
sys.exit(1)
|
||||
|
||||
_echo(f"\nManual pick confirmed: {game_name} (AppID={app_id})")
|
||||
_echo(f"Lock active from now until 100% achievements or {_MANUAL_LOCK_DAYS} days.")
|
||||
_echo("Run 'done' or 'check' once you have 100% to release the lock.\n")
|
||||
|
||||
# Post-assignment: mirror what _assign_chosen_game + cmd_pick do, but for
|
||||
# the whole allowed set so an earlier pick is not torn down by a later one.
|
||||
_apply_allowed_set(config, state)
|
||||
101
steam_backlog_enforcer/main/status.py
Normal file
101
steam_backlog_enforcer/main/status.py
Normal file
@ -0,0 +1,101 @@
|
||||
"""``status`` and ``list`` commands: read-only views of the current state."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from steam_backlog_enforcer._actions import (
|
||||
active_manual_picks,
|
||||
manual_pick_grace_remaining,
|
||||
)
|
||||
from steam_backlog_enforcer._total_block import get_total_block_status
|
||||
from steam_backlog_enforcer.config import load_snapshot
|
||||
from steam_backlog_enforcer.game_install import (
|
||||
_echo,
|
||||
get_installed_games,
|
||||
is_protected_app,
|
||||
)
|
||||
from steam_backlog_enforcer.main._shared import _LIST_DISPLAY_LIMIT
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
from steam_backlog_enforcer.store_blocker import is_store_blocked
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
|
||||
|
||||
def cmd_status(_config: Config, state: State) -> None:
|
||||
"""Show current status."""
|
||||
_echo("=== Steam Backlog Enforcer ===\n")
|
||||
|
||||
total_block = get_total_block_status()
|
||||
if total_block.active:
|
||||
_echo("*** TOTAL GAMING BLOCK ACTIVE ***")
|
||||
if total_block.until is not None:
|
||||
_echo(f"Blocked until: {total_block.until.strftime('%Y-%m-%d %H:%M UTC')}")
|
||||
_echo(f"Days remaining: {total_block.days_remaining:.1f}\n")
|
||||
|
||||
if state.current_app_id:
|
||||
_echo(
|
||||
f"Assigned game: {state.current_game_name} (AppID={state.current_app_id})"
|
||||
)
|
||||
else:
|
||||
_echo("No game currently assigned.")
|
||||
|
||||
_echo(f"Finished games: {len(state.finished_app_ids)}")
|
||||
_echo(f"Store blocked: {is_store_blocked()}")
|
||||
|
||||
# Show installed games.
|
||||
installed = get_installed_games()
|
||||
real_games = [(aid, n) for aid, n in installed if not is_protected_app(aid)]
|
||||
_echo(f"Installed games: {len(real_games)}")
|
||||
|
||||
if state.current_app_id:
|
||||
is_assigned_installed = any(aid == state.current_app_id for aid, _ in installed)
|
||||
_echo(f"Assigned game installed: {is_assigned_installed}")
|
||||
|
||||
picks = active_manual_picks(state)
|
||||
if picks:
|
||||
_echo(f"\nManual picks ({len(picks)}):")
|
||||
for pick in picks:
|
||||
grace = manual_pick_grace_remaining(state, pick["app_id"])
|
||||
undo = (
|
||||
f" — undoable for {grace:.1f} more day(s)"
|
||||
if grace is not None and grace > 0
|
||||
else ""
|
||||
)
|
||||
_echo(f" {pick['game_name']} (AppID={pick['app_id']}){undo}")
|
||||
_echo("\n[MANUAL PICK LOCK is active — most commands are blocked]")
|
||||
|
||||
|
||||
def cmd_list(_config: Config, state: State) -> None:
|
||||
"""List games from the last snapshot."""
|
||||
snapshot = load_snapshot()
|
||||
if snapshot is None:
|
||||
_echo("No snapshot found. Run 'scan' first.")
|
||||
return
|
||||
|
||||
games = [GameInfo.from_snapshot(d) for d in snapshot]
|
||||
incomplete = [g for g in games if not g.is_complete]
|
||||
complete = [g for g in games if g.is_complete]
|
||||
|
||||
# Sort incomplete by completionist hours.
|
||||
def sort_key(g: GameInfo) -> tuple[int, float]:
|
||||
if g.completionist_hours > 0:
|
||||
return (0, g.completionist_hours)
|
||||
return (1, 0.0)
|
||||
|
||||
incomplete.sort(key=sort_key)
|
||||
|
||||
_echo(f"\n{'─' * 70}")
|
||||
_echo(f" INCOMPLETE ({len(incomplete)} games)")
|
||||
_echo(f"{'─' * 70}")
|
||||
for i, g in enumerate(incomplete[:_LIST_DISPLAY_LIMIT], 1):
|
||||
marker = " <<< ASSIGNED" if g.app_id == state.current_app_id else ""
|
||||
hrs = f" [{g.completionist_hours:.0f}h]" if g.completionist_hours > 0 else ""
|
||||
pct = f"{g.completion_pct:.0f}%"
|
||||
_echo(f" {i:3d}. {g.name[:40]:<40s} {pct:>5s}{hrs}{marker}")
|
||||
|
||||
if len(incomplete) > _LIST_DISPLAY_LIMIT:
|
||||
_echo(f" ... and {len(incomplete) - _LIST_DISPLAY_LIMIT} more")
|
||||
|
||||
_echo(f"\n COMPLETE: {len(complete)} games")
|
||||
148
steam_backlog_enforcer/tests/_main_helpers.py
Normal file
148
steam_backlog_enforcer/tests/_main_helpers.py
Normal file
@ -0,0 +1,148 @@
|
||||
"""Shared fixtures and builders for the ``main`` CLI test modules.
|
||||
|
||||
Lives outside a ``test_*.py`` name on purpose: these are constructors, not
|
||||
tests. ``name-tests-test`` exempts ``tests/_*.py`` for exactly this.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any
|
||||
|
||||
from steam_backlog_enforcer._actions import MANUAL_GRACE_DAYS
|
||||
from steam_backlog_enforcer._allowed_games import MANUAL_LOCK_DAYS
|
||||
from steam_backlog_enforcer._total_block import TotalBlockStatus
|
||||
from steam_backlog_enforcer.config import State
|
||||
|
||||
STARTED_AT = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
|
||||
# A start timestamp that is always past the 14-day deadline.
|
||||
EXPIRED_AT = (
|
||||
datetime.now(timezone.utc) - timedelta(days=MANUAL_LOCK_DAYS + 1)
|
||||
).isoformat()
|
||||
|
||||
ACTIVE_STATUS = TotalBlockStatus(
|
||||
active=True,
|
||||
started_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
until=datetime.now(timezone.utc) + timedelta(hours=23),
|
||||
days=1,
|
||||
days_remaining=0.96,
|
||||
)
|
||||
INACTIVE_STATUS = TotalBlockStatus(
|
||||
active=False, started_at=None, until=None, days=0, days_remaining=0.0
|
||||
)
|
||||
|
||||
|
||||
def snap(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
hours: float = -1,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one snapshot entry.
|
||||
|
||||
Args:
|
||||
app_id: Steam application id.
|
||||
name: Game name.
|
||||
total: Total achievement count.
|
||||
unlocked: Unlocked achievement count.
|
||||
hours: Completionist hours (-1 when unknown).
|
||||
|
||||
Returns:
|
||||
A snapshot dict shaped like the on-disk format.
|
||||
"""
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"name": name,
|
||||
"total_achievements": total,
|
||||
"unlocked_achievements": unlocked,
|
||||
"playtime_minutes": 60,
|
||||
"completionist_hours": hours,
|
||||
}
|
||||
|
||||
|
||||
def snap_with_achievements(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
hours: float = -1,
|
||||
) -> dict[str, Any]:
|
||||
"""Build a snapshot entry carrying an empty ``achievements`` list.
|
||||
|
||||
Args:
|
||||
app_id: Steam application id.
|
||||
name: Game name.
|
||||
total: Total achievement count.
|
||||
unlocked: Unlocked achievement count.
|
||||
hours: Completionist hours (-1 when unknown).
|
||||
|
||||
Returns:
|
||||
A snapshot dict with ``achievements`` and zero playtime.
|
||||
"""
|
||||
entry = snap(app_id, name, total, unlocked, hours)
|
||||
entry["playtime_minutes"] = 0
|
||||
entry["achievements"] = []
|
||||
return entry
|
||||
|
||||
|
||||
def locked_state(
|
||||
app_id: int = 100,
|
||||
name: str = "TestGame",
|
||||
started_at: str = STARTED_AT,
|
||||
) -> State:
|
||||
"""Build a State carrying one active manual pick.
|
||||
|
||||
Args:
|
||||
app_id: Picked application id.
|
||||
name: Picked game name.
|
||||
started_at: ISO timestamp the pick started at.
|
||||
|
||||
Returns:
|
||||
A State with a single manual pick.
|
||||
"""
|
||||
return State(
|
||||
manual_picks=[{"app_id": app_id, "game_name": name, "started_at": started_at}],
|
||||
)
|
||||
|
||||
|
||||
IN_GRACE = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
PAST_GRACE = (
|
||||
datetime.now(timezone.utc) - timedelta(days=MANUAL_GRACE_DAYS + 1)
|
||||
).isoformat()
|
||||
|
||||
VALID_REASON = "I need this game installed for a work presentation this week."
|
||||
|
||||
|
||||
def abandonable_state(app_id: int = 100, started_at: str = IN_GRACE) -> State:
|
||||
"""Build a State whose single manual pick is still abandonable.
|
||||
|
||||
Args:
|
||||
app_id: Picked application id.
|
||||
started_at: ISO timestamp the pick started at.
|
||||
|
||||
Returns:
|
||||
A State with the pick also set as the current assignment.
|
||||
"""
|
||||
state = locked_state(app_id=app_id, started_at=started_at)
|
||||
state.current_app_id = app_id
|
||||
state.current_game_name = state.manual_pick_game_name
|
||||
return state
|
||||
|
||||
|
||||
def two_pick_state() -> State:
|
||||
"""Build a State carrying two active manual picks.
|
||||
|
||||
Returns:
|
||||
A State whose current assignment is the second pick.
|
||||
"""
|
||||
state = State(
|
||||
manual_picks=[
|
||||
{"app_id": 100, "game_name": "TestGame", "started_at": IN_GRACE},
|
||||
{"app_id": 200, "game_name": "SecondGame", "started_at": IN_GRACE},
|
||||
],
|
||||
)
|
||||
state.current_app_id = 200
|
||||
state.current_game_name = "SecondGame"
|
||||
return state
|
||||
@ -1,491 +0,0 @@
|
||||
"""Tests for main CLI module."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_add_exception,
|
||||
cmd_buy_dlc,
|
||||
cmd_hide,
|
||||
cmd_install,
|
||||
cmd_installed,
|
||||
cmd_list,
|
||||
cmd_reset,
|
||||
cmd_setup,
|
||||
cmd_status,
|
||||
cmd_unblock,
|
||||
cmd_unhide,
|
||||
cmd_uninstall,
|
||||
main,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
|
||||
|
||||
def _snap(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
hours: float = -1,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"name": name,
|
||||
"total_achievements": total,
|
||||
"unlocked_achievements": unlocked,
|
||||
"playtime_minutes": 60,
|
||||
"completionist_hours": hours,
|
||||
}
|
||||
|
||||
|
||||
class TestCmdStatus:
|
||||
"""Tests for cmd_status."""
|
||||
|
||||
def test_with_game(self) -> None:
|
||||
state = State(current_app_id=440, current_game_name="TF2")
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=True),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_status(Config(), state)
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
|
||||
|
||||
class TestCmdList:
|
||||
"""Tests for cmd_list."""
|
||||
|
||||
def test_no_snapshot(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=None),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_list(Config(), State())
|
||||
assert any("No snapshot" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_with_games(self) -> None:
|
||||
snap = [
|
||||
_snap(1, "A", 10, 5, 20.0),
|
||||
_snap(2, "B", 10, 10, 10.0),
|
||||
_snap(3, "C", 10, 3, -1),
|
||||
]
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_list(Config(), state)
|
||||
|
||||
def test_many_games(self) -> None:
|
||||
snap = [_snap(i, f"Game{i}") for i in range(60)]
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_list(Config(), State())
|
||||
assert any("more" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdUnblock:
|
||||
"""Tests for cmd_unblock."""
|
||||
|
||||
def test_success(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unblock(Config(), State())
|
||||
|
||||
def test_fail(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=False),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_unblock(Config(), State())
|
||||
assert any("Failed" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdBuyDlc:
|
||||
"""Tests for cmd_buy_dlc."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_buy_dlc(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_unblock_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=False),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_buy_dlc(Config(), state)
|
||||
|
||||
def test_success_reblock(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config(block_store=True)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}.block_store", return_value=True),
|
||||
patch(f"{PKG}.restart_steam"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
cmd_buy_dlc(config, state)
|
||||
|
||||
def test_reblock_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config(block_store=True)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}.block_store", return_value=False),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
cmd_buy_dlc(config, state)
|
||||
assert any("Warning" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_no_reblock(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config(block_store=False)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
cmd_buy_dlc(config, state)
|
||||
|
||||
|
||||
class TestCmdReset:
|
||||
"""Tests for cmd_reset."""
|
||||
|
||||
def test_normal_reset(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1])
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=2),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
assert state.current_app_id is None
|
||||
assert state.finished_app_ids == []
|
||||
|
||||
def test_unhide_fails(self) -> None:
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(
|
||||
f"{PKG}.get_all_owned_app_ids",
|
||||
side_effect=OSError("fail"),
|
||||
),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
|
||||
def test_unhide_returns_zero(self) -> None:
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=0),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
|
||||
def test_no_owned_ids(self) -> None:
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
|
||||
|
||||
class TestCmdInstalled:
|
||||
"""Tests for cmd_installed."""
|
||||
|
||||
def test_shows_games(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
f"{PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (228980, "RT")],
|
||||
),
|
||||
patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_installed(Config(), State(current_app_id=440))
|
||||
|
||||
|
||||
class TestCmdUninstall:
|
||||
"""Tests for cmd_uninstall."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_uninstall(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_nothing_to_remove(self) -> None:
|
||||
state = State(current_app_id=440)
|
||||
with (
|
||||
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_uninstall(Config(), state)
|
||||
|
||||
def test_confirms_yes(self) -> None:
|
||||
state = State(current_app_id=440)
|
||||
with (
|
||||
patch(
|
||||
f"{PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (730, "CS")],
|
||||
),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=1),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_uninstall(Config(), state)
|
||||
|
||||
def test_aborts(self) -> None:
|
||||
state = State(current_app_id=440)
|
||||
with (
|
||||
patch(
|
||||
f"{PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (730, "CS")],
|
||||
),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_uninstall(Config(), state)
|
||||
assert any("Aborted" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdSetup:
|
||||
"""Tests for cmd_setup."""
|
||||
|
||||
def test_calls_interactive(self) -> None:
|
||||
with patch(f"{PKG}.interactive_setup") as mock_setup:
|
||||
cmd_setup(Config(), State())
|
||||
mock_setup.assert_called_once()
|
||||
|
||||
|
||||
class TestCmdInstall:
|
||||
"""Tests for cmd_install."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_install(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_already_installed(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(), state)
|
||||
|
||||
def test_installs_ok(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), state)
|
||||
|
||||
def test_install_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=False),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), state)
|
||||
|
||||
|
||||
class TestCmdHide:
|
||||
"""Tests for cmd_hide."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo"):
|
||||
cmd_hide(Config(), State())
|
||||
|
||||
def test_no_owned(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
|
||||
def test_hides(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
|
||||
def test_hides_zero(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
|
||||
def test_unreachable_steam_reports_skip(self) -> None:
|
||||
# Regression guard: a deferred Steam restart used to escape as a
|
||||
# traceback instead of degrading to a skip message.
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(
|
||||
f"{PKG}.try_hide_other_games",
|
||||
return_value=(0, "update in progress"),
|
||||
),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "skipped (update in progress)" in output
|
||||
assert "Hidden" not in output
|
||||
|
||||
|
||||
class TestCmdUnhide:
|
||||
"""Tests for cmd_unhide."""
|
||||
|
||||
def test_no_owned(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unhide(Config(), State())
|
||||
|
||||
def test_unhides(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=1),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unhide(Config(), State())
|
||||
|
||||
def test_unhides_zero(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=0),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unhide(Config(), State())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_add_exception
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_REASON = "I need this game installed for a work presentation this week."
|
||||
|
||||
|
||||
class TestCmdAddException:
|
||||
def test_no_args_prints_usage_and_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception([])
|
||||
|
||||
def test_missing_reason_flag_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "no", "flag"])
|
||||
|
||||
def test_non_numeric_app_id_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["notanumber", "--reason", _VALID_REASON])
|
||||
|
||||
def test_reason_flag_with_no_value_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason"])
|
||||
|
||||
def test_reason_flag_last_position_with_no_value_exits(self) -> None:
|
||||
# 3 args passes the len/flag guard but --reason is last so reason_parts=[]
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "extra", "--reason"])
|
||||
|
||||
def test_invalid_reason_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason", "too short"])
|
||||
|
||||
def test_add_pending_exception_raises_value_error(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(
|
||||
f"{PKG}.add_pending_exception",
|
||||
side_effect=ValueError("already approved"),
|
||||
),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason", _VALID_REASON])
|
||||
|
||||
def test_happy_path(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(
|
||||
f"{PKG}.add_pending_exception",
|
||||
return_value="Exception approved for AppID 440. Active immediately.",
|
||||
),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason", _VALID_REASON])
|
||||
mock_echo.assert_called()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to add-exception
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMainDispatchAddException:
|
||||
def test_dispatches_add_exception(self) -> None:
|
||||
argv = ["prog", "add-exception", "440", "--reason", _VALID_REASON]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.cmd_add_exception") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once_with(["440", "--reason", _VALID_REASON])
|
||||
249
steam_backlog_enforcer/tests/test_main_abandon.py
Normal file
249
steam_backlog_enforcer/tests/test_main_abandon.py
Normal file
@ -0,0 +1,249 @@
|
||||
"""Tests for the main CLI: abandon-pick command."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_enforce_manual_pick_lock,
|
||||
_show_manual_pick_lock_message,
|
||||
cmd_abandon_pick,
|
||||
cmd_status,
|
||||
main,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._main_helpers import (
|
||||
IN_GRACE,
|
||||
PAST_GRACE,
|
||||
abandonable_state,
|
||||
locked_state,
|
||||
two_pick_state,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.abandon"
|
||||
|
||||
|
||||
class TestCmdAbandonPick:
|
||||
def test_no_args_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit) as exc:
|
||||
cmd_abandon_pick(Config(), abandonable_state(), [])
|
||||
assert exc.value.code == 1
|
||||
assert "Usage" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_non_numeric_app_id_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), abandonable_state(), ["abc"])
|
||||
assert "must be a number" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_no_active_pick_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), State(), ["100"])
|
||||
assert "No manual pick" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_wrong_app_id_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), abandonable_state(app_id=100), ["999"])
|
||||
assert "not one of your manual picks" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
def test_expired_grace_exits_without_mutating(self) -> None:
|
||||
state = abandonable_state(started_at=PAST_GRACE)
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch.object(State, "save") as mock_save,
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert exc.value.code == 1
|
||||
assert "EXPIRED" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
mock_save.assert_not_called()
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100]
|
||||
|
||||
def test_expired_grace_with_bad_timestamp_still_exits(self) -> None:
|
||||
state = abandonable_state(started_at="not-a-date")
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
|
||||
def test_aborted_when_not_yes(self) -> None:
|
||||
state = abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch.object(State, "save") as mock_save,
|
||||
patch(f"{PKG}.uninstall_game") as mock_uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
mock_save.assert_not_called()
|
||||
mock_uninstall.assert_not_called()
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100]
|
||||
|
||||
def test_success_clears_lock_and_uninstalls(self) -> None:
|
||||
state = abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert state.manual_picks == []
|
||||
assert state.current_app_id is None
|
||||
assert state.skipped_until["100"] != ""
|
||||
mock_uninstall.assert_called_once_with(100, "TestGame")
|
||||
assert "abandoned" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_skips_uninstall_when_not_installed(self) -> None:
|
||||
state = abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.uninstall_game") as mock_uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
mock_uninstall.assert_not_called()
|
||||
|
||||
def test_warns_when_uninstall_fails(self) -> None:
|
||||
state = abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.uninstall_game", return_value=False),
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert "Warning" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_race_between_check_and_apply_exits(self) -> None:
|
||||
# The grace check passes, then the state-only core refuses: defensive
|
||||
# branch that must exit rather than report a false success.
|
||||
state = abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}.abandon_manual_pick", return_value=False),
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert exc.value.code == 1
|
||||
assert "grace period closed" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
|
||||
class TestAbandonPickLockInteraction:
|
||||
def test_abandon_pick_is_exempt_from_the_lock(self) -> None:
|
||||
# The escape hatch must survive the pre-dispatch lock check.
|
||||
_enforce_manual_pick_lock("abandon-pick", locked_state())
|
||||
|
||||
def test_main_dispatches_abandon_pick_while_locked(self) -> None:
|
||||
argv = ["prog", "abandon-pick", "100"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.Config.load",
|
||||
return_value=Config(steam_api_key="k"),
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.State.load",
|
||||
return_value=abandonable_state(),
|
||||
),
|
||||
patch("steam_backlog_enforcer.main.cmd_abandon_pick") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_lock_message_advertises_abandon_within_grace(self) -> None:
|
||||
state = abandonable_state(started_at=IN_GRACE)
|
||||
with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "abandon-pick 100" in output
|
||||
assert "grace day(s) left" in output
|
||||
|
||||
def test_lock_message_hides_abandon_after_grace(self) -> None:
|
||||
state = abandonable_state(started_at=PAST_GRACE)
|
||||
with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "abandon-pick" not in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Two concurrent manual picks (cap, coexistence, per-pick abandon)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAbandonOneOfTwoPicks:
|
||||
def test_other_pick_survives(self) -> None:
|
||||
state = two_pick_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["200"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "stay locked in: TestGame (AppID=100)" in output
|
||||
assert "Still assigned: TestGame" in output
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100]
|
||||
assert state.current_app_id == 100
|
||||
|
||||
def test_wrong_id_lists_all_active_picks(self) -> None:
|
||||
state = two_pick_state()
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), state, ["999"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TestGame (AppID=100)" in output
|
||||
assert "SecondGame (AppID=200)" in output
|
||||
|
||||
def test_lock_message_lists_both_picks(self) -> None:
|
||||
with patch("steam_backlog_enforcer.main._shared._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(two_pick_state())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "picked 2 game(s)" in output
|
||||
assert "abandon-pick 100" in output
|
||||
assert "abandon-pick 200" in output
|
||||
|
||||
def test_status_lists_both_picks(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.status.is_store_blocked",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.status.get_installed_games",
|
||||
return_value=[],
|
||||
),
|
||||
patch("steam_backlog_enforcer.main.status._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), two_pick_state())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Manual picks (2)" in output
|
||||
assert "undoable for" in output
|
||||
|
||||
def test_status_omits_undo_after_grace(self) -> None:
|
||||
state = two_pick_state()
|
||||
state.manual_picks[0]["started_at"] = PAST_GRACE
|
||||
state.manual_picks[1]["started_at"] = PAST_GRACE
|
||||
with (
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.status.is_store_blocked",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.status.get_installed_games",
|
||||
return_value=[],
|
||||
),
|
||||
patch("steam_backlog_enforcer.main.status._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), state)
|
||||
assert "undoable for" not in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
136
steam_backlog_enforcer/tests/test_main_dispatch.py
Normal file
136
steam_backlog_enforcer/tests/test_main_dispatch.py
Normal file
@ -0,0 +1,136 @@
|
||||
"""Tests for the main CLI: argv resolution and command dispatch."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_print_usage,
|
||||
_resolve_command,
|
||||
main,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main CLI entry point."""
|
||||
|
||||
def test_no_args_exits(self) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog"]),
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_unknown_command_exits(self) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "bogus"]),
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_valid_command_runs(self) -> None:
|
||||
mock_cmd = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "status"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}),
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_setup_no_key_required(self) -> None:
|
||||
mock_cmd = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "setup"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config()),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch.dict(f"{PKG}.COMMANDS", {"setup": ("s", mock_cmd)}),
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_no_api_key_exits(self) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "status"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config()),
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_dashed_command_dispatches_with_note(self) -> None:
|
||||
"""'--status' runs 'status' and says so."""
|
||||
mock_cmd = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "--status"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
assert any(
|
||||
"treating '--status' as 'status'" in str(call.args[0])
|
||||
for call in mock_echo.call_args_list
|
||||
if call.args
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("raw", ["bogus", "--bogus"])
|
||||
def test_unknown_command_names_the_input(self, raw: str) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", raw]),
|
||||
patch("steam_backlog_enforcer.main._registry._echo") as mock_echo,
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
assert any(
|
||||
str(call.args[0]).startswith(f"Unknown command: {raw}")
|
||||
for call in mock_echo.call_args_list
|
||||
if call.args
|
||||
)
|
||||
|
||||
|
||||
class TestResolveCommand:
|
||||
"""Tests for argv[1] normalisation and the usage printer."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("status", "status"),
|
||||
("--status", "status"),
|
||||
("--abandon-pick", "abandon-pick"),
|
||||
("-status", "status"),
|
||||
("bogus", None),
|
||||
("--bogus", None),
|
||||
("--", None),
|
||||
],
|
||||
)
|
||||
def test_resolve(self, raw: str, expected: str | None) -> None:
|
||||
assert _resolve_command(raw) == expected
|
||||
|
||||
def test_usage_suggests_close_match(self) -> None:
|
||||
with patch("steam_backlog_enforcer.main._registry._echo") as mock_echo:
|
||||
_print_usage("statu")
|
||||
lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args]
|
||||
assert "Did you mean 'status'?" in lines
|
||||
|
||||
def test_usage_omits_suggestion_when_far_off(self) -> None:
|
||||
with patch("steam_backlog_enforcer.main._registry._echo") as mock_echo:
|
||||
_print_usage("--zzzzzz")
|
||||
lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args]
|
||||
assert not any(line.startswith("Did you mean") for line in lines)
|
||||
|
||||
def test_usage_without_unknown_has_no_error_line(self) -> None:
|
||||
with patch("steam_backlog_enforcer.main._registry._echo") as mock_echo:
|
||||
_print_usage()
|
||||
lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args]
|
||||
assert not any(line.startswith("Unknown command") for line in lines)
|
||||
203
steam_backlog_enforcer/tests/test_main_dispatch_extra.py
Normal file
203
steam_backlog_enforcer/tests/test_main_dispatch_extra.py
Normal file
@ -0,0 +1,203 @@
|
||||
"""Tests for the main CLI: raw-argv command dispatch."""
|
||||
|
||||
import sys
|
||||
from unittest.mock import (
|
||||
patch,
|
||||
)
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
main,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._main_helpers import (
|
||||
ACTIVE_STATUS,
|
||||
VALID_REASON,
|
||||
locked_state,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
|
||||
|
||||
class TestMainDispatchAddException:
|
||||
def test_dispatches_add_exception(self) -> None:
|
||||
argv = ["prog", "add-exception", "440", "--reason", VALID_REASON]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.cmd_add_exception") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once_with(["440", "--reason", VALID_REASON])
|
||||
|
||||
|
||||
class TestMainDispatchPickManual:
|
||||
def test_dispatches_pick_manual(self) -> None:
|
||||
argv = ["prog", "pick-manual", "489830"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.cmd_pick_manual") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_pick_manual_dispatches_while_locked(self) -> None:
|
||||
# Reaching cmd_pick_manual is required for a second concurrent pick.
|
||||
argv = ["prog", "pick-manual", "730"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=locked_state()),
|
||||
patch(f"{PKG}.cmd_pick_manual") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_status shows lock hint when locked
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMainDispatchBlockGaming:
|
||||
def test_dispatches_block_gaming(self) -> None:
|
||||
argv = ["prog", "block-gaming", "14"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=False,
|
||||
),
|
||||
patch(f"{PKG}.cmd_block_gaming") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once_with(["14"])
|
||||
|
||||
def test_blocked_when_already_active(self) -> None:
|
||||
argv = ["prog", "scan"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.get_total_block_status",
|
||||
return_value=ACTIVE_STATUS,
|
||||
),
|
||||
patch("steam_backlog_enforcer.main._shared._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_status_allowed_when_active(self) -> None:
|
||||
# "status" is dispatched via the COMMANDS dict, which captures the
|
||||
# cmd_status function reference at import time - patching
|
||||
# main.cmd_status would not intercept it. Verify real behavior
|
||||
# (no SystemExit, real status output) instead.
|
||||
argv = ["prog", "status"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=True,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.status.is_store_blocked",
|
||||
return_value=False,
|
||||
),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main.status.get_installed_games",
|
||||
return_value=[],
|
||||
),
|
||||
# cmd_status resolves _echo from its own module, not the package.
|
||||
patch("steam_backlog_enforcer.main.status._echo") as mock_echo,
|
||||
):
|
||||
main() # must not raise SystemExit
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Steam Backlog Enforcer" in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_status shows total block info
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMainDispatchGamingCommands:
|
||||
def test_enforce_passes_its_tail_args_and_exits_with_the_code(self) -> None:
|
||||
"""`enforce` left COMMANDS because its signature cannot carry --demo."""
|
||||
argv = ["prog", "enforce", "--demo"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=False,
|
||||
),
|
||||
patch(f"{PKG}.cmd_enforce", return_value=0) as mock_cmd,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
assert mock_cmd.call_args.args[2] == ["--demo"]
|
||||
|
||||
def test_enforce_propagates_a_nonzero_exit(self) -> None:
|
||||
argv = ["prog", "enforce", "--bogus"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=False,
|
||||
),
|
||||
patch(f"{PKG}.cmd_enforce", return_value=2),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
def test_gaming_unblock_dispatches(self) -> None:
|
||||
argv = ["prog", "gaming-unblock"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=False,
|
||||
),
|
||||
patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
mock_cmd.assert_called_once_with([])
|
||||
|
||||
def test_gaming_unblock_reachable_during_a_total_block(self) -> None:
|
||||
"""A playtime mount makes the total block's own pacman -R fail EBUSY."""
|
||||
argv = ["prog", "gaming-unblock"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(
|
||||
"steam_backlog_enforcer.main._shared.is_total_block_active",
|
||||
return_value=True,
|
||||
),
|
||||
patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd,
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once_with([])
|
||||
132
steam_backlog_enforcer/tests/test_main_done.py
Normal file
132
steam_backlog_enforcer/tests/test_main_done.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Tests for the main CLI: done command dispatch."""
|
||||
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_done,
|
||||
)
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
|
||||
|
||||
class TestCmdDone:
|
||||
"""Tests for cmd_done."""
|
||||
|
||||
def test_no_game_assigned(self) -> None:
|
||||
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
|
||||
cmd_done(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_fetch_fails(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = None
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_not_complete_enforces(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_complete_finalizes(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_final.assert_called_once()
|
||||
|
||||
def test_hltb_cache_miss_fetches(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={1: 15.0},
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_hltb_negative_no_display(self) -> None:
|
||||
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_reassign_returns_true(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
35
steam_backlog_enforcer/tests/test_main_entrypoint.py
Normal file
35
steam_backlog_enforcer/tests/test_main_entrypoint.py
Normal file
@ -0,0 +1,35 @@
|
||||
"""Tests for the ``python -m steam_backlog_enforcer.main`` entry point.
|
||||
|
||||
The shim exists so the invocation string baked into ``run.sh`` and the systemd
|
||||
unit keeps working now that ``main`` is a package rather than a module.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
|
||||
class TestMainModule:
|
||||
"""The ``__main__`` shim that keeps ``python -m ...main`` working."""
|
||||
|
||||
def test_module_is_importable(self) -> None:
|
||||
"""Importing the shim must not execute main()."""
|
||||
with patch("steam_backlog_enforcer.main.main") as mock_main:
|
||||
entry = importlib.import_module("steam_backlog_enforcer.main.__main__")
|
||||
assert entry.main is not None
|
||||
mock_main.assert_not_called()
|
||||
|
||||
def test_guard_is_present(self) -> None:
|
||||
"""The shim calls main() behind a ``__main__`` guard.
|
||||
|
||||
Executing the shim here would call ``main()`` for real, so the guard is
|
||||
asserted on the source instead -- the runtime behaviour it produces is
|
||||
covered end-to-end by the CLI's own tests.
|
||||
"""
|
||||
source = (
|
||||
Path(__file__).resolve().parents[1] / "main" / "__main__.py"
|
||||
).read_text()
|
||||
assert 'if __name__ == "__main__":' in source
|
||||
assert "main()" in source
|
||||
194
steam_backlog_enforcer/tests/test_main_install.py
Normal file
194
steam_backlog_enforcer/tests/test_main_install.py
Normal file
@ -0,0 +1,194 @@
|
||||
"""Tests for the main CLI: install, uninstall and library-visibility commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_hide,
|
||||
cmd_install,
|
||||
cmd_installed,
|
||||
cmd_unhide,
|
||||
cmd_uninstall,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.install"
|
||||
|
||||
|
||||
class TestCmdInstalled:
|
||||
"""Tests for cmd_installed."""
|
||||
|
||||
def test_shows_games(self) -> None:
|
||||
with (
|
||||
patch(
|
||||
f"{PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (228980, "RT")],
|
||||
),
|
||||
patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_installed(Config(), State(current_app_id=440))
|
||||
|
||||
|
||||
class TestCmdUninstall:
|
||||
"""Tests for cmd_uninstall."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_uninstall(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_nothing_to_remove(self) -> None:
|
||||
state = State(current_app_id=440)
|
||||
with (
|
||||
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_uninstall(Config(), state)
|
||||
|
||||
def test_confirms_yes(self) -> None:
|
||||
state = State(current_app_id=440)
|
||||
with (
|
||||
patch(
|
||||
f"{PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (730, "CS")],
|
||||
),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=1),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_uninstall(Config(), state)
|
||||
|
||||
def test_aborts(self) -> None:
|
||||
state = State(current_app_id=440)
|
||||
with (
|
||||
patch(
|
||||
f"{PKG}.get_installed_games",
|
||||
return_value=[(440, "TF2"), (730, "CS")],
|
||||
),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_uninstall(Config(), state)
|
||||
assert any("Aborted" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdInstall:
|
||||
"""Tests for cmd_install."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_install(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_already_installed(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(), state)
|
||||
|
||||
def test_installs_ok(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), state)
|
||||
|
||||
def test_install_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game", return_value=False),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_install(Config(steam_id="i"), state)
|
||||
|
||||
|
||||
class TestCmdHide:
|
||||
"""Tests for cmd_hide."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo"):
|
||||
cmd_hide(Config(), State())
|
||||
|
||||
def test_no_owned(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
|
||||
def test_hides(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
|
||||
def test_hides_zero(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
|
||||
def test_unreachable_steam_reports_skip(self) -> None:
|
||||
# Regression guard: a deferred Steam restart used to escape as a
|
||||
# traceback instead of degrading to a skip message.
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(
|
||||
f"{PKG}.try_hide_other_games",
|
||||
return_value=(0, "update in progress"),
|
||||
),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_hide(Config(), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "skipped (update in progress)" in output
|
||||
assert "Hidden" not in output
|
||||
|
||||
|
||||
class TestCmdUnhide:
|
||||
"""Tests for cmd_unhide."""
|
||||
|
||||
def test_no_owned(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unhide(Config(), State())
|
||||
|
||||
def test_unhides(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=1),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unhide(Config(), State())
|
||||
|
||||
def test_unhides_zero(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=0),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unhide(Config(), State())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_add_exception
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_REASON = "I need this game installed for a work presentation this week."
|
||||
143
steam_backlog_enforcer/tests/test_main_misc.py
Normal file
143
steam_backlog_enforcer/tests/test_main_misc.py
Normal file
@ -0,0 +1,143 @@
|
||||
"""Tests for the main CLI: store, reset, setup, exception and block-gaming commands."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_buy_dlc,
|
||||
cmd_reset,
|
||||
cmd_setup,
|
||||
cmd_unblock,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.misc"
|
||||
|
||||
|
||||
class TestCmdUnblock:
|
||||
"""Tests for cmd_unblock."""
|
||||
|
||||
def test_success(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_unblock(Config(), State())
|
||||
|
||||
def test_fail(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=False),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_unblock(Config(), State())
|
||||
assert any("Failed" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdBuyDlc:
|
||||
"""Tests for cmd_buy_dlc."""
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_buy_dlc(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_unblock_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=False),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_buy_dlc(Config(), state)
|
||||
|
||||
def test_success_reblock(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config(block_store=True)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}.block_store", return_value=True),
|
||||
patch(f"{PKG}.restart_steam"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
cmd_buy_dlc(config, state)
|
||||
|
||||
def test_reblock_fails(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config(block_store=True)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}.block_store", return_value=False),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
cmd_buy_dlc(config, state)
|
||||
assert any("Warning" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_no_reblock(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
config = Config(block_store=False)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store", return_value=True),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value=""),
|
||||
):
|
||||
cmd_buy_dlc(config, state)
|
||||
|
||||
|
||||
class TestCmdReset:
|
||||
"""Tests for cmd_reset."""
|
||||
|
||||
def test_normal_reset(self) -> None:
|
||||
state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1])
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=2),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
assert state.current_app_id is None
|
||||
assert state.finished_app_ids == []
|
||||
|
||||
def test_unhide_fails(self) -> None:
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(
|
||||
f"{PKG}.get_all_owned_app_ids",
|
||||
side_effect=OSError("fail"),
|
||||
),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
|
||||
def test_unhide_returns_zero(self) -> None:
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.unhide_all_games", return_value=0),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
|
||||
def test_no_owned_ids(self) -> None:
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.unblock_store"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
cmd_reset(Config(), state)
|
||||
|
||||
|
||||
class TestCmdSetup:
|
||||
"""Tests for cmd_setup."""
|
||||
|
||||
def test_calls_interactive(self) -> None:
|
||||
with patch(f"{PKG}.interactive_setup") as mock_setup:
|
||||
cmd_setup(Config(), State())
|
||||
mock_setup.assert_called_once()
|
||||
141
steam_backlog_enforcer/tests/test_main_misc_gating.py
Normal file
141
steam_backlog_enforcer/tests/test_main_misc_gating.py
Normal file
@ -0,0 +1,141 @@
|
||||
"""Tests for the main CLI: add-exception and block-gaming commands."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_add_exception,
|
||||
cmd_block_gaming,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._main_helpers import (
|
||||
VALID_REASON,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.misc"
|
||||
|
||||
|
||||
class TestCmdAddException:
|
||||
def test_no_args_prints_usage_and_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception([])
|
||||
|
||||
def test_missing_reason_flag_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "no", "flag"])
|
||||
|
||||
def test_non_numeric_app_id_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["notanumber", "--reason", VALID_REASON])
|
||||
|
||||
def test_reason_flag_with_no_value_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason"])
|
||||
|
||||
def test_reason_flag_last_position_with_no_value_exits(self) -> None:
|
||||
# 3 args passes the len/flag guard but --reason is last so reason_parts=[]
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "extra", "--reason"])
|
||||
|
||||
def test_invalid_reason_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason", "too short"])
|
||||
|
||||
def test_add_pending_exception_raises_value_error(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(
|
||||
f"{PKG}.add_pending_exception",
|
||||
side_effect=ValueError("already approved"),
|
||||
),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason", VALID_REASON])
|
||||
|
||||
def test_happy_path(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(
|
||||
f"{PKG}.add_pending_exception",
|
||||
return_value="Exception approved for AppID 440. Active immediately.",
|
||||
),
|
||||
):
|
||||
cmd_add_exception(["440", "--reason", VALID_REASON])
|
||||
mock_echo.assert_called()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to add-exception
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdBlockGaming:
|
||||
def test_no_args_shows_usage(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit) as exc_info:
|
||||
cmd_block_gaming([])
|
||||
assert exc_info.value.code == 1
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Usage" in output
|
||||
|
||||
def test_non_numeric_days(self) -> None:
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_block_gaming(["abc"])
|
||||
|
||||
def test_zero_days_rejected(self) -> None:
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_block_gaming(["0"])
|
||||
|
||||
def test_negative_days_rejected(self) -> None:
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_block_gaming(["-1"])
|
||||
|
||||
def test_aborted_when_not_yes(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch(f"{PKG}.start_total_block") as mock_start,
|
||||
):
|
||||
cmd_block_gaming(["14"])
|
||||
mock_start.assert_not_called()
|
||||
|
||||
def test_confirmed_starts_block(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}.start_total_block", return_value=True) as mock_start,
|
||||
):
|
||||
cmd_block_gaming(["14"])
|
||||
mock_start.assert_called_once_with(14)
|
||||
|
||||
def test_start_failure_exits_nonzero(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}.start_total_block", return_value=False),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
cmd_block_gaming(["14"])
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to block-gaming
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@ -1,17 +1,14 @@
|
||||
"""Tests for main CLI module — part 2 (missing coverage)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
from unittest.mock import (
|
||||
patch,
|
||||
)
|
||||
|
||||
from steam_backlog_enforcer._cmd_done import (
|
||||
_enforce_on_done,
|
||||
_finalize_completion,
|
||||
cmd_done,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
@ -35,8 +32,6 @@ def _snap(
|
||||
|
||||
|
||||
class TestFinalizeCompletion:
|
||||
"""Tests for _finalize_completion."""
|
||||
|
||||
def test_with_snapshot_and_hiding(self) -> None:
|
||||
config = Config(steam_api_key="k", steam_id="i")
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
@ -182,295 +177,3 @@ class TestFinalizeCompletion:
|
||||
|
||||
mock_pick.side_effect = set_2
|
||||
_finalize_completion(config, state, "G", 1)
|
||||
|
||||
def test_refreshes_snapshot_hours_before_pick(self) -> None:
|
||||
"""Ensure stale snapshot hours are replaced before picking next game."""
|
||||
config = Config()
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
snap = [
|
||||
_snap(2, "A Space for the Unbound", 10, 0, 0.56),
|
||||
_snap(3, "Lacuna", 10, 0, 1.2),
|
||||
]
|
||||
seen: dict[int, float] = {}
|
||||
|
||||
def capture_pick(
|
||||
games: list[GameInfo],
|
||||
s: State,
|
||||
_c: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
for game in games:
|
||||
seen[game.app_id] = game.completionist_hours
|
||||
# Force early return path after pick_next_game.
|
||||
s.current_app_id = None
|
||||
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={2: 20.05}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={3: 18.81},
|
||||
) as mock_fetch_hltb,
|
||||
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=capture_pick),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
_finalize_completion(config, state, "G", 1)
|
||||
|
||||
assert seen[2] == 20.05
|
||||
assert seen[3] == 18.81
|
||||
mock_fetch_hltb.assert_called_once_with([(3, "Lacuna")])
|
||||
|
||||
def test_retriggers_install_after_library_hide_if_still_missing(self) -> None:
|
||||
"""Re-trigger install after hide step in case Steam restart drops it."""
|
||||
config = Config(steam_id="sid")
|
||||
state = State(current_app_id=1, current_game_name="DoneGame")
|
||||
snap = [_snap(2, "Next", 10, 0, 5.0)]
|
||||
|
||||
def set_next(
|
||||
_games: object,
|
||||
s: State,
|
||||
_c: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
s.current_app_id = 2
|
||||
s.current_game_name = "Next"
|
||||
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
_finalize_completion(config, state, "DoneGame", 1)
|
||||
|
||||
mock_install.assert_called_once_with(2, "Next", "sid", use_steam_protocol=True)
|
||||
|
||||
def test_skips_install_retry_when_assigned_game_already_installed(self) -> None:
|
||||
"""Do not re-trigger install when assigned game is already present."""
|
||||
config = Config(steam_id="sid")
|
||||
state = State(current_app_id=1, current_game_name="DoneGame")
|
||||
snap = [_snap(2, "Next", 10, 0, 5.0)]
|
||||
|
||||
def set_next(
|
||||
_games: object,
|
||||
s: State,
|
||||
_c: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
s.current_app_id = 2
|
||||
s.current_game_name = "Next"
|
||||
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
_finalize_completion(config, state, "DoneGame", 1)
|
||||
|
||||
mock_install.assert_not_called()
|
||||
|
||||
|
||||
class TestEnforceOnDone:
|
||||
"""Tests for _enforce_on_done."""
|
||||
|
||||
def test_no_current_game(self) -> None:
|
||||
_enforce_on_done(Config(), State())
|
||||
|
||||
def test_kills_and_uninstalls(self) -> None:
|
||||
config = Config(
|
||||
kill_unauthorized_games=True,
|
||||
uninstall_other_games=True,
|
||||
)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.enforce_allowed_game",
|
||||
return_value=[(1234, 999)],
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
|
||||
def test_no_violations_no_uninstalls(self) -> None:
|
||||
config = Config(
|
||||
kill_unauthorized_games=True,
|
||||
uninstall_other_games=True,
|
||||
)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]),
|
||||
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
|
||||
def test_hide_skipped_when_steam_unreachable(self) -> None:
|
||||
config = Config(kill_unauthorized_games=False, uninstall_other_games=False)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo") as mock_echo,
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.try_hide_other_games",
|
||||
return_value=(0, "Steam is not installed"),
|
||||
),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
assert "skipped (Steam is not installed)" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
def test_reinstall_when_not_installed(self) -> None:
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=False,
|
||||
steam_id="s1",
|
||||
)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
|
||||
|
||||
"""Tests for cmd_done."""
|
||||
|
||||
def test_no_game_assigned(self) -> None:
|
||||
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
|
||||
cmd_done(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_fetch_fails(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = None
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_not_complete_enforces(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_complete_finalizes(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_final.assert_called_once()
|
||||
|
||||
def test_hltb_cache_miss_fetches(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={1: 15.0},
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_hltb_negative_no_display(self) -> None:
|
||||
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_reassign_returns_true(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
225
steam_backlog_enforcer/tests/test_main_part2b.py
Normal file
225
steam_backlog_enforcer/tests/test_main_part2b.py
Normal file
@ -0,0 +1,225 @@
|
||||
"""Tests for the main CLI: enforce-on-done behaviour."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from steam_backlog_enforcer._cmd_done import (
|
||||
_enforce_on_done,
|
||||
cmd_done,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
|
||||
|
||||
def _snap(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
hours: float = -1,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"name": name,
|
||||
"total_achievements": total,
|
||||
"unlocked_achievements": unlocked,
|
||||
"playtime_minutes": 60,
|
||||
"completionist_hours": hours,
|
||||
}
|
||||
|
||||
|
||||
class TestEnforceOnDone:
|
||||
"""Tests for _enforce_on_done."""
|
||||
|
||||
def test_no_current_game(self) -> None:
|
||||
_enforce_on_done(Config(), State())
|
||||
|
||||
def test_kills_and_uninstalls(self) -> None:
|
||||
config = Config(
|
||||
kill_unauthorized_games=True,
|
||||
uninstall_other_games=True,
|
||||
)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.enforce_allowed_game",
|
||||
return_value=[(1234, 999)],
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
|
||||
def test_no_violations_no_uninstalls(self) -> None:
|
||||
config = Config(
|
||||
kill_unauthorized_games=True,
|
||||
uninstall_other_games=True,
|
||||
)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]),
|
||||
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
|
||||
def test_hide_skipped_when_steam_unreachable(self) -> None:
|
||||
config = Config(kill_unauthorized_games=False, uninstall_other_games=False)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo") as mock_echo,
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.try_hide_other_games",
|
||||
return_value=(0, "Steam is not installed"),
|
||||
),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
assert "skipped (Steam is not installed)" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
def test_reinstall_when_not_installed(self) -> None:
|
||||
config = Config(
|
||||
kill_unauthorized_games=False,
|
||||
uninstall_other_games=False,
|
||||
steam_id="s1",
|
||||
)
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
):
|
||||
_enforce_on_done(config, state)
|
||||
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
|
||||
|
||||
"""Tests for cmd_done."""
|
||||
|
||||
def test_no_game_assigned(self) -> None:
|
||||
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
|
||||
cmd_done(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_fetch_fails(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = None
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_not_complete_enforces(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_complete_finalizes(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_final.assert_called_once()
|
||||
|
||||
def test_hltb_cache_miss_fetches(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={1: 15.0},
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_hltb_negative_no_display(self) -> None:
|
||||
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_reassign_returns_true(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
132
steam_backlog_enforcer/tests/test_main_part2c.py
Normal file
132
steam_backlog_enforcer/tests/test_main_part2c.py
Normal file
@ -0,0 +1,132 @@
|
||||
"""Tests for finalize_completion: snapshot refresh and install retry."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import (
|
||||
patch,
|
||||
)
|
||||
|
||||
from steam_backlog_enforcer._cmd_done import (
|
||||
_finalize_completion,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
|
||||
|
||||
def _snap(
|
||||
app_id: int = 1,
|
||||
name: str = "G",
|
||||
total: int = 10,
|
||||
unlocked: int = 0,
|
||||
hours: float = -1,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"name": name,
|
||||
"total_achievements": total,
|
||||
"unlocked_achievements": unlocked,
|
||||
"playtime_minutes": 60,
|
||||
"completionist_hours": hours,
|
||||
}
|
||||
|
||||
|
||||
class TestFinalizeCompletionRetries:
|
||||
def test_refreshes_snapshot_hours_before_pick(self) -> None:
|
||||
"""Ensure stale snapshot hours are replaced before picking next game."""
|
||||
config = Config()
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
snap = [
|
||||
_snap(2, "A Space for the Unbound", 10, 0, 0.56),
|
||||
_snap(3, "Lacuna", 10, 0, 1.2),
|
||||
]
|
||||
seen: dict[int, float] = {}
|
||||
|
||||
def capture_pick(
|
||||
games: list[GameInfo],
|
||||
s: State,
|
||||
_c: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
for game in games:
|
||||
seen[game.app_id] = game.completionist_hours
|
||||
# Force early return path after pick_next_game.
|
||||
s.current_app_id = None
|
||||
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={2: 20.05}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={3: 18.81},
|
||||
) as mock_fetch_hltb,
|
||||
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=capture_pick),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
_finalize_completion(config, state, "G", 1)
|
||||
|
||||
assert seen[2] == 20.05
|
||||
assert seen[3] == 18.81
|
||||
mock_fetch_hltb.assert_called_once_with([(3, "Lacuna")])
|
||||
|
||||
def test_retriggers_install_after_library_hide_if_still_missing(self) -> None:
|
||||
"""Re-trigger install after hide step in case Steam restart drops it."""
|
||||
config = Config(steam_id="sid")
|
||||
state = State(current_app_id=1, current_game_name="DoneGame")
|
||||
snap = [_snap(2, "Next", 10, 0, 5.0)]
|
||||
|
||||
def set_next(
|
||||
_games: object,
|
||||
s: State,
|
||||
_c: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
s.current_app_id = 2
|
||||
s.current_game_name = "Next"
|
||||
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
_finalize_completion(config, state, "DoneGame", 1)
|
||||
|
||||
mock_install.assert_called_once_with(2, "Next", "sid", use_steam_protocol=True)
|
||||
|
||||
def test_skips_install_retry_when_assigned_game_already_installed(self) -> None:
|
||||
"""Do not re-trigger install when assigned game is already present."""
|
||||
config = Config(steam_id="sid")
|
||||
state = State(current_app_id=1, current_game_name="DoneGame")
|
||||
snap = [_snap(2, "Next", 10, 0, 5.0)]
|
||||
|
||||
def set_next(
|
||||
_games: object,
|
||||
s: State,
|
||||
_c: object,
|
||||
**_kwargs: object,
|
||||
) -> None:
|
||||
s.current_app_id = 2
|
||||
s.current_game_name = "Next"
|
||||
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
|
||||
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{CMD_DONE_PKG}.try_hide_other_games", return_value=(1, None)),
|
||||
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
|
||||
patch(f"{CMD_DONE_PKG}.send_notification"),
|
||||
patch.object(State, "save"),
|
||||
):
|
||||
_finalize_completion(config, state, "DoneGame", 1)
|
||||
|
||||
mock_install.assert_not_called()
|
||||
@ -1,397 +0,0 @@
|
||||
"""Tests for main CLI module — part 3 (cmd_done, main, cmd_pick)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from typing import Any
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._cmd_done import (
|
||||
cmd_done,
|
||||
)
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_print_usage,
|
||||
_resolve_command,
|
||||
cmd_pick,
|
||||
main,
|
||||
)
|
||||
from steam_backlog_enforcer.steam_api import GameInfo
|
||||
|
||||
CMD_DONE_PKG = "steam_backlog_enforcer._cmd_done"
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
|
||||
|
||||
def _snap(
|
||||
app_id: int,
|
||||
name: str,
|
||||
total: int,
|
||||
unlocked: int,
|
||||
hours: float,
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"app_id": app_id,
|
||||
"name": name,
|
||||
"total_achievements": total,
|
||||
"unlocked_achievements": unlocked,
|
||||
"playtime_minutes": 0,
|
||||
"completionist_hours": hours,
|
||||
"achievements": [],
|
||||
}
|
||||
|
||||
|
||||
class TestCmdDone:
|
||||
"""Tests for cmd_done."""
|
||||
|
||||
def test_no_game_assigned(self) -> None:
|
||||
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
|
||||
cmd_done(Config(), State())
|
||||
assert any("No game" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_fetch_fails(self) -> None:
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = None
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_not_complete_enforces(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_complete_finalizes(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_final.assert_called_once()
|
||||
|
||||
def test_hltb_cache_miss_fetches(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
|
||||
patch(
|
||||
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
|
||||
return_value={1: 15.0},
|
||||
),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_hltb_negative_no_display(self) -> None:
|
||||
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=5,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
|
||||
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
def test_reassign_returns_true(self) -> None:
|
||||
game = GameInfo(
|
||||
app_id=1,
|
||||
name="G",
|
||||
total_achievements=10,
|
||||
unlocked_achievements=10,
|
||||
playtime_minutes=60,
|
||||
)
|
||||
mock_client = MagicMock()
|
||||
mock_client.refresh_single_game.return_value = game
|
||||
state = State(current_app_id=1, current_game_name="G")
|
||||
with (
|
||||
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
|
||||
patch(f"{CMD_DONE_PKG}._echo"),
|
||||
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
|
||||
patch(f"{CMD_DONE_PKG}._finalize_completion"),
|
||||
):
|
||||
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
|
||||
|
||||
|
||||
class TestMain:
|
||||
"""Tests for main CLI entry point."""
|
||||
|
||||
def test_no_args_exits(self) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog"]),
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_unknown_command_exits(self) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "bogus"]),
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_valid_command_runs(self) -> None:
|
||||
mock_cmd = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "status"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}),
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_setup_no_key_required(self) -> None:
|
||||
mock_cmd = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "setup"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config()),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch.dict(f"{PKG}.COMMANDS", {"setup": ("s", mock_cmd)}),
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_no_api_key_exits(self) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "status"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config()),
|
||||
patch(f"{PKG}._echo"),
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
|
||||
def test_dashed_command_dispatches_with_note(self) -> None:
|
||||
"""'--status' runs 'status' and says so."""
|
||||
mock_cmd = MagicMock()
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", "--status"]),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
assert any(
|
||||
"treating '--status' as 'status'" in str(call.args[0])
|
||||
for call in mock_echo.call_args_list
|
||||
if call.args
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("raw", ["bogus", "--bogus"])
|
||||
def test_unknown_command_names_the_input(self, raw: str) -> None:
|
||||
with (
|
||||
patch.object(sys, "argv", ["prog", raw]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
pytest.raises(SystemExit, match="1"),
|
||||
):
|
||||
main()
|
||||
assert any(
|
||||
str(call.args[0]).startswith(f"Unknown command: {raw}")
|
||||
for call in mock_echo.call_args_list
|
||||
if call.args
|
||||
)
|
||||
|
||||
|
||||
class TestResolveCommand:
|
||||
"""Tests for argv[1] normalisation and the usage printer."""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected"),
|
||||
[
|
||||
("status", "status"),
|
||||
("--status", "status"),
|
||||
("--abandon-pick", "abandon-pick"),
|
||||
("-status", "status"),
|
||||
("bogus", None),
|
||||
("--bogus", None),
|
||||
("--", None),
|
||||
],
|
||||
)
|
||||
def test_resolve(self, raw: str, expected: str | None) -> None:
|
||||
assert _resolve_command(raw) == expected
|
||||
|
||||
def test_usage_suggests_close_match(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_print_usage("statu")
|
||||
lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args]
|
||||
assert "Did you mean 'status'?" in lines
|
||||
|
||||
def test_usage_omits_suggestion_when_far_off(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_print_usage("--zzzzzz")
|
||||
lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args]
|
||||
assert not any(line.startswith("Did you mean") for line in lines)
|
||||
|
||||
def test_usage_without_unknown_has_no_error_line(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_print_usage()
|
||||
lines = [str(c.args[0]) for c in mock_echo.call_args_list if c.args]
|
||||
assert not any(line.startswith("Unknown command") for line in lines)
|
||||
|
||||
|
||||
class TestCmdPick:
|
||||
"""Tests for cmd_pick."""
|
||||
|
||||
def test_no_snapshot_prints_message(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
|
||||
mock_echo.assert_called_once_with("No snapshot found. Run 'scan' first.")
|
||||
|
||||
def test_calls_pick_next_game(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
config = Config(steam_api_key="k", steam_id="i")
|
||||
state = State()
|
||||
cmd_pick(config, state)
|
||||
mock_pick.assert_called_once()
|
||||
|
||||
def test_hides_games_after_pick(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(2, None)) as mock_hide,
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_hide.assert_called_once_with([1, 2, 3], {2})
|
||||
|
||||
def test_no_hide_message_when_none_hidden(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_echo.assert_not_called()
|
||||
|
||||
def test_unreachable_steam_reports_skip(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
patch(
|
||||
f"{PKG}.try_hide_other_games",
|
||||
return_value=(0, "Steam is not installed"),
|
||||
),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "skipped (Steam is not installed)" in output
|
||||
|
||||
def test_no_hide_when_no_current_app(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids") as mock_owned,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
|
||||
mock_owned.assert_not_called()
|
||||
|
||||
def test_no_hide_when_owned_ids_empty(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}.try_hide_other_games") as mock_hide,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_hide.assert_not_called()
|
||||
|
||||
def test_hltb_cache_applied_to_games(self) -> None:
|
||||
snap = [_snap(2, "NewGame", 10, 0, -1.0)]
|
||||
captured_games: list[list[GameInfo]] = []
|
||||
config = Config(steam_api_key="k", steam_id="i")
|
||||
state = State()
|
||||
|
||||
def capture_pick(games: list[GameInfo], *_args: object) -> None:
|
||||
captured_games.append(list(games))
|
||||
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snap),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 7.5}),
|
||||
patch(f"{PKG}.pick_next_game", side_effect=capture_pick),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick(config, state)
|
||||
|
||||
assert len(captured_games) == 1
|
||||
assert captured_games[0][0].completionist_hours == pytest.approx(7.5)
|
||||
@ -1,790 +0,0 @@
|
||||
"""Tests for main CLI module — part 4 (manual pick lock + cmd_pick_manual)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_MANUAL_GRACE_DAYS,
|
||||
_MANUAL_LOCK_DAYS,
|
||||
_enforce_manual_pick_lock,
|
||||
_is_manual_pick_locked,
|
||||
_resolve_game_name,
|
||||
_show_manual_pick_lock_message,
|
||||
cmd_abandon_pick,
|
||||
cmd_pick_manual,
|
||||
cmd_status,
|
||||
main,
|
||||
)
|
||||
from steam_backlog_enforcer.steam_api import SteamAPIError
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
|
||||
# A start timestamp that is always within the 14-day lock window.
|
||||
_STARTED_AT = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
# A start timestamp that is always past the 14-day deadline.
|
||||
_EXPIRED_AT = (
|
||||
datetime.now(timezone.utc) - timedelta(days=_MANUAL_LOCK_DAYS + 1)
|
||||
).isoformat()
|
||||
|
||||
|
||||
def _locked_state(
|
||||
app_id: int = 100,
|
||||
name: str = "TestGame",
|
||||
started_at: str = _STARTED_AT,
|
||||
) -> State:
|
||||
return State(
|
||||
manual_picks=[{"app_id": app_id, "game_name": name, "started_at": started_at}],
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _is_manual_pick_locked
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestIsManualPickLocked:
|
||||
def test_no_manual_pick_not_locked(self) -> None:
|
||||
assert _is_manual_pick_locked(State()) is False
|
||||
|
||||
def test_game_finished_not_locked(self) -> None:
|
||||
state = _locked_state(app_id=100)
|
||||
state.finished_app_ids = [100]
|
||||
assert _is_manual_pick_locked(state) is False
|
||||
|
||||
def test_deadline_passed_not_locked(self) -> None:
|
||||
state = _locked_state(started_at=_EXPIRED_AT)
|
||||
assert _is_manual_pick_locked(state) is False
|
||||
|
||||
def test_active_lock_returns_true(self) -> None:
|
||||
state = _locked_state(started_at=_STARTED_AT)
|
||||
assert _is_manual_pick_locked(state) is True
|
||||
|
||||
def test_no_started_at_stays_locked(self) -> None:
|
||||
# Missing timestamp → cannot determine deadline → stays locked.
|
||||
state = _locked_state(started_at="")
|
||||
assert _is_manual_pick_locked(state) is True
|
||||
|
||||
def test_invalid_started_at_stays_locked(self) -> None:
|
||||
state = _locked_state(started_at="not-a-date")
|
||||
assert _is_manual_pick_locked(state) is True
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _show_manual_pick_lock_message
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShowManualPickLockMessage:
|
||||
def test_shows_game_info(self) -> None:
|
||||
state = _locked_state(app_id=42, name="MyGame", started_at=_STARTED_AT)
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "MyGame" in output
|
||||
assert "42" in output
|
||||
|
||||
def test_shows_deadline_when_started_at_valid(self) -> None:
|
||||
state = _locked_state(started_at=_STARTED_AT)
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Deadline" in output
|
||||
|
||||
def test_no_crash_on_invalid_started_at(self) -> None:
|
||||
state = _locked_state(started_at="bad-date")
|
||||
with patch(f"{PKG}._echo"):
|
||||
_show_manual_pick_lock_message(state) # must not raise
|
||||
|
||||
def test_no_crash_on_empty_started_at(self) -> None:
|
||||
state = _locked_state(started_at="")
|
||||
with patch(f"{PKG}._echo"):
|
||||
_show_manual_pick_lock_message(state) # must not raise
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _enforce_manual_pick_lock
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEnforceManualPickLock:
|
||||
def test_no_lock_passes(self) -> None:
|
||||
_enforce_manual_pick_lock("scan", State()) # no exit
|
||||
|
||||
def test_exempt_command_passes_while_locked(self) -> None:
|
||||
state = _locked_state()
|
||||
_enforce_manual_pick_lock("done", state) # no exit
|
||||
_enforce_manual_pick_lock("status", state) # no exit
|
||||
|
||||
def test_blocked_command_exits(self) -> None:
|
||||
state = _locked_state()
|
||||
with (
|
||||
patch(f"{PKG}._show_manual_pick_lock_message"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
_enforce_manual_pick_lock("scan", state)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_add_exception_blocked_when_locked(self) -> None:
|
||||
state = _locked_state()
|
||||
with (
|
||||
patch(f"{PKG}._show_manual_pick_lock_message"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_enforce_manual_pick_lock("add-exception", state)
|
||||
|
||||
def test_pick_manual_allowed_when_already_locked(self) -> None:
|
||||
# A second pick must be reachable while the first holds the lock; the
|
||||
# cap inside cmd_pick_manual is what limits it, not the lock check.
|
||||
_enforce_manual_pick_lock("pick-manual", _locked_state())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _resolve_game_name
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestResolveGameName:
|
||||
def test_found_in_snapshot(self) -> None:
|
||||
snapshot = [
|
||||
{
|
||||
"app_id": 440,
|
||||
"name": "TF2",
|
||||
"total_achievements": 0,
|
||||
"unlocked_achievements": 0,
|
||||
"playtime_minutes": 0,
|
||||
}
|
||||
]
|
||||
with patch(f"{PKG}.load_snapshot", return_value=snapshot):
|
||||
result = _resolve_game_name(Config(), 440)
|
||||
assert result == "TF2"
|
||||
|
||||
def test_not_in_snapshot_found_via_api(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[]),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.return_value = [
|
||||
{"appid": 730, "name": "Counter-Strike 2"}
|
||||
]
|
||||
result = _resolve_game_name(Config(), 730)
|
||||
assert result == "Counter-Strike 2"
|
||||
|
||||
def test_api_raises_returns_none(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[]),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.side_effect = SteamAPIError("fail")
|
||||
result = _resolve_game_name(Config(), 999)
|
||||
assert result is None
|
||||
|
||||
def test_not_found_anywhere_returns_none(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 1, "name": "X"}]),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.return_value = [{"appid": 1}]
|
||||
result = _resolve_game_name(Config(), 999)
|
||||
assert result is None
|
||||
|
||||
def test_no_snapshot_falls_through_to_api(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=None),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.return_value = [
|
||||
{"appid": 440, "name": "TF2"}
|
||||
]
|
||||
result = _resolve_game_name(Config(), 440)
|
||||
assert result == "TF2"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_pick_manual
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdPickManual:
|
||||
def _base_patches(self) -> dict[str, object]:
|
||||
return {
|
||||
f"{PKG}._resolve_game_name": "Skyrim SE",
|
||||
f"{PKG}.uninstall_other_games": 2,
|
||||
f"{PKG}.is_game_installed": False,
|
||||
f"{PKG}.install_game": None,
|
||||
f"{PKG}.get_all_owned_app_ids": [1, 2, 489830],
|
||||
f"{PKG}.try_hide_other_games": (2, None),
|
||||
}
|
||||
|
||||
def test_invalid_app_id(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_pick_manual(Config(), State(), ["abc"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Error" in output
|
||||
|
||||
def test_game_not_found(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value=None),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick_manual(Config(), State(), ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "not found" in output
|
||||
|
||||
def test_aborted_when_not_yes(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch(f"{PKG}.State.save") as mock_save,
|
||||
):
|
||||
cmd_pick_manual(Config(), State(), ["489830"])
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_prompts_for_id_when_no_args(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", side_effect=["489830", "YES"]),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, [])
|
||||
assert state.current_app_id == 489830
|
||||
|
||||
def test_success_sets_state_and_runs_post_steps(self) -> None:
|
||||
state = State()
|
||||
config = Config(uninstall_other_games=True)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save") as mock_save,
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=2) as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 489830]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(1, None)) as mock_hide,
|
||||
):
|
||||
cmd_pick_manual(config, state, ["489830"])
|
||||
|
||||
assert [p["app_id"] for p in state.manual_picks] == [489830]
|
||||
assert state.manual_picks[0]["game_name"] == "Skyrim SE"
|
||||
assert state.manual_picks[0]["started_at"] != ""
|
||||
assert state.current_app_id == 489830
|
||||
mock_save.assert_called_once()
|
||||
mock_uninstall.assert_called_once_with({489830})
|
||||
mock_install.assert_called_once()
|
||||
mock_hide.assert_called_once()
|
||||
|
||||
def test_no_uninstall_when_config_off(self) -> None:
|
||||
state = State()
|
||||
config = Config(uninstall_other_games=False)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games") as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(config, state, ["489830"])
|
||||
mock_uninstall.assert_not_called()
|
||||
|
||||
def test_game_already_installed_skips_install(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_no_hide_when_no_owned_ids(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}.try_hide_other_games") as mock_hide,
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
mock_hide.assert_not_called()
|
||||
|
||||
def test_uninstall_returns_zero_no_echo(self) -> None:
|
||||
state = State()
|
||||
config = Config(uninstall_other_games=True)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(config, state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Uninstalled 0" not in output
|
||||
|
||||
def test_enforcement_started_at_set_when_empty(self) -> None:
|
||||
state = State(enforcement_started_at="")
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
assert state.enforcement_started_at != ""
|
||||
|
||||
def test_enforcement_started_at_not_overwritten(self) -> None:
|
||||
existing_ts = "2026-01-01T00:00:00+00:00"
|
||||
state = State(enforcement_started_at=existing_ts)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
assert state.enforcement_started_at == existing_ts
|
||||
|
||||
def test_hide_returns_zero_no_echo(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Library: hid" not in output
|
||||
|
||||
def test_unreachable_steam_reports_skip(self) -> None:
|
||||
# The pick itself must survive a Steam that cannot be driven: this
|
||||
# used to abort cmd_pick_manual with a traceback after the pick had
|
||||
# already been saved.
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(
|
||||
f"{PKG}.try_hide_other_games",
|
||||
return_value=(0, "update in progress"),
|
||||
),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "skipped (update in progress)" in output
|
||||
assert [p["app_id"] for p in state.manual_picks] == [489830]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to pick-manual
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMainDispatchPickManual:
|
||||
def test_dispatches_pick_manual(self) -> None:
|
||||
argv = ["prog", "pick-manual", "489830"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.cmd_pick_manual") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_pick_manual_dispatches_while_locked(self) -> None:
|
||||
# Reaching cmd_pick_manual is required for a second concurrent pick.
|
||||
argv = ["prog", "pick-manual", "730"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=_locked_state()),
|
||||
patch(f"{PKG}.cmd_pick_manual") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_status shows lock hint when locked
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdStatusLockHint:
|
||||
def test_shows_lock_hint_when_locked(self) -> None:
|
||||
state = _locked_state()
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "MANUAL PICK LOCK" in output
|
||||
|
||||
def test_no_lock_hint_when_not_locked(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "MANUAL PICK LOCK" not in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_abandon_pick (grace period escape hatch)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Inside the 4-day grace window / already past it.
|
||||
_IN_GRACE = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
_PAST_GRACE = (
|
||||
datetime.now(timezone.utc) - timedelta(days=_MANUAL_GRACE_DAYS + 1)
|
||||
).isoformat()
|
||||
|
||||
|
||||
def _abandonable_state(app_id: int = 100, started_at: str = _IN_GRACE) -> State:
|
||||
state = _locked_state(app_id=app_id, started_at=started_at)
|
||||
state.current_app_id = app_id
|
||||
state.current_game_name = state.manual_pick_game_name
|
||||
return state
|
||||
|
||||
|
||||
class TestCmdAbandonPick:
|
||||
def test_no_args_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit) as exc:
|
||||
cmd_abandon_pick(Config(), _abandonable_state(), [])
|
||||
assert exc.value.code == 1
|
||||
assert "Usage" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_non_numeric_app_id_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), _abandonable_state(), ["abc"])
|
||||
assert "must be a number" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_no_active_pick_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), State(), ["100"])
|
||||
assert "No manual pick" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_wrong_app_id_exits(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), _abandonable_state(app_id=100), ["999"])
|
||||
assert "not one of your manual picks" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
def test_expired_grace_exits_without_mutating(self) -> None:
|
||||
state = _abandonable_state(started_at=_PAST_GRACE)
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch.object(State, "save") as mock_save,
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert exc.value.code == 1
|
||||
assert "EXPIRED" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
mock_save.assert_not_called()
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100]
|
||||
|
||||
def test_expired_grace_with_bad_timestamp_still_exits(self) -> None:
|
||||
state = _abandonable_state(started_at="not-a-date")
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
|
||||
def test_aborted_when_not_yes(self) -> None:
|
||||
state = _abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch.object(State, "save") as mock_save,
|
||||
patch(f"{PKG}.uninstall_game") as mock_uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
mock_save.assert_not_called()
|
||||
mock_uninstall.assert_not_called()
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100]
|
||||
|
||||
def test_success_clears_lock_and_uninstalls(self) -> None:
|
||||
state = _abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert state.manual_picks == []
|
||||
assert state.current_app_id is None
|
||||
assert state.skipped_until["100"] != ""
|
||||
mock_uninstall.assert_called_once_with(100, "TestGame")
|
||||
assert "abandoned" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_skips_uninstall_when_not_installed(self) -> None:
|
||||
state = _abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.uninstall_game") as mock_uninstall,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
mock_uninstall.assert_not_called()
|
||||
|
||||
def test_warns_when_uninstall_fails(self) -> None:
|
||||
state = _abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.uninstall_game", return_value=False),
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert "Warning" in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_race_between_check_and_apply_exits(self) -> None:
|
||||
# The grace check passes, then the state-only core refuses: defensive
|
||||
# branch that must exit rather than report a false success.
|
||||
state = _abandonable_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}.abandon_manual_pick", return_value=False),
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["100"])
|
||||
assert exc.value.code == 1
|
||||
assert "grace period closed" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
|
||||
class TestAbandonPickLockInteraction:
|
||||
def test_abandon_pick_is_exempt_from_the_lock(self) -> None:
|
||||
# The escape hatch must survive the pre-dispatch lock check.
|
||||
_enforce_manual_pick_lock("abandon-pick", _locked_state())
|
||||
|
||||
def test_main_dispatches_abandon_pick_while_locked(self) -> None:
|
||||
argv = ["prog", "abandon-pick", "100"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=_abandonable_state()),
|
||||
patch(f"{PKG}.cmd_abandon_pick") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once()
|
||||
|
||||
def test_lock_message_advertises_abandon_within_grace(self) -> None:
|
||||
state = _abandonable_state(started_at=_IN_GRACE)
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "abandon-pick 100" in output
|
||||
assert "grace day(s) left" in output
|
||||
|
||||
def test_lock_message_hides_abandon_after_grace(self) -> None:
|
||||
state = _abandonable_state(started_at=_PAST_GRACE)
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "abandon-pick" not in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Two concurrent manual picks (cap, coexistence, per-pick abandon)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _two_pick_state() -> State:
|
||||
state = State(
|
||||
manual_picks=[
|
||||
{"app_id": 100, "game_name": "TestGame", "started_at": _IN_GRACE},
|
||||
{"app_id": 200, "game_name": "SecondGame", "started_at": _IN_GRACE},
|
||||
],
|
||||
)
|
||||
state.current_app_id = 200
|
||||
state.current_game_name = "SecondGame"
|
||||
return state
|
||||
|
||||
|
||||
class TestPickManualCap:
|
||||
def test_second_pick_is_added_not_replacing(self) -> None:
|
||||
state = _locked_state(app_id=100)
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100, 489830]
|
||||
|
||||
def test_third_pick_refused_at_cap(self) -> None:
|
||||
state = _two_pick_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
assert exc.value.code == 1
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "already have 2 manual pick(s)" in output
|
||||
assert "Already locked in (2/2)" in output
|
||||
assert len(state.manual_picks) == 2
|
||||
|
||||
def test_duplicate_pick_refused_by_core(self) -> None:
|
||||
# The cap has room, so this exercises the core's own refusal path.
|
||||
state = _locked_state(app_id=489830)
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
assert "already one of your manual picks" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
def test_cascade_keeps_every_allowed_game(self) -> None:
|
||||
# The regression this whole feature turns on: adding a second pick must
|
||||
# not uninstall or hide the first.
|
||||
state = _locked_state(app_id=100)
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0) as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 100, 489830]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(1, None)) as mock_hide,
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
mock_uninstall.assert_called_once_with({100, 489830})
|
||||
mock_hide.assert_called_once_with([1, 100, 489830], {100, 489830})
|
||||
|
||||
def test_installs_every_missing_allowed_game(self) -> None:
|
||||
state = _locked_state(app_id=100)
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
installed = {call.args[0] for call in mock_install.call_args_list}
|
||||
assert installed == {100, 489830}
|
||||
|
||||
|
||||
class TestAbandonOneOfTwoPicks:
|
||||
def test_other_pick_survives(self) -> None:
|
||||
state = _two_pick_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
):
|
||||
cmd_abandon_pick(Config(), state, ["200"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "stay locked in: TestGame (AppID=100)" in output
|
||||
assert "Still assigned: TestGame" in output
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100]
|
||||
assert state.current_app_id == 100
|
||||
|
||||
def test_wrong_id_lists_all_active_picks(self) -> None:
|
||||
state = _two_pick_state()
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit):
|
||||
cmd_abandon_pick(Config(), state, ["999"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TestGame (AppID=100)" in output
|
||||
assert "SecondGame (AppID=200)" in output
|
||||
|
||||
def test_lock_message_lists_both_picks(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(_two_pick_state())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "picked 2 game(s)" in output
|
||||
assert "abandon-pick 100" in output
|
||||
assert "abandon-pick 200" in output
|
||||
|
||||
def test_status_lists_both_picks(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), _two_pick_state())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Manual picks (2)" in output
|
||||
assert "undoable for" in output
|
||||
|
||||
def test_status_omits_undo_after_grace(self) -> None:
|
||||
state = _two_pick_state()
|
||||
state.manual_picks[0]["started_at"] = _PAST_GRACE
|
||||
state.manual_picks[1]["started_at"] = _PAST_GRACE
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), state)
|
||||
assert "undoable for" not in " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
@ -1,334 +0,0 @@
|
||||
"""Tests for main CLI module — part 5 (total gaming block lock + command)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
import sys
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._total_block import TotalBlockStatus
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_TOTAL_BLOCK_EXEMPT_COMMANDS,
|
||||
_enforce_total_block_lock,
|
||||
_show_total_block_lock_message,
|
||||
cmd_block_gaming,
|
||||
cmd_status,
|
||||
main,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main"
|
||||
|
||||
_ACTIVE_STATUS = TotalBlockStatus(
|
||||
active=True,
|
||||
started_at=datetime.now(timezone.utc) - timedelta(hours=1),
|
||||
until=datetime.now(timezone.utc) + timedelta(hours=23),
|
||||
days=1,
|
||||
days_remaining=0.96,
|
||||
)
|
||||
_INACTIVE_STATUS = TotalBlockStatus(
|
||||
active=False, started_at=None, until=None, days=0, days_remaining=0.0
|
||||
)
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _show_total_block_lock_message
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShowTotalBlockLockMessage:
|
||||
def test_shows_remaining_time(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_total_block_lock_message(_ACTIVE_STATUS)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
||||
assert "Days remaining" in output
|
||||
|
||||
def test_lists_exempt_commands(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_total_block_lock_message(_ACTIVE_STATUS)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "status" in output
|
||||
assert "enforce" in output
|
||||
|
||||
def test_no_crash_without_until(self) -> None:
|
||||
status = TotalBlockStatus(
|
||||
active=True, started_at=None, until=None, days=1, days_remaining=0.5
|
||||
)
|
||||
with patch(f"{PKG}._echo"):
|
||||
_show_total_block_lock_message(status) # must not raise
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _enforce_total_block_lock
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEnforceTotalBlockLock:
|
||||
def test_not_active_passes(self) -> None:
|
||||
with patch(f"{PKG}.is_total_block_active", return_value=False):
|
||||
_enforce_total_block_lock("scan") # no exit
|
||||
|
||||
def test_exempt_command_passes_while_active(self) -> None:
|
||||
with patch(f"{PKG}.is_total_block_active", return_value=True):
|
||||
_enforce_total_block_lock("status") # no exit
|
||||
_enforce_total_block_lock("enforce") # no exit
|
||||
|
||||
def test_blocked_command_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
_enforce_total_block_lock("scan")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_done_blocked_while_active(self) -> None:
|
||||
"""Stricter than the manual-pick lock: even 'done' is blocked."""
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_enforce_total_block_lock("done")
|
||||
|
||||
def test_add_exception_blocked_while_active(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_enforce_total_block_lock("add-exception")
|
||||
|
||||
def test_exempt_set_is_stricter_than_manual_pick(self) -> None:
|
||||
"""Pinned exactly: widening this set weakens the total block.
|
||||
|
||||
gaming-unblock is present because a playtime bind mount makes the
|
||||
total block's own `pacman -R steam` fail EBUSY - it has to stay
|
||||
reachable exactly when the two collide. gaming-reset is deliberately
|
||||
absent: it would shorten enforcement.
|
||||
"""
|
||||
assert (
|
||||
frozenset({"status", "enforce", "gaming-status", "gaming-unblock"})
|
||||
== _TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
)
|
||||
assert "gaming-reset" not in _TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_block_gaming
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdBlockGaming:
|
||||
def test_no_args_shows_usage(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo, pytest.raises(SystemExit) as exc_info:
|
||||
cmd_block_gaming([])
|
||||
assert exc_info.value.code == 1
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Usage" in output
|
||||
|
||||
def test_non_numeric_days(self) -> None:
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_block_gaming(["abc"])
|
||||
|
||||
def test_zero_days_rejected(self) -> None:
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_block_gaming(["0"])
|
||||
|
||||
def test_negative_days_rejected(self) -> None:
|
||||
with patch(f"{PKG}._echo"), pytest.raises(SystemExit):
|
||||
cmd_block_gaming(["-1"])
|
||||
|
||||
def test_aborted_when_not_yes(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch(f"{PKG}.start_total_block") as mock_start,
|
||||
):
|
||||
cmd_block_gaming(["14"])
|
||||
mock_start.assert_not_called()
|
||||
|
||||
def test_confirmed_starts_block(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}.start_total_block", return_value=True) as mock_start,
|
||||
):
|
||||
cmd_block_gaming(["14"])
|
||||
mock_start.assert_called_once_with(14)
|
||||
|
||||
def test_start_failure_exits_nonzero(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch(f"{PKG}.start_total_block", return_value=False),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
cmd_block_gaming(["14"])
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to block-gaming
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMainDispatchBlockGaming:
|
||||
def test_dispatches_block_gaming(self) -> None:
|
||||
argv = ["prog", "block-gaming", "14"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.cmd_block_gaming") as mock_cmd,
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once_with(["14"])
|
||||
|
||||
def test_blocked_when_already_active(self) -> None:
|
||||
argv = ["prog", "scan"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_status_allowed_when_active(self) -> None:
|
||||
# "status" is dispatched via the COMMANDS dict, which captures the
|
||||
# cmd_status function reference at import time - patching
|
||||
# main.cmd_status would not intercept it. Verify real behavior
|
||||
# (no SystemExit, real status output) instead.
|
||||
argv = ["prog", "status"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
main() # must not raise SystemExit
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Steam Backlog Enforcer" in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_status shows total block info
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCmdStatusTotalBlock:
|
||||
def test_shows_total_block_when_active(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_ACTIVE_STATUS),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
||||
assert "Days remaining" in output
|
||||
|
||||
def test_no_total_block_section_when_inactive(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_total_block_status", return_value=_INACTIVE_STATUS),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK" not in output
|
||||
|
||||
def test_active_without_until_skips_remaining_time(self) -> None:
|
||||
status = TotalBlockStatus(
|
||||
active=True, started_at=None, until=None, days=1, days_remaining=0.5
|
||||
)
|
||||
with (
|
||||
patch(f"{PKG}.get_total_block_status", return_value=status),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
||||
assert "Days remaining" not in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to the daily-gaming-budget commands
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestMainDispatchGamingCommands:
|
||||
def test_enforce_passes_its_tail_args_and_exits_with_the_code(self) -> None:
|
||||
"""`enforce` left COMMANDS because its signature cannot carry --demo."""
|
||||
argv = ["prog", "enforce", "--demo"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.cmd_enforce", return_value=0) as mock_cmd,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
assert mock_cmd.call_args.args[2] == ["--demo"]
|
||||
|
||||
def test_enforce_propagates_a_nonzero_exit(self) -> None:
|
||||
argv = ["prog", "enforce", "--bogus"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.cmd_enforce", return_value=2),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 2
|
||||
|
||||
def test_gaming_unblock_dispatches(self) -> None:
|
||||
argv = ["prog", "gaming-unblock"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=False),
|
||||
patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd,
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
main()
|
||||
assert exc_info.value.code == 0
|
||||
mock_cmd.assert_called_once_with([])
|
||||
|
||||
def test_gaming_unblock_reachable_during_a_total_block(self) -> None:
|
||||
"""A playtime mount makes the total block's own pacman -R fail EBUSY."""
|
||||
argv = ["prog", "gaming-unblock"]
|
||||
with (
|
||||
patch.object(sys, "argv", argv),
|
||||
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
|
||||
patch(f"{PKG}.State.load", return_value=State()),
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.cmd_gaming_unblock", return_value=0) as mock_cmd,
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
main()
|
||||
mock_cmd.assert_called_once_with([])
|
||||
98
steam_backlog_enforcer/tests/test_main_pick_cap.py
Normal file
98
steam_backlog_enforcer/tests/test_main_pick_cap.py
Normal file
@ -0,0 +1,98 @@
|
||||
"""Tests for the main CLI: pick-manual slot cap."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
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 (
|
||||
locked_state,
|
||||
two_pick_state,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.picks"
|
||||
|
||||
|
||||
class TestPickManualCap:
|
||||
def test_second_pick_is_added_not_replacing(self) -> None:
|
||||
state = locked_state(app_id=100)
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
assert [p["app_id"] for p in state.manual_picks] == [100, 489830]
|
||||
|
||||
def test_third_pick_refused_at_cap(self) -> None:
|
||||
state = two_pick_state()
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
pytest.raises(SystemExit) as exc,
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
assert exc.value.code == 1
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "already have 2 manual pick(s)" in output
|
||||
assert "Already locked in (2/2)" in output
|
||||
assert len(state.manual_picks) == 2
|
||||
|
||||
def test_duplicate_pick_refused_by_core(self) -> None:
|
||||
# The cap has room, so this exercises the core's own refusal path.
|
||||
state = locked_state(app_id=489830)
|
||||
with (
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
assert "already one of your manual picks" in " ".join(
|
||||
str(c) for c in mock_echo.call_args_list
|
||||
)
|
||||
|
||||
def test_cascade_keeps_every_allowed_game(self) -> None:
|
||||
# The regression this whole feature turns on: adding a second pick must
|
||||
# not uninstall or hide the first.
|
||||
state = locked_state(app_id=100)
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0) as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 100, 489830]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(1, None)) as mock_hide,
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
mock_uninstall.assert_called_once_with({100, 489830})
|
||||
mock_hide.assert_called_once_with([1, 100, 489830], {100, 489830})
|
||||
|
||||
def test_installs_every_missing_allowed_game(self) -> None:
|
||||
state = locked_state(app_id=100)
|
||||
with (
|
||||
patch(f"{PKG}._echo"),
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(max_manual_picks=2), state, ["489830"])
|
||||
installed = {call.args[0] for call in mock_install.call_args_list}
|
||||
assert installed == {100, 489830}
|
||||
220
steam_backlog_enforcer/tests/test_main_pick_manual.py
Normal file
220
steam_backlog_enforcer/tests/test_main_pick_manual.py
Normal file
@ -0,0 +1,220 @@
|
||||
"""Tests for the main CLI: pick-manual command."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_pick_manual,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.picks"
|
||||
|
||||
|
||||
class TestCmdPickManual:
|
||||
def _base_patches(self) -> dict[str, object]:
|
||||
return {
|
||||
f"{PKG}._resolve_game_name": "Skyrim SE",
|
||||
f"{PKG}.uninstall_other_games": 2,
|
||||
f"{PKG}.is_game_installed": False,
|
||||
f"{PKG}.install_game": None,
|
||||
f"{PKG}.get_all_owned_app_ids": [1, 2, 489830],
|
||||
f"{PKG}.try_hide_other_games": (2, None),
|
||||
}
|
||||
|
||||
def test_invalid_app_id(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
cmd_pick_manual(Config(), State(), ["abc"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Error" in output
|
||||
|
||||
def test_game_not_found(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value=None),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick_manual(Config(), State(), ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "not found" in output
|
||||
|
||||
def test_aborted_when_not_yes(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="no"),
|
||||
patch("steam_backlog_enforcer.config.State.save") as mock_save,
|
||||
):
|
||||
cmd_pick_manual(Config(), State(), ["489830"])
|
||||
mock_save.assert_not_called()
|
||||
|
||||
def test_prompts_for_id_when_no_args(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", side_effect=["489830", "YES"]),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, [])
|
||||
assert state.current_app_id == 489830
|
||||
|
||||
def test_success_sets_state_and_runs_post_steps(self) -> None:
|
||||
state = State()
|
||||
config = Config(uninstall_other_games=True)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save") as mock_save,
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=2) as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=False),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 489830]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(1, None)) as mock_hide,
|
||||
):
|
||||
cmd_pick_manual(config, state, ["489830"])
|
||||
|
||||
assert [p["app_id"] for p in state.manual_picks] == [489830]
|
||||
assert state.manual_picks[0]["game_name"] == "Skyrim SE"
|
||||
assert state.manual_picks[0]["started_at"] != ""
|
||||
assert state.current_app_id == 489830
|
||||
mock_save.assert_called_once()
|
||||
mock_uninstall.assert_called_once_with({489830})
|
||||
mock_install.assert_called_once()
|
||||
mock_hide.assert_called_once()
|
||||
|
||||
def test_no_uninstall_when_config_off(self) -> None:
|
||||
state = State()
|
||||
config = Config(uninstall_other_games=False)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games") as mock_uninstall,
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(config, state, ["489830"])
|
||||
mock_uninstall.assert_not_called()
|
||||
|
||||
def test_game_already_installed_skips_install(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.install_game") as mock_install,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
mock_install.assert_not_called()
|
||||
|
||||
def test_no_hide_when_no_owned_ids(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}.try_hide_other_games") as mock_hide,
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
mock_hide.assert_not_called()
|
||||
|
||||
def test_uninstall_returns_zero_no_echo(self) -> None:
|
||||
state = State()
|
||||
config = Config(uninstall_other_games=True)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(config, state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Uninstalled 0" not in output
|
||||
|
||||
def test_enforcement_started_at_set_when_empty(self) -> None:
|
||||
state = State(enforcement_started_at="")
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
assert state.enforcement_started_at != ""
|
||||
|
||||
def test_enforcement_started_at_not_overwritten(self) -> None:
|
||||
existing_ts = "2026-01-01T00:00:00+00:00"
|
||||
state = State(enforcement_started_at=existing_ts)
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo"),
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
assert state.enforcement_started_at == existing_ts
|
||||
|
||||
def test_hide_returns_zero_no_echo(self) -> None:
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Library: hid" not in output
|
||||
|
||||
def test_unreachable_steam_reports_skip(self) -> None:
|
||||
# The pick itself must survive a Steam that cannot be driven: this
|
||||
# used to abort cmd_pick_manual with a traceback after the pick had
|
||||
# already been saved.
|
||||
state = State()
|
||||
with (
|
||||
patch(f"{PKG}._resolve_game_name", return_value="Skyrim SE"),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
patch("builtins.input", return_value="YES"),
|
||||
patch.object(State, "save"),
|
||||
patch(f"{PKG}.uninstall_other_games", return_value=0),
|
||||
patch(f"{PKG}.is_game_installed", return_value=True),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
|
||||
patch(
|
||||
f"{PKG}.try_hide_other_games",
|
||||
return_value=(0, "update in progress"),
|
||||
),
|
||||
):
|
||||
cmd_pick_manual(Config(), state, ["489830"])
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "skipped (update in progress)" in output
|
||||
assert [p["app_id"] for p in state.manual_picks] == [489830]
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to pick-manual
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
193
steam_backlog_enforcer/tests/test_main_picks.py
Normal file
193
steam_backlog_enforcer/tests/test_main_picks.py
Normal file
@ -0,0 +1,193 @@
|
||||
"""Tests for the main CLI: pick and pick-manual commands."""
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_resolve_game_name,
|
||||
cmd_pick,
|
||||
)
|
||||
from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIError
|
||||
from steam_backlog_enforcer.tests._main_helpers import (
|
||||
snap,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.picks"
|
||||
|
||||
|
||||
class TestCmdPick:
|
||||
"""Tests for cmd_pick."""
|
||||
|
||||
def test_no_snapshot_prints_message(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
|
||||
mock_echo.assert_called_once_with("No snapshot found. Run 'scan' first.")
|
||||
|
||||
def test_calls_pick_next_game(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, 5.0)]
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
|
||||
patch(f"{PKG}.pick_next_game") as mock_pick,
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
config = Config(steam_api_key="k", steam_id="i")
|
||||
state = State()
|
||||
cmd_pick(config, state)
|
||||
mock_pick.assert_called_once()
|
||||
|
||||
def test_hides_games_after_pick(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(2, None)) as mock_hide,
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_hide.assert_called_once_with([1, 2, 3], {2})
|
||||
|
||||
def test_no_hide_message_when_none_hidden(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
patch(f"{PKG}.try_hide_other_games", return_value=(0, None)),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_echo.assert_not_called()
|
||||
|
||||
def test_unreachable_steam_reports_skip(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
|
||||
patch(
|
||||
f"{PKG}.try_hide_other_games",
|
||||
return_value=(0, "Steam is not installed"),
|
||||
),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "skipped (Steam is not installed)" in output
|
||||
|
||||
def test_no_hide_when_no_current_app(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, 5.0)]
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids") as mock_owned,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
|
||||
mock_owned.assert_not_called()
|
||||
|
||||
def test_no_hide_when_owned_ids_empty(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, 5.0)]
|
||||
state = State(current_app_id=2, current_game_name="NewGame")
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={}),
|
||||
patch(f"{PKG}.pick_next_game"),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
patch(f"{PKG}.try_hide_other_games") as mock_hide,
|
||||
):
|
||||
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
|
||||
mock_hide.assert_not_called()
|
||||
|
||||
def test_hltb_cache_applied_to_games(self) -> None:
|
||||
snapshot = [snap(2, "NewGame", 10, 0, -1.0)]
|
||||
captured_games: list[list[GameInfo]] = []
|
||||
config = Config(steam_api_key="k", steam_id="i")
|
||||
state = State()
|
||||
|
||||
def capture_pick(games: list[GameInfo], *_args: object) -> None:
|
||||
captured_games.append(list(games))
|
||||
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}.load_hltb_cache", return_value={2: 7.5}),
|
||||
patch(f"{PKG}.pick_next_game", side_effect=capture_pick),
|
||||
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
|
||||
):
|
||||
cmd_pick(config, state)
|
||||
|
||||
assert len(captured_games) == 1
|
||||
assert captured_games[0][0].completionist_hours == pytest.approx(7.5)
|
||||
|
||||
|
||||
class TestResolveGameName:
|
||||
def test_found_in_snapshot(self) -> None:
|
||||
snapshot = [
|
||||
{
|
||||
"app_id": 440,
|
||||
"name": "TF2",
|
||||
"total_achievements": 0,
|
||||
"unlocked_achievements": 0,
|
||||
"playtime_minutes": 0,
|
||||
}
|
||||
]
|
||||
with patch(f"{PKG}.load_snapshot", return_value=snapshot):
|
||||
result = _resolve_game_name(Config(), 440)
|
||||
assert result == "TF2"
|
||||
|
||||
def test_not_in_snapshot_found_via_api(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[]),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.return_value = [
|
||||
{"appid": 730, "name": "Counter-Strike 2"}
|
||||
]
|
||||
result = _resolve_game_name(Config(), 730)
|
||||
assert result == "Counter-Strike 2"
|
||||
|
||||
def test_api_raises_returns_none(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[]),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.side_effect = SteamAPIError("fail")
|
||||
result = _resolve_game_name(Config(), 999)
|
||||
assert result is None
|
||||
|
||||
def test_not_found_anywhere_returns_none(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 1, "name": "X"}]),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.return_value = [{"appid": 1}]
|
||||
result = _resolve_game_name(Config(), 999)
|
||||
assert result is None
|
||||
|
||||
def test_no_snapshot_falls_through_to_api(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=None),
|
||||
patch(f"{PKG}.SteamAPIClient") as mock_cls,
|
||||
):
|
||||
mock_cls.return_value.get_owned_games.return_value = [
|
||||
{"appid": 440, "name": "TF2"}
|
||||
]
|
||||
result = _resolve_game_name(Config(), 440)
|
||||
assert result == "TF2"
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_pick_manual
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
214
steam_backlog_enforcer/tests/test_main_shared.py
Normal file
214
steam_backlog_enforcer/tests/test_main_shared.py
Normal file
@ -0,0 +1,214 @@
|
||||
"""Tests for the main CLI: shared lock helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from steam_backlog_enforcer._total_block import TotalBlockStatus
|
||||
from steam_backlog_enforcer.config import State
|
||||
from steam_backlog_enforcer.main import (
|
||||
_TOTAL_BLOCK_EXEMPT_COMMANDS,
|
||||
_enforce_manual_pick_lock,
|
||||
_enforce_total_block_lock,
|
||||
_is_manual_pick_locked,
|
||||
_show_manual_pick_lock_message,
|
||||
_show_total_block_lock_message,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._main_helpers import (
|
||||
ACTIVE_STATUS,
|
||||
EXPIRED_AT,
|
||||
STARTED_AT,
|
||||
locked_state,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main._shared"
|
||||
|
||||
|
||||
class TestIsManualPickLocked:
|
||||
def test_no_manual_pick_not_locked(self) -> None:
|
||||
assert _is_manual_pick_locked(State()) is False
|
||||
|
||||
def test_game_finished_not_locked(self) -> None:
|
||||
state = locked_state(app_id=100)
|
||||
state.finished_app_ids = [100]
|
||||
assert _is_manual_pick_locked(state) is False
|
||||
|
||||
def test_deadline_passed_not_locked(self) -> None:
|
||||
state = locked_state(started_at=EXPIRED_AT)
|
||||
assert _is_manual_pick_locked(state) is False
|
||||
|
||||
def test_active_lock_returns_true(self) -> None:
|
||||
state = locked_state(started_at=STARTED_AT)
|
||||
assert _is_manual_pick_locked(state) is True
|
||||
|
||||
def test_no_started_at_stays_locked(self) -> None:
|
||||
# Missing timestamp → cannot determine deadline → stays locked.
|
||||
state = locked_state(started_at="")
|
||||
assert _is_manual_pick_locked(state) is True
|
||||
|
||||
def test_invalid_started_at_stays_locked(self) -> None:
|
||||
state = locked_state(started_at="not-a-date")
|
||||
assert _is_manual_pick_locked(state) is True
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _show_manual_pick_lock_message
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShowManualPickLockMessage:
|
||||
def test_shows_game_info(self) -> None:
|
||||
state = locked_state(app_id=42, name="MyGame", started_at=STARTED_AT)
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "MyGame" in output
|
||||
assert "42" in output
|
||||
|
||||
def test_shows_deadline_when_started_at_valid(self) -> None:
|
||||
state = locked_state(started_at=STARTED_AT)
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_manual_pick_lock_message(state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "Deadline" in output
|
||||
|
||||
def test_no_crash_on_invalid_started_at(self) -> None:
|
||||
state = locked_state(started_at="bad-date")
|
||||
with patch(f"{PKG}._echo"):
|
||||
_show_manual_pick_lock_message(state) # must not raise
|
||||
|
||||
def test_no_crash_on_empty_started_at(self) -> None:
|
||||
state = locked_state(started_at="")
|
||||
with patch(f"{PKG}._echo"):
|
||||
_show_manual_pick_lock_message(state) # must not raise
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _enforce_manual_pick_lock
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEnforceManualPickLock:
|
||||
def test_no_lock_passes(self) -> None:
|
||||
_enforce_manual_pick_lock("scan", State()) # no exit
|
||||
|
||||
def test_exempt_command_passes_while_locked(self) -> None:
|
||||
state = locked_state()
|
||||
_enforce_manual_pick_lock("done", state) # no exit
|
||||
_enforce_manual_pick_lock("status", state) # no exit
|
||||
|
||||
def test_blocked_command_exits(self) -> None:
|
||||
state = locked_state()
|
||||
with (
|
||||
patch(f"{PKG}._show_manual_pick_lock_message"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
_enforce_manual_pick_lock("scan", state)
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_add_exception_blocked_when_locked(self) -> None:
|
||||
state = locked_state()
|
||||
with (
|
||||
patch(f"{PKG}._show_manual_pick_lock_message"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_enforce_manual_pick_lock("add-exception", state)
|
||||
|
||||
def test_pick_manual_allowed_when_already_locked(self) -> None:
|
||||
# A second pick must be reachable while the first holds the lock; the
|
||||
# cap inside cmd_pick_manual is what limits it, not the lock check.
|
||||
_enforce_manual_pick_lock("pick-manual", locked_state())
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _resolve_game_name
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShowTotalBlockLockMessage:
|
||||
def test_shows_remaining_time(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_total_block_lock_message(ACTIVE_STATUS)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
||||
assert "Days remaining" in output
|
||||
|
||||
def test_lists_exempt_commands(self) -> None:
|
||||
with patch(f"{PKG}._echo") as mock_echo:
|
||||
_show_total_block_lock_message(ACTIVE_STATUS)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "status" in output
|
||||
assert "enforce" in output
|
||||
|
||||
def test_no_crash_without_until(self) -> None:
|
||||
status = TotalBlockStatus(
|
||||
active=True, started_at=None, until=None, days=1, days_remaining=0.5
|
||||
)
|
||||
with patch(f"{PKG}._echo"):
|
||||
_show_total_block_lock_message(status) # must not raise
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# _enforce_total_block_lock
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestEnforceTotalBlockLock:
|
||||
def test_not_active_passes(self) -> None:
|
||||
with patch(f"{PKG}.is_total_block_active", return_value=False):
|
||||
_enforce_total_block_lock("scan") # no exit
|
||||
|
||||
def test_exempt_command_passes_while_active(self) -> None:
|
||||
with patch(f"{PKG}.is_total_block_active", return_value=True):
|
||||
_enforce_total_block_lock("status") # no exit
|
||||
_enforce_total_block_lock("enforce") # no exit
|
||||
|
||||
def test_blocked_command_exits(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit) as exc_info,
|
||||
):
|
||||
_enforce_total_block_lock("scan")
|
||||
assert exc_info.value.code == 1
|
||||
|
||||
def test_done_blocked_while_active(self) -> None:
|
||||
"""Stricter than the manual-pick lock: even 'done' is blocked."""
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_enforce_total_block_lock("done")
|
||||
|
||||
def test_add_exception_blocked_while_active(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_total_block_active", return_value=True),
|
||||
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
||||
patch(f"{PKG}._show_total_block_lock_message"),
|
||||
pytest.raises(SystemExit),
|
||||
):
|
||||
_enforce_total_block_lock("add-exception")
|
||||
|
||||
def test_exempt_set_is_stricter_than_manual_pick(self) -> None:
|
||||
"""Pinned exactly: widening this set weakens the total block.
|
||||
|
||||
gaming-unblock is present because a playtime bind mount makes the
|
||||
total block's own `pacman -R steam` fail EBUSY - it has to stay
|
||||
reachable exactly when the two collide. gaming-reset is deliberately
|
||||
absent: it would shorten enforcement.
|
||||
"""
|
||||
assert (
|
||||
frozenset({"status", "enforce", "gaming-status", "gaming-unblock"})
|
||||
== _TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
)
|
||||
assert "gaming-reset" not in _TOTAL_BLOCK_EXEMPT_COMMANDS
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_block_gaming
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
163
steam_backlog_enforcer/tests/test_main_status.py
Normal file
163
steam_backlog_enforcer/tests/test_main_status.py
Normal file
@ -0,0 +1,163 @@
|
||||
"""Tests for the main CLI: status and list commands."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from unittest.mock import patch
|
||||
|
||||
from steam_backlog_enforcer._actions import MANUAL_GRACE_DAYS
|
||||
from steam_backlog_enforcer._total_block import TotalBlockStatus
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import (
|
||||
cmd_list,
|
||||
cmd_status,
|
||||
)
|
||||
from steam_backlog_enforcer.tests._main_helpers import (
|
||||
ACTIVE_STATUS,
|
||||
INACTIVE_STATUS,
|
||||
locked_state,
|
||||
snap,
|
||||
)
|
||||
|
||||
PKG = "steam_backlog_enforcer.main.status"
|
||||
|
||||
|
||||
class TestCmdStatus:
|
||||
"""Tests for cmd_status."""
|
||||
|
||||
def test_with_game(self) -> None:
|
||||
state = State(current_app_id=440, current_game_name="TF2")
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=True),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_status(Config(), state)
|
||||
|
||||
def test_no_game(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
|
||||
|
||||
class TestCmdList:
|
||||
"""Tests for cmd_list."""
|
||||
|
||||
def test_no_snapshot(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=None),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_list(Config(), State())
|
||||
assert any("No snapshot" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
def test_with_games(self) -> None:
|
||||
snapshot = [
|
||||
snap(1, "A", 10, 5, 20.0),
|
||||
snap(2, "B", 10, 10, 10.0),
|
||||
snap(3, "C", 10, 3, -1),
|
||||
]
|
||||
state = State(current_app_id=1)
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}._echo"),
|
||||
):
|
||||
cmd_list(Config(), state)
|
||||
|
||||
def test_many_games(self) -> None:
|
||||
snapshot = [snap(i, f"Game{i}") for i in range(60)]
|
||||
with (
|
||||
patch(f"{PKG}.load_snapshot", return_value=snapshot),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_list(Config(), State())
|
||||
assert any("more" in str(c) for c in mock_echo.call_args_list)
|
||||
|
||||
|
||||
class TestCmdStatusLockHint:
|
||||
def test_shows_lock_hint_when_locked(self) -> None:
|
||||
state = locked_state()
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), state)
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "MANUAL PICK LOCK" in output
|
||||
|
||||
def test_no_lock_hint_when_not_locked(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "MANUAL PICK LOCK" not in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# cmd_abandon_pick (grace period escape hatch)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
# Inside the 4-day grace window / already past it.
|
||||
_IN_GRACE = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
_PAST_GRACE = (
|
||||
datetime.now(timezone.utc) - timedelta(days=MANUAL_GRACE_DAYS + 1)
|
||||
).isoformat()
|
||||
|
||||
|
||||
def _abandonable_state(app_id: int = 100, started_at: str = _IN_GRACE) -> State:
|
||||
state = locked_state(app_id=app_id, started_at=started_at)
|
||||
state.current_app_id = app_id
|
||||
state.current_game_name = state.manual_pick_game_name
|
||||
return state
|
||||
|
||||
|
||||
class TestCmdStatusTotalBlock:
|
||||
def test_shows_total_block_when_active(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_total_block_status", return_value=ACTIVE_STATUS),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
||||
assert "Days remaining" in output
|
||||
|
||||
def test_no_total_block_section_when_inactive(self) -> None:
|
||||
with (
|
||||
patch(f"{PKG}.get_total_block_status", return_value=INACTIVE_STATUS),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK" not in output
|
||||
|
||||
def test_active_without_until_skips_remaining_time(self) -> None:
|
||||
status = TotalBlockStatus(
|
||||
active=True, started_at=None, until=None, days=1, days_remaining=0.5
|
||||
)
|
||||
with (
|
||||
patch(f"{PKG}.get_total_block_status", return_value=status),
|
||||
patch(f"{PKG}.is_store_blocked", return_value=False),
|
||||
patch(f"{PKG}.get_installed_games", return_value=[]),
|
||||
patch(f"{PKG}._echo") as mock_echo,
|
||||
):
|
||||
cmd_status(Config(), State())
|
||||
output = " ".join(str(c) for c in mock_echo.call_args_list)
|
||||
assert "TOTAL GAMING BLOCK ACTIVE" in output
|
||||
assert "Days remaining" not in output
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# main() dispatch to the daily-gaming-budget commands
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
@ -12,6 +12,7 @@ from unittest.mock import MagicMock, patch
|
||||
from steam_backlog_enforcer import main as main_mod
|
||||
from steam_backlog_enforcer._web_server import create_server, serve
|
||||
from steam_backlog_enforcer.config import Config, State
|
||||
from steam_backlog_enforcer.main import misc as misc_mod
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
@ -176,6 +177,9 @@ class TestCmdServe:
|
||||
"""Tests for the main.cmd_serve wiring."""
|
||||
|
||||
def test_invokes_serve(self) -> None:
|
||||
with patch.object(main_mod, "serve") as mock_serve:
|
||||
# Patch the module that *defines* cmd_serve, not the main package that
|
||||
# re-exports it: cmd_serve resolves ``serve`` from its own globals, so
|
||||
# patching the re-export would not bite.
|
||||
with patch.object(misc_mod, "serve") as mock_serve:
|
||||
main_mod.cmd_serve(Config(), State())
|
||||
mock_serve.assert_called_once()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user