diff --git a/steam_backlog_enforcer/main.py b/steam_backlog_enforcer/main.py deleted file mode 100644 index c82cda3..0000000 --- a/steam_backlog_enforcer/main.py +++ /dev/null @@ -1,1076 +0,0 @@ -"""Main CLI for Steam Backlog Enforcer.""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -import difflib -import logging -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, - allowed_app_ids, - allowed_games, - apply_manual_pick, - can_abandon_manual_pick, - find_manual_pick, - manual_pick_grace_remaining, - manual_pick_slots_left, -) -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._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._enforce_loop import ( - get_all_owned_app_ids, -) -from steam_backlog_enforcer._hltb_types import load_hltb_cache -from steam_backlog_enforcer._stats import cmd_stats -from steam_backlog_enforcer._total_block import ( - TotalBlockStatus, - get_total_block_status, - is_total_block_active, - 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 ( - Config, - State, - interactive_setup, - load_snapshot, -) -from steam_backlog_enforcer.game_install import ( - _echo, - get_installed_games, - install_game, - is_game_installed, - is_protected_app, - uninstall_game, - uninstall_other_games, -) -from steam_backlog_enforcer.library_hider import ( - restart_steam, - try_hide_other_games, - unhide_all_games, -) -from steam_backlog_enforcer.scanning import ( - do_check, - do_scan, - pick_next_game, -) -from steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient, SteamAPIError -from steam_backlog_enforcer.store_blocker import ( - block_store, - is_store_blocked, - unblock_store, -) - -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__) - -_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) - - -# ────────────────────────────────────────────────────────────── -# CLI commands -# ────────────────────────────────────────────────────────────── - - -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") - - -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_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_setup(_config: Config, _state: State) -> None: - """Run interactive setup.""" - interactive_setup() - - -_MIN_ADD_EXCEPTION_ARGS = 3 -_ADD_EXCEPTION_USAGE = ( - 'Usage: add-exception --reason ""\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." -) - - -def cmd_add_exception(args: list[str]) -> None: - """Add a whitelist exception, active immediately. - - Usage: add-exception --reason "" - - 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_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!") - - -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 cmd_serve(_config: Config, _state: State) -> None: - """Start the interactive web UI server (read-only, localhost only).""" - serve() - - -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 ' 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) - - -_ABANDON_PICK_USAGE = ( - "Usage: abandon-pick \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 ' to choose one yourself.\n") - else: - _echo(f"\nStill assigned: {state.current_game_name}\n") - - -_BLOCK_GAMING_USAGE = ( - "Usage: block-gaming \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_block_gaming(args: list[str]) -> None: - """Start a total gaming block for a fixed number of days. - - Usage: block-gaming - - 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) - - -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, 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] = { - name: desc for name, (desc, _) in COMMANDS.items() -} | _EXTRA_COMMAND_DESCRIPTIONS - - -def _resolve_command(raw: 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(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 [args]\n") - _echo("Commands:") - for name, desc in _ALL_COMMANDS.items(): - _echo(f" {name:<14s} {desc}") - - -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) - - -if __name__ == "__main__": - main() diff --git a/steam_backlog_enforcer/main/__init__.py b/steam_backlog_enforcer/main/__init__.py new file mode 100644 index 0000000..136f544 --- /dev/null +++ b/steam_backlog_enforcer/main/__init__.py @@ -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, 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) diff --git a/steam_backlog_enforcer/main/__main__.py b/steam_backlog_enforcer/main/__main__.py new file mode 100644 index 0000000..106808c --- /dev/null +++ b/steam_backlog_enforcer/main/__main__.py @@ -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() diff --git a/steam_backlog_enforcer/main/_registry.py b/steam_backlog_enforcer/main/_registry.py new file mode 100644 index 0000000..74672a8 --- /dev/null +++ b/steam_backlog_enforcer/main/_registry.py @@ -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 [args]\n") + _echo("Commands:") + for name, desc in all_commands.items(): + _echo(f" {name:<14s} {desc}") diff --git a/steam_backlog_enforcer/main/_shared.py b/steam_backlog_enforcer/main/_shared.py new file mode 100644 index 0000000..40d285e --- /dev/null +++ b/steam_backlog_enforcer/main/_shared.py @@ -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) diff --git a/steam_backlog_enforcer/main/abandon.py b/steam_backlog_enforcer/main/abandon.py new file mode 100644 index 0000000..3ca1a6a --- /dev/null +++ b/steam_backlog_enforcer/main/abandon.py @@ -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 \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 ' to choose one yourself.\n") + else: + _echo(f"\nStill assigned: {state.current_game_name}\n") diff --git a/steam_backlog_enforcer/main/install.py b/steam_backlog_enforcer/main/install.py new file mode 100644 index 0000000..b834dbd --- /dev/null +++ b/steam_backlog_enforcer/main/install.py @@ -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!") diff --git a/steam_backlog_enforcer/main/misc.py b/steam_backlog_enforcer/main/misc.py new file mode 100644 index 0000000..d2ad495 --- /dev/null +++ b/steam_backlog_enforcer/main/misc.py @@ -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 --reason ""\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 \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 --reason "" + + 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 + + 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) diff --git a/steam_backlog_enforcer/main/picks.py b/steam_backlog_enforcer/main/picks.py new file mode 100644 index 0000000..f742aba --- /dev/null +++ b/steam_backlog_enforcer/main/picks.py @@ -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 ' 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) diff --git a/steam_backlog_enforcer/main/status.py b/steam_backlog_enforcer/main/status.py new file mode 100644 index 0000000..1867362 --- /dev/null +++ b/steam_backlog_enforcer/main/status.py @@ -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") diff --git a/steam_backlog_enforcer/tests/_main_helpers.py b/steam_backlog_enforcer/tests/_main_helpers.py new file mode 100644 index 0000000..1255e00 --- /dev/null +++ b/steam_backlog_enforcer/tests/_main_helpers.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_main.py b/steam_backlog_enforcer/tests/test_main.py deleted file mode 100644 index 8f2ce04..0000000 --- a/steam_backlog_enforcer/tests/test_main.py +++ /dev/null @@ -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]) diff --git a/steam_backlog_enforcer/tests/test_main_abandon.py b/steam_backlog_enforcer/tests/test_main_abandon.py new file mode 100644 index 0000000..a10fb1d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_abandon.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_dispatch.py b/steam_backlog_enforcer/tests/test_main_dispatch.py new file mode 100644 index 0000000..eee68f3 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_dispatch.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_dispatch_extra.py b/steam_backlog_enforcer/tests/test_main_dispatch_extra.py new file mode 100644 index 0000000..422a7fc --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_dispatch_extra.py @@ -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([]) diff --git a/steam_backlog_enforcer/tests/test_main_done.py b/steam_backlog_enforcer/tests/test_main_done.py new file mode 100644 index 0000000..7724057 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_done.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_entrypoint.py b/steam_backlog_enforcer/tests/test_main_entrypoint.py new file mode 100644 index 0000000..811781d --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_entrypoint.py @@ -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 diff --git a/steam_backlog_enforcer/tests/test_main_install.py b/steam_backlog_enforcer/tests/test_main_install.py new file mode 100644 index 0000000..10e1a57 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_install.py @@ -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." diff --git a/steam_backlog_enforcer/tests/test_main_misc.py b/steam_backlog_enforcer/tests/test_main_misc.py new file mode 100644 index 0000000..ef0a74c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_misc.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_main_misc_gating.py b/steam_backlog_enforcer/tests/test_main_misc_gating.py new file mode 100644 index 0000000..22edf4c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_misc_gating.py @@ -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 +# ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/test_main_part2.py b/steam_backlog_enforcer/tests/test_main_part2.py index 58a224b..1f1543d 100644 --- a/steam_backlog_enforcer/tests/test_main_part2.py +++ b/steam_backlog_enforcer/tests/test_main_part2.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_part2b.py b/steam_backlog_enforcer/tests/test_main_part2b.py new file mode 100644 index 0000000..5ea09b8 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_part2b.py @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_part2c.py b/steam_backlog_enforcer/tests/test_main_part2c.py new file mode 100644 index 0000000..8cca379 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_part2c.py @@ -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() diff --git a/steam_backlog_enforcer/tests/test_main_part3.py b/steam_backlog_enforcer/tests/test_main_part3.py deleted file mode 100644 index d5c2aeb..0000000 --- a/steam_backlog_enforcer/tests/test_main_part3.py +++ /dev/null @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_part4.py b/steam_backlog_enforcer/tests/test_main_part4.py deleted file mode 100644 index 22f73c6..0000000 --- a/steam_backlog_enforcer/tests/test_main_part4.py +++ /dev/null @@ -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) diff --git a/steam_backlog_enforcer/tests/test_main_part5.py b/steam_backlog_enforcer/tests/test_main_part5.py deleted file mode 100644 index 5692ba4..0000000 --- a/steam_backlog_enforcer/tests/test_main_part5.py +++ /dev/null @@ -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([]) diff --git a/steam_backlog_enforcer/tests/test_main_pick_cap.py b/steam_backlog_enforcer/tests/test_main_pick_cap.py new file mode 100644 index 0000000..d6a033e --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_pick_cap.py @@ -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} diff --git a/steam_backlog_enforcer/tests/test_main_pick_manual.py b/steam_backlog_enforcer/tests/test_main_pick_manual.py new file mode 100644 index 0000000..815f6b9 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_pick_manual.py @@ -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 +# ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/test_main_picks.py b/steam_backlog_enforcer/tests/test_main_picks.py new file mode 100644 index 0000000..035d80c --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_picks.py @@ -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 +# ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/test_main_shared.py b/steam_backlog_enforcer/tests/test_main_shared.py new file mode 100644 index 0000000..a255bf7 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_shared.py @@ -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 +# ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/test_main_status.py b/steam_backlog_enforcer/tests/test_main_status.py new file mode 100644 index 0000000..fe32178 --- /dev/null +++ b/steam_backlog_enforcer/tests/test_main_status.py @@ -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 +# ────────────────────────────────────────────────────────────── diff --git a/steam_backlog_enforcer/tests/test_web_server.py b/steam_backlog_enforcer/tests/test_web_server.py index fa02e60..04891a4 100644 --- a/steam_backlog_enforcer/tests/test_web_server.py +++ b/steam_backlog_enforcer/tests/test_web_server.py @@ -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()