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:
Krzysztof Rudnicki 2026-08-23 19:56:34 +02:00
parent 10c33690b9
commit 45ff4eb7c2
32 changed files with 3935 additions and 3389 deletions

File diff suppressed because it is too large Load Diff

View 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)

View 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()

View 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}")

View 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)

View 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")

View 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!")

View 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)

View 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)

View 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")

View 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

View File

@ -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])

View 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)

View 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)

View 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([])

View 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)

View 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

View 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."

View 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()

View 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
# ──────────────────────────────────────────────────────────────

View File

@ -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)

View 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)

View 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()

View File

@ -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)

View File

@ -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)

View File

@ -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([])

View 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}

View 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
# ──────────────────────────────────────────────────────────────

View 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
# ──────────────────────────────────────────────────────────────

View 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
# ──────────────────────────────────────────────────────────────

View 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
# ──────────────────────────────────────────────────────────────

View File

@ -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()