From acfb1c48a046994231e4f40e7841a0738a4a910a Mon Sep 17 00:00:00 2001 From: Krzysztof kuhy Rudnicki Date: Thu, 28 May 2026 07:27:32 +0200 Subject: [PATCH] =?UTF-8?q?chore:=20remove=20steam=5Fbacklog=5Fenforcer=20?= =?UTF-8?q?=E2=80=94=20extracted=20to=20own=20repo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Moved to https://github.com/kuhyx/steam-backlog-enforcer with full git history, rewritten imports, standalone pyproject.toml, and CI. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 26 +- ...ove-steam-backlog-enforcer-2026-05-28.json | 25 + python_pkg/steam_backlog_enforcer/README.md | 55 -- python_pkg/steam_backlog_enforcer/__init__.py | 1 - .../steam_backlog_enforcer/_cmd_done.py | 328 -------- .../steam_backlog_enforcer/_enforce_loop.py | 329 -------- .../steam_backlog_enforcer/_hltb_detail.py | 354 --------- .../steam_backlog_enforcer/_hltb_search.py | 552 -------------- .../steam_backlog_enforcer/_hltb_types.py | 236 ------ .../_scanning_confidence.py | 252 ------- python_pkg/steam_backlog_enforcer/_stats.py | 350 --------- .../steam_backlog_enforcer/_whitelist.py | 347 --------- python_pkg/steam_backlog_enforcer/config.py | 180 ----- python_pkg/steam_backlog_enforcer/enforcer.py | 97 --- .../steam_backlog_enforcer/game_install.py | 431 ----------- python_pkg/steam_backlog_enforcer/hltb.py | 359 --------- python_pkg/steam_backlog_enforcer/install.sh | 41 - .../steam_backlog_enforcer/library_hider.py | 387 ---------- python_pkg/steam_backlog_enforcer/main.py | 443 ----------- python_pkg/steam_backlog_enforcer/protondb.py | 221 ------ python_pkg/steam_backlog_enforcer/run.sh | 7 - python_pkg/steam_backlog_enforcer/scanning.py | 521 ------------- .../steam-backlog-enforcer.service | 19 - .../steam_backlog_enforcer/steam_api.py | 281 ------- .../steam_backlog_enforcer/store_blocker.py | 431 ----------- .../steam_backlog_enforcer/tests/__init__.py | 0 .../steam_backlog_enforcer/tests/conftest.py | 155 ---- .../tests/test_cmd_done_part2.py | 87 --- .../tests/test_config.py | 264 ------- .../tests/test_enforce_loop.py | 373 ---------- .../tests/test_enforce_loop_part2.py | 195 ----- .../tests/test_enforcer.py | 201 ----- .../tests/test_game_install.py | 280 ------- .../tests/test_game_install_part2.py | 163 ---- .../tests/test_game_install_part3.py | 263 ------- .../steam_backlog_enforcer/tests/test_hltb.py | 443 ----------- .../tests/test_hltb_detail.py | 569 -------------- .../tests/test_hltb_part2.py | 366 --------- .../tests/test_hltb_part3.py | 45 -- .../tests/test_hltb_search.py | 289 -------- .../tests/test_hltb_search_part2.py | 325 -------- .../tests/test_library_hider.py | 425 ----------- .../tests/test_library_hider_part2.py | 194 ----- .../steam_backlog_enforcer/tests/test_main.py | 496 ------------- .../tests/test_main_part2.py | 424 ----------- .../tests/test_main_part3.py | 305 -------- .../tests/test_polls_tracking.py | 382 ---------- .../tests/test_polls_tracking_part2.py | 417 ----------- .../tests/test_protondb.py | 291 -------- .../tests/test_scanning.py | 446 ----------- .../tests/test_scanning_part2.py | 134 ---- .../tests/test_scanning_part3.py | 414 ----------- .../tests/test_scanning_part4.py | 328 -------- .../tests/test_stats.py | 701 ------------------ .../tests/test_steam_api.py | 333 --------- .../tests/test_store_blocker.py | 470 ------------ .../tests/test_store_blocker_part2.py | 200 ----- .../tests/test_whitelist.py | 496 ------------- 58 files changed, 32 insertions(+), 16715 deletions(-) create mode 100644 docs/superpowers/evidence/remove-steam-backlog-enforcer-2026-05-28.json delete mode 100644 python_pkg/steam_backlog_enforcer/README.md delete mode 100644 python_pkg/steam_backlog_enforcer/__init__.py delete mode 100644 python_pkg/steam_backlog_enforcer/_cmd_done.py delete mode 100644 python_pkg/steam_backlog_enforcer/_enforce_loop.py delete mode 100644 python_pkg/steam_backlog_enforcer/_hltb_detail.py delete mode 100644 python_pkg/steam_backlog_enforcer/_hltb_search.py delete mode 100644 python_pkg/steam_backlog_enforcer/_hltb_types.py delete mode 100644 python_pkg/steam_backlog_enforcer/_scanning_confidence.py delete mode 100644 python_pkg/steam_backlog_enforcer/_stats.py delete mode 100644 python_pkg/steam_backlog_enforcer/_whitelist.py delete mode 100644 python_pkg/steam_backlog_enforcer/config.py delete mode 100644 python_pkg/steam_backlog_enforcer/enforcer.py delete mode 100644 python_pkg/steam_backlog_enforcer/game_install.py delete mode 100644 python_pkg/steam_backlog_enforcer/hltb.py delete mode 100755 python_pkg/steam_backlog_enforcer/install.sh delete mode 100644 python_pkg/steam_backlog_enforcer/library_hider.py delete mode 100644 python_pkg/steam_backlog_enforcer/main.py delete mode 100644 python_pkg/steam_backlog_enforcer/protondb.py delete mode 100755 python_pkg/steam_backlog_enforcer/run.sh delete mode 100644 python_pkg/steam_backlog_enforcer/scanning.py delete mode 100644 python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service delete mode 100644 python_pkg/steam_backlog_enforcer/steam_api.py delete mode 100644 python_pkg/steam_backlog_enforcer/store_blocker.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/__init__.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/conftest.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_cmd_done_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_config.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_enforce_loop_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_enforcer.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_game_install.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_game_install_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_game_install_part3.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_hltb.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_hltb_part3.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_hltb_search_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_library_hider.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_main.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_main_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_main_part3.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_polls_tracking_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_protondb.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_scanning.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_scanning_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_scanning_part3.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_scanning_part4.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_stats.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_steam_api.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_store_blocker.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_store_blocker_part2.py delete mode 100644 python_pkg/steam_backlog_enforcer/tests/test_whitelist.py diff --git a/CLAUDE.md b/CLAUDE.md index 4356105..b1e65c2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -3,8 +3,11 @@ ## Project Overview A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed -components span personal productivity tools: Steam backlog management, alarm/shutdown scheduling, screen -locking, Linux system configuration, and Android phone focus enforcement. +components span personal productivity tools: alarm/shutdown scheduling, screen locking, Linux system +configuration, and Android phone focus enforcement. + +Steam backlog enforcer has been extracted to its own repo: +[`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer). Archived / unmaintained projects live in the sibling repository [`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive). @@ -28,17 +31,6 @@ Archived / unmaintained projects live in the sibling repository ### Python Packages (`python_pkg/`) -- **steam_backlog_enforcer/** — Steam game backlog enforcer with HLTB hour tracking, game library hider, - ProtonDB compatibility checks, and installation automation - - `main.py` — entry point and enforce loop - - `enforcer.py` — core enforcement logic - - `hltb.py` / `_hltb_search.py` / `_hltb_detail.py` — HowLongToBeat API integration - - `steam_api.py` — Steam library and install API - - `library_hider.py` — hides non-current games from Steam - - `protondb.py` — ProtonDB compatibility rating lookup - - `scanning.py` — scan and select next game - - `tests/` — pytest tests; requires real Steam state; `conftest.py` redirects all paths to tmp_path - - **screen_locker/** — Tkinter/systemd screen locker with workout tracking and sick-day management - `screen_lock.py` — main locker UI - `_early_bird.py` — early-bird workout check @@ -156,7 +148,6 @@ before committing. The `ai-evidence-contract` hook will reject commits without i python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100 # Run a single package -python -m pytest python_pkg/steam_backlog_enforcer/ --cov=python_pkg.steam_backlog_enforcer --cov-branch --cov-fail-under=100 python -m pytest python_pkg/screen_locker/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100 python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100 python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100 @@ -235,15 +226,12 @@ For every commit that touches `.py`, `.sh`, `.c`, `.go`, `.ts`, etc.: # Type aliases for test dicts (keeps mypy happy) Event = dict[str, Any] -# Mock external calls — never hit real APIs/filesystem/Steam -with patch("python_pkg.steam_backlog_enforcer.main.some_func") as mock: +# Mock external calls — never hit real APIs/filesystem +with patch("python_pkg.screen_locker.screen_lock.some_func") as mock: ... # Use PropertyMock for property exceptions type(mock_obj).property_name = PropertyMock(side_effect=TypeError()) - -# steam_backlog_enforcer: conftest.py redirects ALL paths to tmp_path -# Never assume real state.json or steamapps path is safe to touch in tests ``` ### Branch Coverage Tips diff --git a/docs/superpowers/evidence/remove-steam-backlog-enforcer-2026-05-28.json b/docs/superpowers/evidence/remove-steam-backlog-enforcer-2026-05-28.json new file mode 100644 index 0000000..ade74cc --- /dev/null +++ b/docs/superpowers/evidence/remove-steam-backlog-enforcer-2026-05-28.json @@ -0,0 +1,25 @@ +{ + "intent": "Remove steam_backlog_enforcer from monorepo after extracting it to its own GitHub repo.", + "scope": [ + "python_pkg/steam_backlog_enforcer/ — deleted", + "CLAUDE.md — removed steam_backlog_enforcer documentation sections", + "No other packages affected" + ], + "changes": [ + "Deleted python_pkg/steam_backlog_enforcer/ entirely (extracted to github.com/kuhyx/steam-backlog-enforcer)", + "Updated CLAUDE.md: removed steam_backlog_enforcer architecture section, removed its test command, updated project overview and test patterns" + ], + "verification": [ + { + "command": "python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100 -q", + "result": "pass", + "evidence": "902 passed, 100% branch coverage across all remaining packages" + } + ], + "risks": [ + "Any local scripts pointing to python_pkg/steam_backlog_enforcer/ will break — use the standalone repo instead" + ], + "rollback": [ + "Clone github.com/kuhyx/steam-backlog-enforcer and place it back under python_pkg/ with original imports (python_pkg.steam_backlog_enforcer.*)" + ] +} diff --git a/python_pkg/steam_backlog_enforcer/README.md b/python_pkg/steam_backlog_enforcer/README.md deleted file mode 100644 index 2ed4598..0000000 --- a/python_pkg/steam_backlog_enforcer/README.md +++ /dev/null @@ -1,55 +0,0 @@ -# Steam Backlog Enforcer - -Forces you to 100% complete one Steam game at a time before moving on. - -## Features - -- **Achievement tracking**: Picks the next game by shortest HLTB completionist time -- **Store blocking**: Blocks `store.steampowered.com` via `/etc/hosts` -- **Game uninstalling**: Removes all installed games except the assigned one -- **Process enforcement**: Kills unauthorized game processes -- **Tampering detection**: Detects achievement unlocks on non-assigned games -- **HLTB integration**: Estimates completion time with persistent cache - -## Setup - -```bash -python -m python_pkg.steam_backlog_enforcer.main setup -``` - -## Commands - -| Command | Description | -| ----------- | ------------------------------------------ | -| `scan` | Scan library, fetch HLTB data, assign game | -| `check` | Check if assigned game is complete | -| `status` | Show current assignment and blocking | -| `list` | List incomplete games from snapshot | -| `skip` | Skip the currently assigned game | -| `enforce` | Run enforcer (block, uninstall, kill) | -| `unblock` | Remove store blocking | -| `reset` | Reset all state | -| `installed` | List currently installed Steam games | -| `uninstall` | Interactively uninstall non-assigned games | -| `setup` | First-time configuration | - -## Enforce mode - -```bash -sudo python -m python_pkg.steam_backlog_enforcer.main enforce -``` - -This will: - -1. Block Steam store in `/etc/hosts` -2. Uninstall all games except the assigned one -3. Continuously kill any unauthorized game processes - -## Game Uninstall - -Directly removes appmanifest files and game directories from `~/.local/share/Steam/steamapps/`. -Preserves Proton versions and Steam Linux Runtime. - -```bash -python -m python_pkg.steam_backlog_enforcer.main uninstall -``` diff --git a/python_pkg/steam_backlog_enforcer/__init__.py b/python_pkg/steam_backlog_enforcer/__init__.py deleted file mode 100644 index 57c2864..0000000 --- a/python_pkg/steam_backlog_enforcer/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Steam Backlog Enforcer - forces you to finish your Steam games.""" diff --git a/python_pkg/steam_backlog_enforcer/_cmd_done.py b/python_pkg/steam_backlog_enforcer/_cmd_done.py deleted file mode 100644 index 834eb41..0000000 --- a/python_pkg/steam_backlog_enforcer/_cmd_done.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Done-flow helpers and cmd_done command for Steam Backlog Enforcer.""" - -from __future__ import annotations - -import logging -import sys - -from python_pkg.steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids -from python_pkg.steam_backlog_enforcer.config import Config, State, load_snapshot -from python_pkg.steam_backlog_enforcer.enforcer import ( - enforce_allowed_game, - send_notification, -) -from python_pkg.steam_backlog_enforcer.game_install import ( - _echo, - install_game, - is_game_installed, - uninstall_other_games, -) -from python_pkg.steam_backlog_enforcer.hltb import ( - fetch_hltb_confidence_cached, - fetch_hltb_times_cached, - load_hltb_cache, - load_hltb_polls_cache, - save_hltb_cache, -) -from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games -from python_pkg.steam_backlog_enforcer.scanning import pick_next_game -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient - -_REASSIGN_REFRESH_LIMIT = 50 -_SKIP_DAYS = 7 -logger = logging.getLogger(__name__) - - -def _prompt_keep_or_skip(game: GameInfo) -> bool: - """Ask the user whether to keep the freshly-picked ``game``. - - Returns ``True`` to accept the pick, ``False`` to skip it (which the - caller will translate into a 7-day skip entry on ``State``). When - stdin is not a TTY (e.g. background daemon, piped invocation), the - pick is accepted silently to preserve the legacy non-interactive - behaviour. - """ - if not sys.stdin.isatty(): - return True - hours_str = "" - if game.completionist_hours > 0: - hours_str = f" (~{game.completionist_hours:.1f}h leisure+dlc)" - _echo(f"\n Next pick: {game.name} (AppID={game.app_id}){hours_str}") - while True: - try: - answer = ( - input(f" Keep this game? [Y/n] (n = skip for {_SKIP_DAYS} days): ") - .strip() - .lower() - ) - except EOFError: - return True - if answer in {"", "y", "yes"}: - return True - if answer in {"n", "no"}: - return False - _echo(" Please answer 'y' or 'n'.") - - -def _backfill_polls_for_finished( - state: State, - extra_app_id: int | None = None, -) -> dict[int, int]: - """Lazily fetch poll counts for already-finished games missing them. - - If ``extra_app_id`` is provided and its poll count is missing, it is - refreshed alongside finished games (used to populate polls for the - currently-assigned game on first run after the schema upgrade). - """ - polls_cache = load_hltb_polls_cache() - snapshot_data = load_snapshot() or [] - name_by_id = {d["app_id"]: d["name"] for d in snapshot_data} - candidate_ids = list(state.finished_app_ids) - if extra_app_id is not None and polls_cache.get(extra_app_id, 0) == 0: - candidate_ids.append(extra_app_id) - missing = [ - (aid, name_by_id[aid]) - for aid in candidate_ids - if aid in name_by_id and polls_cache.get(aid, 0) == 0 - ] - if not missing: - return polls_cache - - _echo(f" Backfilling HLTB poll counts for {len(missing)} game(s)...") - cache = load_hltb_cache() - preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache} - for aid, _name in missing: - cache.pop(aid, None) - save_hltb_cache(cache, polls_cache) - - fetch_hltb_confidence_cached(missing) - - refreshed_hours = load_hltb_cache() - refreshed_polls = load_hltb_polls_cache() - for aid, prior_hours in preserved_hours.items(): - if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0: - refreshed_hours[aid] = prior_hours - save_hltb_cache(refreshed_hours, refreshed_polls) - return refreshed_polls - - -def _report_assigned_confidence( - app_id: int, - state: State, -) -> None: - """Print HLTB poll-count confidence for the currently-assigned game.""" - polls_cache = _backfill_polls_for_finished(state, extra_app_id=app_id) - chosen_polls = polls_cache.get(app_id, 0) - - finished_polls = [ - (polls_cache[aid], aid) - for aid in state.finished_app_ids - if polls_cache.get(aid, 0) > 0 and aid != app_id - ] - snapshot_data = load_snapshot() or [] - name_by_id = {d["app_id"]: d["name"] for d in snapshot_data} - - warning = "" - if finished_polls: - min_polls = min(p for p, _ in finished_polls) - if 0 < chosen_polls < min_polls: - warning = " ⚠ NEW LOW — estimate may be unreliable" - elif chosen_polls == 0: - warning = " ⚠ no polls recorded — estimate may be unreliable" - elif chosen_polls == 0: - warning = " ⚠ no polls recorded — estimate may be unreliable" - - _echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}") - if finished_polls: - min_polls, min_aid = min(finished_polls) - min_name = name_by_id.get(min_aid, f"AppID={min_aid}") - _echo(f" Historical min among finished: {min_polls} ({min_name})") - - -def _apply_cached_hours_to_games( - games: list[GameInfo], - hltb_cache: dict[int, float], -) -> None: - """Overlay cached HLTB hours onto games (including cached misses).""" - for game in games: - if game.app_id in hltb_cache: - game.completionist_hours = hltb_cache[game.app_id] - - -def _refresh_uncached_shortlist_hours( - games: list[GameInfo], - hltb_cache: dict[int, float], - skip: set[int], - *, - upper_bound_hours: float | None = None, -) -> None: - """Refresh likely-short uncached games to avoid stale snapshot decisions.""" - shorter_uncached = [ - (g.app_id, g.name) - for g in sorted( - ( - game - for game in games - if not game.is_complete - and game.app_id not in skip - and game.completionist_hours > 0 - and game.app_id not in hltb_cache - and ( - upper_bound_hours is None - or game.completionist_hours < upper_bound_hours - ) - ), - key=lambda game: game.completionist_hours, - )[:_REASSIGN_REFRESH_LIMIT] - ] - if shorter_uncached: - refreshed = fetch_hltb_times_cached(shorter_uncached) - hltb_cache.update(refreshed) - - -def _finalize_completion( - config: Config, - state: State, - game_name: str, - app_id: int, -) -> None: - """Mark game complete, pick next, hide non-assigned games, notify.""" - _echo(f"\n COMPLETED: {game_name}!") - state.finished_app_ids.append(app_id) - - snapshot_data = load_snapshot() - _echo("\nPicking next game...") - if not snapshot_data: - _echo(" No snapshot found. Run 'scan' first.") - state.current_app_id = None - state.current_game_name = "" - state.save() - return - - games = [GameInfo.from_snapshot(d) for d in snapshot_data] - hltb_cache = load_hltb_cache() - skip = set(state.finished_app_ids) | state.active_skipped_ids() - _refresh_uncached_shortlist_hours(games, hltb_cache, skip) - _apply_cached_hours_to_games(games, hltb_cache) - pick_next_game(games, state, config, on_select=_prompt_keep_or_skip) - - if state.current_app_id is None: - _echo(" No more games to assign!") - return - - owned_ids = get_all_owned_app_ids(config) - if owned_ids: - hidden = hide_other_games(owned_ids, state.current_app_id) - if hidden > 0: - _echo(f"\n Library: hid {hidden} games") - - if not is_game_installed(state.current_app_id): - logger.info( - "Assigned game still missing after library reconciliation; " - "re-triggering install" - ) - _echo( - "\n Assigned game still missing after library reconciliation; " - "re-triggering install..." - ) - _echo(f"\n Auto-installing {state.current_game_name}...") - install_game( - state.current_app_id, - state.current_game_name, - config.steam_id, - use_steam_protocol=True, - ) - - send_notification( - "Game Complete!", - f"Finished {game_name}! Now playing: {state.current_game_name}", - ) - _echo(f"\nAll done! Go play {state.current_game_name}!") - - -def _enforce_on_done(config: Config, state: State) -> None: - """Run a single enforcement pass during the 'done' command. - - Kills unauthorized game processes, uninstalls unauthorized games, - and ensures the assigned game is installed. - """ - if state.current_app_id is None: - return - - if config.kill_unauthorized_games: - violations = enforce_allowed_game( - state.current_app_id, - kill_unauthorized=True, - ) - for pid, app_id in violations: - _echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})") - - if config.uninstall_other_games: - count = uninstall_other_games(state.current_app_id) - if count: - _echo(f" Uninstalled {count} unauthorized game(s)") - - if not is_game_installed(state.current_app_id): - _echo(f" Re-installing {state.current_game_name}...") - install_game( - state.current_app_id, - state.current_game_name, - config.steam_id, - use_steam_protocol=True, - ) - - # Reconcile library: hide non-assigned games and unhide the assigned one. - # Without this, an interrupted earlier completion can leave the new - # assigned game hidden and stale games visible. - owned_ids = get_all_owned_app_ids(config) - if owned_ids: - hidden = hide_other_games(owned_ids, state.current_app_id) - if hidden > 0: - _echo(f" Library: hid {hidden} games") - - -def cmd_done(config: Config, state: State) -> None: - """Check completion, pick next game, uninstall & hide. - - All-in-one command for after finishing a game: - 1. Verify 100% achievements on Steam. - 2. Pick the next game (shortest HLTB leisure+dlc time). - 3. Uninstall all non-assigned games. - 4. Hide all non-assigned games in the Steam library. - 5. Install the newly assigned game. - """ - if state.current_app_id is None: - _echo("No game currently assigned. Run 'scan' first.") - return - - client = SteamAPIClient(config.steam_api_key, config.steam_id) - game_name = state.current_game_name - app_id = state.current_app_id - - _echo(f"Checking {game_name} (AppID={app_id})...") - game = client.refresh_single_game(app_id, game_name) - if game is None: - _echo(" Could not fetch achievement data from Steam.") - return - - _echo( - f" Progress: {game.unlocked_achievements}/{game.total_achievements}" - f" ({game.completion_pct:.1f}%)" - ) - - hltb_cache = load_hltb_cache() - hours = hltb_cache.get(app_id, -1.0) - if hours < 0: - hltb_cache = fetch_hltb_times_cached([(app_id, game_name)]) - hours = hltb_cache.get(app_id, -1.0) - if hours > 0: - _echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours") - _report_assigned_confidence(app_id, state) - - if not game.is_complete: - remaining = game.total_achievements - game.unlocked_achievements - _echo(f"\n NOT COMPLETE: {remaining} achievements remaining. Keep going!") - _enforce_on_done(config, state) - return - - _finalize_completion(config, state, game_name, app_id) diff --git a/python_pkg/steam_backlog_enforcer/_enforce_loop.py b/python_pkg/steam_backlog_enforcer/_enforce_loop.py deleted file mode 100644 index af7b4dd..0000000 --- a/python_pkg/steam_backlog_enforcer/_enforce_loop.py +++ /dev/null @@ -1,329 +0,0 @@ -"""Enforcement daemon loop and related helpers.""" - -from __future__ import annotations - -import json -import logging -import time -from typing import Any - -from python_pkg.steam_backlog_enforcer._whitelist import ( - lock_enforcement_files, - promote_pending_exceptions, -) -from python_pkg.steam_backlog_enforcer.config import ( - CONFIG_DIR, - CONFIG_FILE, - Config, - State, - _atomic_write, - load_snapshot, -) -from python_pkg.steam_backlog_enforcer.enforcer import ( - enforce_allowed_game, - send_notification, -) -from python_pkg.steam_backlog_enforcer.game_install import ( - _echo, - get_installed_games, - install_game, - is_game_installed, - is_protected_app, - uninstall_game, - uninstall_other_games, -) -from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games -from python_pkg.steam_backlog_enforcer.steam_api import SteamAPIClient -from python_pkg.steam_backlog_enforcer.store_blocker import block_store - -logger = logging.getLogger(__name__) -_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json" -_OWNED_IDS_CACHE_TTL_SECONDS = 3600 - - -def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None: - """Return fresh cached owned app IDs for this steam_id, if available.""" - if not steam_id or not _OWNED_IDS_CACHE_FILE.exists(): - return None - - try: - data: dict[str, Any] = json.loads( - _OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8") - ) - except (json.JSONDecodeError, OSError, ValueError): - return None - - cached_steam_id = str(data.get("steam_id", "")) - if cached_steam_id != steam_id: - return None - - fetched_at = float(data.get("fetched_at", 0.0) or 0.0) - age = time.time() - fetched_at - if age > _OWNED_IDS_CACHE_TTL_SECONDS: - return None - - raw_ids = data.get("app_ids", []) - if not isinstance(raw_ids, list): - return None - - return [int(app_id) for app_id in raw_ids] - - -def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None: - """Persist owned app IDs cache for this steam_id.""" - payload = { - "steam_id": steam_id, - "fetched_at": time.time(), - "app_ids": app_ids, - } - _atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n") - - -# ────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────── - - -def get_all_owned_app_ids(config: Config) -> list[int]: - """Get all owned game app IDs from Steam API plus snapshot fallback. - - Snapshot data contains only games with achievements, so API data is the - primary source for library hiding. Snapshot IDs are merged in to keep - behavior resilient when the API result is partial. - """ - snapshot = load_snapshot() or [] - snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d] - cached_ids = _load_owned_app_ids_cache(config.steam_id) - - if cached_ids is not None: - merged_ids: list[int] = [] - seen: set[int] = set() - for app_id in [*cached_ids, *snapshot_ids]: - if app_id in seen: - continue - seen.add(app_id) - merged_ids.append(app_id) - logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids)) - return merged_ids - - try: - client = SteamAPIClient(config.steam_api_key, config.steam_id) - owned = client.get_owned_games() - api_ids = [int(g["appid"]) for g in owned if "appid" in g] - _save_owned_app_ids_cache(config.steam_id, api_ids) - - merged_ids: list[int] = [] - seen: set[int] = set() - for app_id in [*api_ids, *snapshot_ids]: - if app_id in seen: - continue - seen.add(app_id) - merged_ids.append(app_id) - except (OSError, RuntimeError, ValueError): - if snapshot_ids: - return snapshot_ids - logger.warning("Could not fetch owned game list for hiding.") - return [] - else: - return merged_ids - - -# ────────────────────────────────────────────────────────────── -# Enforce mode (daemon loop) -# ────────────────────────────────────────────────────────────── - -# How often the enforce loop runs (seconds). -ENFORCE_INTERVAL = 3 - - -def _guard_installed_games(allowed_app_id: int | None) -> int: - """Remove any unauthorized game manifests + files. Runs every loop. - - Returns number of games removed this pass. - """ - if allowed_app_id is None: - return 0 - installed = get_installed_games() - count = 0 - for app_id, name in installed: - if app_id == allowed_app_id: - continue - if is_protected_app(app_id): - continue - - logger.warning( - "Unauthorized game detected — removing: %s (AppID=%d)", name, app_id - ) - if uninstall_game(app_id, name): - count += 1 - send_notification( - "Game Removed!", - f"Uninstalled {name} (AppID={app_id}). " - f"Only the assigned game is allowed.", - ) - return count - - -def _enforce_setup(config: Config, state: State) -> None: - """Perform initial setup for enforcement mode. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - # Initial store block. - if config.block_store: - if block_store(): - _echo(" Steam store: BLOCKED") - else: - _echo(" Steam store: FAILED (need sudo?)") - - # Initial cleanup. - if config.uninstall_other_games: - _echo(" Uninstalling non-assigned games...") - count = uninstall_other_games(state.current_app_id) - _echo(f" Uninstalled {count} games") - - # Auto-install the assigned game. - _enforce_auto_install(config, state) - - # Hide all other games in the Steam library. - _enforce_hide_games(config, state) - - -def _enforce_auto_install(config: Config, state: State) -> None: - """Auto-install the assigned game if not already installed. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - app_id = state.current_app_id - if app_id is None: - return - if not is_game_installed(app_id): - _echo(f" Auto-installing {state.current_game_name}...") - if install_game( - app_id, - state.current_game_name, - config.steam_id, - use_steam_protocol=True, - ): - send_notification( - "Game Installing", - f"{state.current_game_name} is being downloaded.", - ) - else: - _echo(" Could not auto-install. Install manually from Steam.") - else: - _echo(f" Assigned game already installed: {state.current_game_name}") - - -def _enforce_hide_games(config: Config, state: State) -> None: - """Hide non-assigned games in the Steam library. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - owned_ids = get_all_owned_app_ids(config) - if owned_ids: - hidden = hide_other_games(owned_ids, state.current_app_id) - if hidden > 0: - _echo(f" Library: hid {hidden} games (only assigned game visible)") - else: - _echo(" Library: games already hidden") - else: - _echo(" Library hiding: skipped (no owned game list — run 'scan' first)") - - -def _enforce_loop_iteration(config: Config, state: State) -> None: - """Perform one iteration of the enforcement loop. - - Args: - config: Enforcer configuration. - state: Current enforcer state. - """ - if state.current_app_id is None: - return - - # A) Kill unauthorized game processes. - if config.kill_unauthorized_games: - violations = enforce_allowed_game( - state.current_app_id, - kill_unauthorized=True, - ) - for pid, app_id in violations: - _echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})") - send_notification( - "Game Blocked!", - f"Killed unauthorized game (AppID={app_id}). " - f"Focus on {state.current_game_name}!", - ) - - # B) Remove any newly-installed unauthorized games. - if config.uninstall_other_games: - removed = _guard_installed_games(state.current_app_id) - if removed > 0: - _echo(f" Guard removed {removed} unauthorized game(s)") - - # C) Re-install assigned game if it was somehow removed. - app_id = state.current_app_id - if app_id is not None and not is_game_installed(app_id): - logger.info( - "Assigned game disappeared — re-installing %s", - state.current_game_name, - ) - install_game( - app_id, - state.current_game_name, - config.steam_id, - ) - - # D) Promote any cooldown-elapsed pending exceptions to approved. - newly_approved = promote_pending_exceptions() - for aid in newly_approved: - logger.info("Exception approved: AppID=%d", aid) - - # E) Re-apply immutable flag so config cannot be edited without root. - lock_enforcement_files(CONFIG_FILE) - - -def do_enforce(config: Config, state: State) -> None: - """Run the enforcer: block store, uninstall other games, kill processes. - - This is a persistent loop that continuously: - 1. Keeps the Steam store blocked. - 2. Removes any newly-installed unauthorized games. - 3. Auto-installs the assigned game if missing. - 4. Kills any running unauthorized game processes. - """ - if state.current_app_id is None: - _echo("No game assigned. Run 'scan' first.") - return - - _echo(f"Enforcing: {state.current_game_name} (AppID={state.current_app_id})") - _enforce_setup(config, state) - - _echo(f" Enforce loop: ACTIVE (every {ENFORCE_INTERVAL}s)") - _echo(" Guarding: processes + installs + store") - _echo(" Press Ctrl+C to stop.\n") - try: - while True: - # Reload state from disk so CLI changes (e.g. new game - # assignment via ``done`` / ``scan``) take effect immediately - # without needing to restart the daemon. - try: - fresh = State.load() - except (json.JSONDecodeError, OSError, ValueError) as exc: - logger.warning("Failed to reload state: %s", exc) - time.sleep(ENFORCE_INTERVAL) - continue - state.current_app_id = fresh.current_app_id - state.current_game_name = fresh.current_game_name - state.finished_app_ids = fresh.finished_app_ids - - _enforce_loop_iteration(config, state) - time.sleep(ENFORCE_INTERVAL) - except KeyboardInterrupt: - _echo("\nEnforcer stopped.") diff --git a/python_pkg/steam_backlog_enforcer/_hltb_detail.py b/python_pkg/steam_backlog_enforcer/_hltb_detail.py deleted file mode 100644 index 9137ef2..0000000 --- a/python_pkg/steam_backlog_enforcer/_hltb_detail.py +++ /dev/null @@ -1,354 +0,0 @@ -"""Detail page parsing and leisure time / DLC fetching for HLTB.""" - -from __future__ import annotations - -import asyncio -from http import HTTPStatus -import json -import logging -import re -from typing import Any - -import aiohttp - -from python_pkg.steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, - HLTB_BASE_URL, - MAX_CONCURRENT, - HLTBResult, - ProgressCb, - _HLTBExtras, - save_hltb_cache, -) - -logger = logging.getLogger(__name__) - -_NEXT_DATA_RE = re.compile( - r'', -) - - -def _parse_game_page(html: str) -> dict[str, Any] | None: - """Extract game data dict from a HLTB game page's __NEXT_DATA__.""" - match = _NEXT_DATA_RE.search(html) - if not match: - return None - try: - data = json.loads(match.group(1)) - result: dict[str, Any] = data["props"]["pageProps"]["game"]["data"] - except (json.JSONDecodeError, KeyError, TypeError): - return None - return result - - -def _as_positive_int(value: object) -> int: - """Convert HLTB numeric JSON values to a positive int, or 0 when invalid.""" - if isinstance(value, int): - return max(0, value) - if isinstance(value, float): - int_value = int(value) - return max(0, int_value) - if isinstance(value, str): - try: - int_value = int(value) - return max(0, int_value) - except ValueError: - return 0 - return 0 - - -def _platform_comp_high_candidates(game_data: dict[str, Any]) -> list[int]: - """Collect positive ``comp_high`` values from ``platformData`` entries.""" - platform_data = game_data.get("platformData", []) - if not isinstance(platform_data, list): - return [] - candidates = [] - for entry in platform_data: - if isinstance(entry, dict): - v = _as_positive_int(entry.get("comp_high", 0)) - if v > 0: - candidates.append(v) - return candidates - - -def _extract_comp_100_avg_and_high(game_data: dict[str, Any]) -> tuple[float, float]: - """Extract (average comp_100, high comp_100) from game detail data. - - Returns hours as floats: (avg_hours, high_hours). Returns (-1, -1) when - insufficient data is present. The average is ``comp_100`` (seconds) from - ``game[0]``; the high is ``comp_100_h``. - """ - games = game_data.get("game", []) - if not isinstance(games, list) or not games: - return -1, -1 - if not isinstance(games[0], dict): - return -1, -1 - - base = games[0] - avg_s = _as_positive_int(base.get("comp_100", 0)) - high_s = _as_positive_int(base.get("comp_100_h", 0)) - - avg_h = round(avg_s / 3600, 2) if avg_s > 0 else -1 - high_h = round(high_s / 3600, 2) if high_s > 0 else avg_h - return avg_h, high_h - - -def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float: - """Extract base-game leisure hours from game detail data. - - Returns the highest (slowest) time to beat across all play styles. - Candidates considered: - - 1. ``comp_high`` from each entry in ``platformData`` — the per-platform - slowest individual submission displayed on the HLTB page. - 2. The ``_h`` (leisure/high) fields from ``game[0]``: - ``comp_main_h``, ``comp_plus_h``, ``comp_100_h``, ``comp_all_h``. - 3. Falls back to average times: ``comp_main``, ``comp_plus``, ``comp_100``. - """ - games = game_data.get("game", []) - if not isinstance(games, list) or not games: - return -1 - if not isinstance(games[0], dict): - return -1 - - base = games[0] - candidates = _platform_comp_high_candidates(game_data) - - # 2. Leisure/high fields from the game record - for field in ("comp_main_h", "comp_plus_h", "comp_100_h", "comp_all_h"): - v = _as_positive_int(base.get(field, 0)) - if v > 0: - candidates.append(v) - - leisure_s = max(candidates) if candidates else 0 - - # 3. Fallback: average completion times - if leisure_s <= 0: - avg_candidates = [ - _as_positive_int(base.get("comp_main", 0)), - _as_positive_int(base.get("comp_plus", 0)), - _as_positive_int(base.get("comp_100", 0)), - ] - leisure_s = max(avg_candidates) - - if leisure_s <= 0: - return -1 - - return round(leisure_s / 3600, 2) - - -def _extract_dlc_relationships(game_data: dict[str, Any]) -> list[tuple[int, float]]: - """Extract DLC relationship IDs and fallback hours from detail data.""" - relationships = game_data.get("relationships", []) - if not isinstance(relationships, list): - return [] - - dlcs: list[tuple[int, float]] = [] - for rel in relationships: - if not isinstance(rel, dict): - continue - if str(rel.get("game_type", "")).lower() != "dlc": - continue - dlc_id = _as_positive_int(rel.get("game_id", 0)) - fallback_comp_100 = _as_positive_int(rel.get("comp_100", 0)) - if fallback_comp_100 > 0: - fallback_hours = round(fallback_comp_100 / 3600, 2) - else: - fallback_hours = 0.0 - dlcs.append((dlc_id, fallback_hours)) - - return dlcs - - -def _extract_leisure_hours(game_data: dict[str, Any]) -> float: - """Compute total leisure hours: base game + all DLCs. - - Uses the highest (slowest) time across ``platformData comp_high`` and - leisure ``_h`` fields from ``game[0]``. Falls back to average completion - times. Also sums leisure time from any DLC listed in ``relationships``. - """ - base_hours = _extract_base_leisure_hours(game_data) - if base_hours <= 0: - return -1 - - total_hours = base_hours - - # Add DLC leisure times from relationships. - for _dlc_id, fallback_hours in _extract_dlc_relationships(game_data): - total_hours += fallback_hours - - return round(total_hours, 2) - - -async def _fetch_detail_one( - sem: asyncio.Semaphore, - session: aiohttp.ClientSession, - hltb_game_id: int, -) -> dict[str, Any] | None: - """Fetch a single HLTB game detail page and parse its data.""" - async with sem: - url = f"{HLTB_BASE_URL}/game/{hltb_game_id}" - headers = { - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" - ), - "accept": "text/html", - "referer": "https://howlongtobeat.com/", - } - try: - async with session.get(url, headers=headers) as resp: - if resp.status == HTTPStatus.OK: - html = await resp.text() - return _parse_game_page(html) - except (aiohttp.ClientError, asyncio.TimeoutError) as exc: - logger.debug( - "HLTB detail fetch failed for game_id=%d: %s", - hltb_game_id, - exc, - ) - return None - - -def _process_game_detail( - game_data: dict[str, Any], - dlc_rels: list[tuple[int, float]], - dlc_hours_by_id: dict[int, float], -) -> tuple[float, float, float]: - """Return (leisure_hours, rush_hours, leisure_100h) for one game's detail data.""" - leisure = _extract_leisure_hours(game_data) - if leisure > 0: - leisure = _apply_dlc_leisure_overrides(leisure, dlc_rels, dlc_hours_by_id) - - avg_h, high_h = _extract_comp_100_avg_and_high(game_data) - rush_h = -1.0 - if avg_h > 0: - dlc_rush = sum(fh for _, fh in dlc_rels if fh > 0) - rush_h = round(avg_h + dlc_rush, 2) - - l100 = -1.0 - if high_h > 0: - l100 = _apply_dlc_leisure_overrides(high_h, dlc_rels, dlc_hours_by_id) - - return leisure, rush_h, l100 - - -async def _fetch_leisure_times( - search_results: list[HLTBResult], - cache: dict[int, float], - polls: dict[int, int], - progress_cb: ProgressCb | None, - extras: _HLTBExtras | None = None, -) -> None: - """Fetch leisure times from game detail pages for all search results. - - Updates ``cache`` in-place with leisure hours (including DLC time). - Also populates ``extras.rush`` (avg comp_100 + DLC) and - ``extras.leisure_100h`` (comp_100_h + DLC leisure). - The ``polls`` and ``extras.count_comp`` are forwarded to - :func:`save_hltb_cache` so confidence metrics persist. - """ - if extras is None: - extras = _HLTBExtras() - - valid = [r for r in search_results if r.hltb_game_id > 0] - if not valid: - return - - timeout = aiohttp.ClientTimeout(total=30, sock_read=20) - sem = asyncio.Semaphore(MAX_CONCURRENT) - connector = aiohttp.TCPConnector( - limit=MAX_CONCURRENT, - keepalive_timeout=30, - ) - - total = len(valid) - done = 0 - found = 0 - - async with aiohttp.ClientSession( - timeout=timeout, - connector=connector, - ) as session: - coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid] - details = await asyncio.gather(*coros) - - dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details) - dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids) - - for r, game_data in zip(valid, details, strict=False): - done += 1 - if game_data is not None: - dlc_rels = dlc_relationships_by_app.get(r.app_id, []) - leisure, rush_h, l100 = _process_game_detail( - game_data, dlc_rels, dlc_hours_by_id - ) - if leisure > 0: - r.completionist_hours = leisure - cache[r.app_id] = leisure - found += 1 - if rush_h > 0: - extras.rush[r.app_id] = rush_h - if l100 > 0: - extras.leisure_100h[r.app_id] = l100 - - if progress_cb is not None: - progress_cb(done, total, found, r.game_name) - - if not done % _SAVE_INTERVAL: - save_hltb_cache(cache, polls, extras) - - -def _collect_dlc_relationships( - valid: list[HLTBResult], - details: list[dict[str, Any] | None], -) -> tuple[dict[int, list[tuple[int, float]]], list[int]]: - """Collect DLC relationship IDs for all base-game detail responses.""" - by_app: dict[int, list[tuple[int, float]]] = {} - unique_dlc_ids: set[int] = set() - - for result, game_data in zip(valid, details, strict=False): - if game_data is None: - continue - dlc_rels = _extract_dlc_relationships(game_data) - by_app[result.app_id] = dlc_rels - for dlc_id, _fallback_hours in dlc_rels: - if dlc_id > 0: - unique_dlc_ids.add(dlc_id) - - return by_app, sorted(unique_dlc_ids) - - -async def _fetch_dlc_leisure_hours( - sem: asyncio.Semaphore, - session: aiohttp.ClientSession, - dlc_ids: list[int], -) -> dict[int, float]: - """Fetch leisure hours for each DLC game id.""" - if not dlc_ids: - return {} - - coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids] - dlc_details = await asyncio.gather(*coros) - - dlc_hours_by_id: dict[int, float] = {} - for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False): - if dlc_data is None: - continue - dlc_leisure = _extract_base_leisure_hours(dlc_data) - if dlc_leisure > 0: - dlc_hours_by_id[dlc_id] = dlc_leisure - return dlc_hours_by_id - - -def _apply_dlc_leisure_overrides( - base_hours: float, - dlc_rels: list[tuple[int, float]], - dlc_hours_by_id: dict[int, float], -) -> float: - """Replace fallback DLC hours with detailed leisure hours when available.""" - adjusted = base_hours - for dlc_id, fallback_hours in dlc_rels: - dlc_leisure = dlc_hours_by_id.get(dlc_id, -1.0) - if dlc_leisure > 0: - adjusted += dlc_leisure - fallback_hours - return round(adjusted, 2) diff --git a/python_pkg/steam_backlog_enforcer/_hltb_search.py b/python_pkg/steam_backlog_enforcer/_hltb_search.py deleted file mode 100644 index b23c09b..0000000 --- a/python_pkg/steam_backlog_enforcer/_hltb_search.py +++ /dev/null @@ -1,552 +0,0 @@ -"""Internal HLTB search helpers: URL discovery, auth, matching, and batch fetch.""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass, field -from difflib import SequenceMatcher -from http import HTTPStatus -import json -import logging -import re -import time -from typing import Any - -import aiohttp -from howlongtobeatpy.HTMLRequests import HTMLRequests - -from python_pkg.steam_backlog_enforcer._hltb_detail import ( - _fetch_leisure_times, -) -from python_pkg.steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, - _SUBSET_SUFFIXES, - MAX_CONCURRENT, - MIN_SIMILARITY, - HLTBResult, - ProgressCb, - _AuthInfo, - _HLTBExtras, - save_hltb_cache, -) - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────── -# HLTB API setup (done once, not per-request like the library) -# ────────────────────────────────────────────────────────────── - - -def _get_hltb_search_url() -> str: - """Discover the current HLTB search API endpoint. - - Scrapes the homepage for JS bundles containing the fetch URL. - Falls back to ``/api/finder`` if extraction fails. - """ - try: - search_info = HTMLRequests.send_website_request_getcode( - parse_all_scripts=False, - ) - if search_info is None: - search_info = HTMLRequests.send_website_request_getcode( - parse_all_scripts=True, - ) - if search_info and search_info.search_url: - url: str = HTMLRequests.BASE_URL + search_info.search_url - return url - except (OSError, RuntimeError, ValueError, TypeError): - logger.debug("Failed to discover HLTB search URL, using default") - return "https://howlongtobeat.com/api/finder" - - -async def _get_auth_info( - search_url: str, - session: aiohttp.ClientSession, -) -> _AuthInfo | None: - """Fetch the HLTB auth token and honeypot key/val (one GET request).""" - init_url = search_url + "/init" - ts = int(time.time() * 1000) - headers = { - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" - ), - "referer": "https://howlongtobeat.com/", - } - try: - async with session.get( - init_url, - params={"t": ts}, - headers=headers, - ) as resp: - if resp.status == HTTPStatus.OK: - data = await resp.json() - token: str | None = data.get("token") - if token is None: - return None - return _AuthInfo( - token=token, - hp_key=data.get("hpKey", ""), - hp_val=data.get("hpVal", ""), - ) - except (aiohttp.ClientError, asyncio.TimeoutError): - logger.warning("Failed to get HLTB auth token") - return None - - -def _similarity(a: str, b: str) -> float: - """Case-insensitive SequenceMatcher ratio between two strings.""" - return SequenceMatcher(None, a.lower(), b.lower()).ratio() - - -_TM_RE = re.compile("[™®©\uff0a]") -_COLON_WORD_RE = re.compile(r":(\s|$)") -_STANDALONE_PUNCT_RE = re.compile(r"^[-/|]$") -_AMP_RE = re.compile(r"\s*&\s*") - - -def _sanitize_search_name(name: str) -> str: - """Strip HLTB-breaking characters from a game name for searchTerms. - - Removes trademark/copyright symbols, colons at word-end, standalone - punctuation tokens (dash, slash, pipe), and replaces & with 'and'. - """ - cleaned = _TM_RE.sub("", name) - cleaned = _AMP_RE.sub(" and ", cleaned) - cleaned = _COLON_WORD_RE.sub(" ", cleaned) - tokens = [t for t in cleaned.split() if not _STANDALONE_PUNCT_RE.match(t)] - return " ".join(tokens) - - -def _build_search_payload(game_name: str, auth: _AuthInfo | None = None) -> str: - """Build the JSON POST body for an HLTB search.""" - payload: dict[str, Any] = { - "searchType": "games", - "searchTerms": _sanitize_search_name(game_name).split(), - "searchPage": 1, - "size": 20, - "searchOptions": { - "games": { - "userId": 0, - "platform": "", - "sortCategory": "popular", - "rangeCategory": "main", - "rangeTime": {"min": 0, "max": 0}, - "gameplay": { - "perspective": "", - "flow": "", - "genre": "", - "difficulty": "", - }, - "rangeYear": {"max": "", "min": ""}, - "modifier": "", - }, - "users": {"sortCategory": "postcount"}, - "lists": {"sortCategory": "follows"}, - "filter": "", - "sort": 0, - "randomizer": 0, - }, - "useCache": True, - } - if auth and auth.hp_key: - payload[auth.hp_key] = auth.hp_val - return json.dumps(payload) - - -_STEAM_SUFFIX_RE = re.compile( - r"\s+(?:\(Legacy\)|\(Classic\)|\(beta\)|\(Remastered\)|Legacy|Classic|RHCP" - r"|\(Phase\s+\d+\))\s*$", - re.IGNORECASE, -) - - -def _build_search_variants(game_name: str) -> list[str]: - """Return fallback search terms for one Steam game title. - - Tries progressively simplified names so HLTB search finds a result even - when the Steam title contains edition suffixes, Steam-only labels, or - subtitle decorators that HLTB does not index under. - - Order matters: most-specific first, then stripped-down fallbacks. - Simplifications are chained so e.g. "Foo - Bar Edition" → - "Foo - Bar" → "Foo" and "Foo - Bar Edition" → "Foo Edition" → "Foo". - """ - base = game_name.strip() - seen: set[str] = set() - variants: list[str] = [] - - def _add(name: str) -> None: - s = name.strip() - if s and s not in seen: - seen.add(s) - variants.append(s) - - _add(base) - - # Strip Steam-only labels that HLTB never uses - no_steam = _STEAM_SUFFIX_RE.sub("", base).strip() - _add(no_steam) - - # Strip trailing year "(YYYY)" - no_year = re.sub(r"\s*\(\d{4}\)$", "", base).strip() - _add(no_year) - - # Strip " - subtitle" portion (e.g. "Brothers - A Tale of Two Sons" → "Brothers") - no_subtitle = re.sub(r"\s+-\s+.*$", "", base).strip() - _add(no_subtitle) - # Also strip edition from the subtitle-stripped name. - # e.g. "Rocksmith 2014 Edition - Remastered" → "Rocksmith 2014 Edition" - # → "Rocksmith 2014" - if no_subtitle != base: - _add(re.sub(r"\s+\w+\s+Edition\s*$", "", no_subtitle, flags=re.IGNORECASE)) - _add(re.sub(r"\s+Edition\s*$", "", no_subtitle, flags=re.IGNORECASE)) - - # Strip "GOTY Edition" / "Gold Edition" / "Definitive Edition" etc. from base - no_edition = re.sub(r"\s+\w+\s+Edition\s*$", "", base, flags=re.IGNORECASE).strip() - _add(no_edition) - - # Strip just " Edition" at end from base - no_bare_edition = re.sub(r"\s+Edition\s*$", "", base, flags=re.IGNORECASE).strip() - _add(no_bare_edition) - - # Strip ": subtitle" portion (e.g. "Batman: Arkham Asylum" → "Batman") - no_colon_sub = re.sub(r"\s*:.*$", "", base).strip() - _add(no_colon_sub) - - return variants - - -def _collect_candidates( - query_name: str, - data: dict[str, Any], -) -> list[tuple[dict[str, Any], float]]: - """Build candidate list from one HLTB response payload.""" - candidates: list[tuple[dict[str, Any], float]] = [] - lower_name = query_name.lower() - for entry in data.get("data", []): - entry_name = entry.get("game_name", "") - entry_alias = entry.get("game_alias", "") or "" - is_dlc = str(entry.get("game_type", "")).lower() == "dlc" - sim = max( - _similarity(query_name, entry_name), - _similarity(query_name, entry_alias), - ) - is_full_edition = ( - (not is_dlc) and entry_name.lower().startswith(lower_name + ":") - ) or ((not is_dlc) and entry_name.lower().startswith(lower_name + " -")) - if sim >= MIN_SIMILARITY or is_full_edition: - comp_100 = entry.get("comp_100", 0) - if comp_100 and comp_100 > 0: - candidates.append((entry, sim)) - return candidates - - -def _build_result_from_best( - app_id: int, - original_name: str, - query_name: str, - best: tuple[dict[str, Any], float], -) -> HLTBResult: - """Convert selected HLTB entry into HLTBResult.""" - entry, sim = best - hours = round(entry["comp_100"] / 3600, 2) - logger.debug( - ("HLTB match for '%s' via '%s': '%s' (id=%s, comp_100=%s, sim=%.3f)"), - original_name, - query_name, - entry.get("game_name"), - entry.get("game_id"), - entry.get("comp_100"), - sim, - ) - return HLTBResult( - app_id=app_id, - game_name=original_name, - completionist_hours=hours, - similarity=sim, - hltb_game_id=entry.get("game_id", 0), - comp_100_count=int(entry.get("comp_100_count", 0) or 0), - count_comp=int(entry.get("count_comp", 0) or 0), - ) - - -def _pick_best_hltb_entry( - search_name: str, - candidates: list[tuple[dict[str, Any], float]], -) -> tuple[dict[str, Any], float] | None: - """Pick the best HLTB entry, preferring full editions over demos/chapters. - - When a short name like "FAITH" matches both "FAITH" (demo) and - "FAITH: The Unholy Trinity" (full game), prefer the full game - since Steam often lists the full game under the shorter name. - - When an exact match like "Timberman" (26 h) competes against an - unrelated subtitle entry like "Timberman: The Big Adventure" (2 h), - the exact match wins because it has more hours. - """ - if not candidates: - return None - - # Prefer base games over DLC entries when both are present. - non_dlc = [c for c in candidates if str(c[0].get("game_type", "")).lower() != "dlc"] - usable = non_dlc or candidates - if len(usable) == 1: - return usable[0] - - lower = search_name.lower() - best_exact = _find_exact_match(usable, lower) - best_extended = _find_best_extended(usable, lower) - return _resolve_exact_vs_extended(best_exact, best_extended, usable) - - -def _find_exact_match( - usable: list[tuple[dict[str, Any], float]], - lower: str, -) -> tuple[dict[str, Any], float] | None: - """Find best exact name/alias match (highest comp_100).""" - return next( - ( - (e, s) - for e, s in sorted( - usable, - key=lambda x: x[0].get("comp_100", 0), - reverse=True, - ) - if (e.get("game_name") or "").lower() == lower - or (e.get("game_alias") or "").lower() == lower - ), - None, - ) - - -def _find_best_extended( - usable: list[tuple[dict[str, Any], float]], - lower: str, -) -> tuple[dict[str, Any], float] | None: - """Find best extended entry ("Name: Subtitle" / "Name - Subtitle"). - - Skips subset entries (prologue, demo, etc.). - """ - best: tuple[dict[str, Any], float] | None = None - for entry, sim in usable: - game_type = str(entry.get("game_type", "")).lower() - if game_type not in ("", "game"): - continue - entry_name = (entry.get("game_name") or "").lower() - if entry_name.startswith((lower + ":", lower + " -")): - suffix = entry_name[len(lower) :].lstrip(" :-") - if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES) and ( - best is None or entry.get("comp_100", 0) > best[0].get("comp_100", 0) - ): - best = (entry, sim) - return best - - -def _resolve_exact_vs_extended( - best_exact: tuple[dict[str, Any], float] | None, - best_extended: tuple[dict[str, Any], float] | None, - usable: list[tuple[dict[str, Any], float]], -) -> tuple[dict[str, Any], float]: - """Decide between exact match, extended entry, or highest similarity.""" - if best_exact is not None and best_extended is not None: - exact_hours = best_exact[0].get("comp_100", 0) - extended_hours = best_extended[0].get("comp_100", 0) - exact_confidence = int(best_exact[0].get("comp_100_count", 0) or 0) + int( - best_exact[0].get("count_comp", 0) or 0 - ) - extended_confidence = int(best_extended[0].get("comp_100_count", 0) or 0) + int( - best_extended[0].get("count_comp", 0) or 0 - ) - # Prefer the extended entry only when it has strictly more hours - # than the exact match AND at least as much confidence. - # This lets "FAITH: The Unholy Trinity" (full game) beat - # a low-confidence exact demo while preventing low-confidence - # mods like "Celeste - Strawberry Jam" from beating - # the exact base game. - if extended_hours > exact_hours and extended_confidence >= exact_confidence: - return best_extended - return best_exact - if best_exact is not None: - return best_exact - if best_extended is not None: - return best_extended - - # Fall back to highest similarity. - return max(usable, key=lambda x: x[1]) - - -# ────────────────────────────────────────────────────────────── -# Async fetching with shared session & progress -# ────────────────────────────────────────────────────────────── - - -@dataclass -class _SearchCtx: - """Shared context for HLTB search requests.""" - - session: aiohttp.ClientSession - search_url: str - headers: dict[str, str] - cache: dict[int, float] - polls: dict[int, int] = field(default_factory=dict) - count_comp: dict[int, int] = field(default_factory=dict) - auth: _AuthInfo | None = None - counter: dict[str, int] = field(default_factory=dict) - total: int = 0 - progress_cb: ProgressCb | None = None - hltb_game_id: dict[int, int] = field(default_factory=dict) - - -async def _search_one( - sem: asyncio.Semaphore, - ctx: _SearchCtx, - app_id: int, - name: str, -) -> HLTBResult | None: - """Search HLTB for one game via direct POST, update cache.""" - async with sem: - result: HLTBResult | None = None - for query_name in _build_search_variants(name): - payload = _build_search_payload(query_name, ctx.auth) - try: - async with ctx.session.post( - ctx.search_url, - headers=ctx.headers, - data=payload, - ) as resp: - if resp.status != HTTPStatus.OK: - continue - data = await resp.json() - candidates = _collect_candidates(query_name, data) - best = _pick_best_hltb_entry(query_name, candidates) - if best is None: - continue - result = _build_result_from_best(app_id, name, query_name, best) - break - except (aiohttp.ClientError, asyncio.TimeoutError) as exc: - logger.debug("HLTB search failed for '%s': %s", query_name, exc) - - # Update cache immediately (miss = -1). - if result is not None: - ctx.cache[app_id] = result.completionist_hours - ctx.polls[app_id] = result.comp_100_count - ctx.count_comp[app_id] = result.count_comp - if result.hltb_game_id > 0: - ctx.hltb_game_id[app_id] = result.hltb_game_id - ctx.counter["found"] += 1 - else: - ctx.cache[app_id] = -1 - ctx.polls[app_id] = 0 - ctx.count_comp[app_id] = 0 - - ctx.counter["done"] += 1 - done = ctx.counter["done"] - - # Incremental save every _SAVE_INTERVAL lookups. - if not done % _SAVE_INTERVAL: - save_hltb_cache( - ctx.cache, - ctx.polls, - _HLTBExtras(count_comp=ctx.count_comp, hltb_game_id=ctx.hltb_game_id), - ) - - # Report progress. - if ctx.progress_cb is not None: - ctx.progress_cb(done, ctx.total, ctx.counter["found"], name) - - return result - - -async def _fetch_batch( - games: list[tuple[int, str]], - cache: dict[int, float], - polls: dict[int, int], - progress_cb: ProgressCb | None, - extras: _HLTBExtras | None = None, -) -> list[HLTBResult]: - """Fetch HLTB data for a batch of games using one shared session.""" - if extras is None: - extras = _HLTBExtras() - - # 1. Discover the search URL (sync, one-time). - search_url = _get_hltb_search_url() - logger.info("HLTB search URL: %s", search_url) - - timeout = aiohttp.ClientTimeout(total=20, sock_read=15) - - # 2. Get auth info (separate session — avoids reuse issues). - async with aiohttp.ClientSession(timeout=timeout) as init_session: - auth = await _get_auth_info(search_url, init_session) - if auth is None: - logger.warning("Could not get HLTB auth info, aborting fetch.") - return [] - logger.info("HLTB auth token acquired.") - - # 3. Build shared headers for all search requests. - headers: dict[str, str] = { - "content-type": "application/json", - "accept": "*/*", - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" - ), - "referer": "https://howlongtobeat.com/", - "x-auth-token": auth.token, - } - if auth.hp_key: - headers["x-hp-key"] = auth.hp_key - headers["x-hp-val"] = auth.hp_val - - # 4. Fire all searches through a single persistent session. - sem = asyncio.Semaphore(MAX_CONCURRENT) - counter = {"done": 0, "found": 0} - total = len(games) - - connector = aiohttp.TCPConnector( - limit=MAX_CONCURRENT, - keepalive_timeout=30, - ) - async with aiohttp.ClientSession( - timeout=timeout, - connector=connector, - ) as session: - ctx = _SearchCtx( - session=session, - search_url=search_url, - headers=headers, - cache=cache, - polls=polls, - count_comp=extras.count_comp, - auth=auth, - counter=counter, - total=total, - progress_cb=progress_cb, - hltb_game_id=extras.hltb_game_id, - ) - tasks = [ - _search_one( - sem, - ctx, - app_id, - name, - ) - for app_id, name in games - ] - results = await asyncio.gather(*tasks) - - search_results = [r for r in results if r is not None] - - # 5. Fetch leisure times + DLC from game detail pages. - logger.info( - "Fetching leisure times for %d games from detail pages...", - len(search_results), - ) - await _fetch_leisure_times( - search_results, - cache, - polls, - progress_cb=None, - extras=extras, - ) - - return search_results diff --git a/python_pkg/steam_backlog_enforcer/_hltb_types.py b/python_pkg/steam_backlog_enforcer/_hltb_types.py deleted file mode 100644 index 4dc5e14..0000000 --- a/python_pkg/steam_backlog_enforcer/_hltb_types.py +++ /dev/null @@ -1,236 +0,0 @@ -"""Shared types, constants, and cache I/O for the HLTB integration.""" - -from __future__ import annotations - -from collections.abc import Callable -from dataclasses import dataclass -import json -import logging -from typing import Any - -from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write - -logger = logging.getLogger(__name__) - -HLTB_CACHE_FILE = CONFIG_DIR / "hltb_cache.json" -MAX_CONCURRENT = 60 # parallel requests to HLTB -_SAVE_INTERVAL = 50 # flush cache to disk every N results -MIN_SIMILARITY = 0.5 -HLTB_BASE_URL = "https://howlongtobeat.com" - -# Suffixes that indicate a subset release (prologue, demo, etc.). -# Used to avoid preferring "Game - Prologue" over "Game" when both exist. -_SUBSET_SUFFIXES = frozenset( - { - "prologue", - "demo", - "trial", - "lite", - "prelude", - } -) - -# Type for progress callbacks: (done, total, found, game_name) -ProgressCb = Callable[[int, int, int, str], None] - - -@dataclass -class HLTBResult: - """Result from a HowLongToBeat lookup.""" - - app_id: int - game_name: str - completionist_hours: float - similarity: float - hltb_game_id: int = 0 - comp_100_count: int = 0 - count_comp: int = 0 - rush_hours: float = -1 - leisure_100h: float = -1 - - -class _HLTBExtras: - """Mutable accumulator for HLTB data beyond the core hours cache. - - Passed through the fetch pipeline so callers stay within the 5-arg limit. - """ - - def __init__( - self, - count_comp: dict[int, int] | None = None, - rush: dict[int, float] | None = None, - leisure_100h: dict[int, float] | None = None, - hltb_game_id: dict[int, int] | None = None, - ) -> None: - """Initialize with optional pre-populated dicts.""" - self.count_comp: dict[int, int] = count_comp if count_comp is not None else {} - self.rush: dict[int, float] = rush if rush is not None else {} - self.leisure_100h: dict[int, float] = ( - leisure_100h if leisure_100h is not None else {} - ) - self.hltb_game_id: dict[int, int] = ( - hltb_game_id if hltb_game_id is not None else {} - ) - - -@dataclass -class _AuthInfo: - """HLTB API authentication details.""" - - token: str - hp_key: str = "" - hp_val: str = "" - - -def _read_raw_cache() -> dict[int, dict[str, Any]]: - """Read the persistent HLTB cache, normalizing legacy float entries. - - Cache schema on disk (current): - { - "": { - "hours": , - "polls": , - "count_comp": , - "rush_hours": , - "leisure_100h": , - "hltb_game_id": - } - } - - Legacy format (single float value per app) is migrated transparently. - """ - if not HLTB_CACHE_FILE.exists(): - return {} - try: - data = json.loads(HLTB_CACHE_FILE.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError): - logger.warning("Corrupt HLTB cache, starting fresh.") - return {} - out: dict[int, dict[str, Any]] = {} - for k, v in data.items(): - try: - aid = int(k) - except (TypeError, ValueError): - continue - if isinstance(v, dict): - out[aid] = { - "hours": float(v.get("hours", -1)), - "polls": int(v.get("polls", 0)), - "count_comp": int(v.get("count_comp", 0)), - "rush_hours": float(v.get("rush_hours", -1)), - "leisure_100h": float(v.get("leisure_100h", -1)), - "hltb_game_id": int(v.get("hltb_game_id", 0)), - } - else: - try: - out[aid] = { - "hours": float(v), - "polls": 0, - "count_comp": 0, - "rush_hours": -1, - "leisure_100h": -1, - "hltb_game_id": 0, - } - except (TypeError, ValueError): - continue - return out - - -def load_hltb_cache() -> dict[int, float]: - """Load the hours portion of the HLTB cache. - - Returns: dict mapping app_id -> completionist_hours (-1 = no data on HLTB). - """ - return {aid: v["hours"] for aid, v in _read_raw_cache().items()} - - -def load_hltb_polls_cache() -> dict[int, int]: - """Load the polled-completionist-times portion of the HLTB cache. - - Returns: dict mapping app_id -> ``comp_100_count`` (0 = unknown). - """ - return {aid: v["polls"] for aid, v in _read_raw_cache().items()} - - -def load_hltb_count_comp_cache() -> dict[int, int]: - """Load the ``count_comp`` portion of the HLTB cache. - - Returns: dict mapping app_id -> ``count_comp`` (0 = unknown). - """ - return {aid: v["count_comp"] for aid, v in _read_raw_cache().items()} - - -def load_hltb_rush_cache() -> dict[int, float]: - """Load the rush-hours (avg comp_100 + DLC) portion of the HLTB cache. - - Returns: dict mapping app_id -> rush_hours (-1 = not yet computed). - """ - return {aid: v["rush_hours"] for aid, v in _read_raw_cache().items()} - - -def load_hltb_leisure_100h_cache() -> dict[int, float]: - """Load the leisure-100h (comp_100_h + DLC) portion of the HLTB cache. - - Returns: dict mapping app_id -> leisure_100h (-1 = not yet computed). - """ - return {aid: v["leisure_100h"] for aid, v in _read_raw_cache().items()} - - -def load_hltb_game_id_cache() -> dict[int, int]: - """Load the HLTB game ID portion of the cache. - - Returns: dict mapping app_id -> hltb_game_id (0 = not yet looked up). - """ - return {aid: v["hltb_game_id"] for aid, v in _read_raw_cache().items()} - - -def save_hltb_cache( - cache: dict[int, float], - polls: dict[int, int] | None = None, - extras: _HLTBExtras | None = None, -) -> None: - """Save the HLTB cache to disk, including confidence and stats metrics.""" - polls = polls or {} - if extras is None: - extras = _HLTBExtras() - # Preserve existing per-game data when the caller didn't populate the maps. - # A partial save (e.g. confidence-only) must not clobber rush/leisure/game-id - # data that a prior detail fetch already wrote. - needs_existing = ( - not extras.hltb_game_id or not extras.rush or not extras.leisure_100h - ) - if needs_existing: - existing = _read_raw_cache() - game_id_map: dict[int, int] = extras.hltb_game_id or { - aid: v["hltb_game_id"] for aid, v in existing.items() - } - rush_map: dict[int, float] = extras.rush or { - aid: v["rush_hours"] for aid, v in existing.items() if v["rush_hours"] > 0 - } - leisure_map: dict[int, float] = extras.leisure_100h or { - aid: v["leisure_100h"] - for aid, v in existing.items() - if v["leisure_100h"] > 0 - } - else: - game_id_map = extras.hltb_game_id - rush_map = extras.rush - leisure_map = extras.leisure_100h - out = { - str(aid): { - "hours": hours, - "polls": polls.get(aid, 0), - "count_comp": extras.count_comp.get(aid, 0), - "rush_hours": rush_map.get(aid, -1), - "leisure_100h": leisure_map.get(aid, -1), - "hltb_game_id": game_id_map.get(aid, 0), - } - for aid, hours in cache.items() - } - try: - _atomic_write( - HLTB_CACHE_FILE, - json.dumps(out, indent=2) + "\n", - ) - except OSError: - logger.exception("Failed to save HLTB cache") diff --git a/python_pkg/steam_backlog_enforcer/_scanning_confidence.py b/python_pkg/steam_backlog_enforcer/_scanning_confidence.py deleted file mode 100644 index 225e8b7..0000000 --- a/python_pkg/steam_backlog_enforcer/_scanning_confidence.py +++ /dev/null @@ -1,252 +0,0 @@ -"""Confidence-checking and candidate-filtering helpers for scanning.""" - -from __future__ import annotations - -import logging -from typing import TYPE_CHECKING - -from python_pkg.steam_backlog_enforcer._hltb_types import ( - _HLTBExtras, - load_hltb_cache, - load_hltb_count_comp_cache, - load_hltb_polls_cache, - save_hltb_cache, -) -from python_pkg.steam_backlog_enforcer.game_install import _echo -from python_pkg.steam_backlog_enforcer.hltb import fetch_hltb_confidence_cached - -if TYPE_CHECKING: - from python_pkg.steam_backlog_enforcer.config import State - from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -logger = logging.getLogger(__name__) - -_MIN_COMP_100_POLLS = 3 -_MIN_COUNT_COMP = 15 -_MIN_CONFIDENCE_SUM = 18 - - -def _apply_cached_confidence_to_candidates(candidates: list[GameInfo]) -> None: - """Overlay cached confidence counters onto candidate game objects.""" - polls_cache = load_hltb_polls_cache() - count_comp_cache = load_hltb_count_comp_cache() - for game in candidates: - if game.app_id in polls_cache: - game.comp_100_count = polls_cache[game.app_id] - if game.app_id in count_comp_cache: - game.count_comp = count_comp_cache[game.app_id] - - -def _confidence_fail_reasons(game: GameInfo) -> list[str]: - """Return threshold-failure reasons for a game's HLTB confidence data.""" - reasons: list[str] = [] - if game.comp_100_count < _MIN_COMP_100_POLLS: - reasons.append(f"comp_100 polls {game.comp_100_count} < {_MIN_COMP_100_POLLS}") - if game.count_comp < _MIN_COUNT_COMP: - reasons.append(f"count_comp {game.count_comp} < {_MIN_COUNT_COMP}") - - total = game.comp_100_count + game.count_comp - if total < _MIN_CONFIDENCE_SUM: - reasons.append(f"comp_100+count_comp {total} < {_MIN_CONFIDENCE_SUM}") - - return reasons - - -def _refresh_candidate_confidence(game: GameInfo) -> None: - """Refresh confidence metrics for one candidate when cache looks stale. - - Only refreshes when both metrics are missing (0), which typically means - the game was cached before confidence fields were added. - """ - if game.comp_100_count > 0 or game.count_comp > 0: - return - - _refresh_candidate_confidence_batch([game]) - - -def _force_refresh_candidate_confidence(game: GameInfo) -> None: - """Force-refresh one candidate's confidence metrics from HLTB.""" - _refresh_candidate_confidence_batch([game], force=True) - - -def _refresh_candidate_confidence_batch( - candidates: list[GameInfo], - *, - force: bool = False, -) -> None: - """Refresh missing confidence metrics for candidates in one HLTB batch. - - This prevents O(N) one-game API loops when many snapshot entries predate - confidence fields and therefore have ``comp_100_count==0`` and - ``count_comp==0``. - """ - missing = [ - game - for game in candidates - if force or (game.comp_100_count == 0 and game.count_comp == 0) - ] - if not missing: - return - - refresh_slice = missing - if len(refresh_slice) == 1: - game = refresh_slice[0] - _echo(f" Refreshing HLTB confidence for {game.name} (AppID={game.app_id})...") - else: - _echo(f" Refreshing HLTB confidence for {len(refresh_slice)} candidate(s)...") - - cache = load_hltb_cache() - polls = load_hltb_polls_cache() - count_comp = load_hltb_count_comp_cache() - app_ids = [game.app_id for game in refresh_slice] - names = [(game.app_id, game.name) for game in refresh_slice] - prior_hours = {aid: cache.get(aid, -1) for aid in app_ids} - - for aid in app_ids: - cache.pop(aid, None) - polls.pop(aid, None) - count_comp.pop(aid, None) - save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp)) - - fetch_hltb_confidence_cached(names) - - refreshed_hours = load_hltb_cache() - refreshed_polls = load_hltb_polls_cache() - refreshed_count_comp = load_hltb_count_comp_cache() - for aid, old_hours in prior_hours.items(): - if old_hours > 0 and refreshed_hours.get(aid, -1) <= 0: - refreshed_hours[aid] = old_hours - save_hltb_cache( - refreshed_hours, refreshed_polls, _HLTBExtras(count_comp=refreshed_count_comp) - ) - - for game in refresh_slice: - game.comp_100_count = refreshed_polls.get(game.app_id, 0) - game.count_comp = refreshed_count_comp.get(game.app_id, 0) - - -def _filter_hltb_confident_candidates( - candidates: list[GameInfo], -) -> list[GameInfo]: - """Keep only candidates that satisfy HLTB confidence thresholds.""" - _refresh_candidate_confidence_batch(candidates) - - kept: list[GameInfo] = [] - for game in candidates: - reasons = _confidence_fail_reasons(game) - if reasons: - _echo( - f" Skipping {game.name} (AppID={game.app_id}): " - f"HLTB confidence too low ({'; '.join(reasons)})" - ) - continue - kept.append(game) - return kept - - -def _candidate_passes_hltb_confidence(game: GameInfo) -> bool: - """Return True if candidate passes confidence with cache-first behavior. - - Only refreshes when confidence fields are missing (both zero), which keeps - normal runs cache-friendly and avoids repeated refetches for known - low-confidence entries. - """ - reasons = _confidence_fail_reasons(game) - if not reasons: - return True - - # Re-check once when confidence fields are missing in cache. - _refresh_candidate_confidence(game) - reasons = _confidence_fail_reasons(game) - if reasons: - _echo( - f" Skipping {game.name} (AppID={game.app_id}): " - f"HLTB confidence too low ({'; '.join(reasons)})" - ) - return False - return True - - -def _backfill_polls_for_finished( - state: State, - games: list[GameInfo], -) -> dict[int, int]: - """Lazily fetch poll counts for already-finished games missing them. - - Reads the polls cache, identifies finished games whose poll count is - still ``0`` (typically because the cache predates the polls schema), - and triggers a one-shot HLTB search to backfill them. Returns the - refreshed polls cache. - """ - polls_cache = load_hltb_polls_cache() - name_by_id = {g.app_id: g.name for g in games} - missing = [ - (aid, name_by_id[aid]) - for aid in state.finished_app_ids - if aid in name_by_id and polls_cache.get(aid, 0) == 0 - ] - if not missing: - return polls_cache - - logger.info( - "Backfilling HLTB poll counts for %d already-finished games...", - len(missing), - ) - # Force a fresh search by removing the hours entries we want to refetch. - # (fetch_hltb_times_cached skips entries already in the hours cache.) - cache = load_hltb_cache() - preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache} - for aid, _name in missing: - cache.pop(aid, None) - save_hltb_cache(cache, polls_cache) - - fetch_hltb_confidence_cached(missing) - - # Restore any previously-known hours that the refetch may have replaced - # with a worse match (we trust prior leisure+dlc estimates). - refreshed_hours = load_hltb_cache() - refreshed_polls = load_hltb_polls_cache() - for aid, prior_hours in preserved_hours.items(): - if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0: - refreshed_hours[aid] = prior_hours - save_hltb_cache(refreshed_hours, refreshed_polls) - return refreshed_polls - - -def _report_poll_confidence( - chosen: GameInfo, - games: list[GameInfo], - state: State, -) -> None: - """Print HLTB poll-count confidence info for the just-assigned game. - - Shows the chosen game's ``comp_100_count`` (number of polled - completionist times on HowLongToBeat) and the historical minimum - among the user's previously-finished games. Marks a new historical - low so the user can be skeptical of unreliable estimates. - """ - polls_cache = _backfill_polls_for_finished(state, games) - chosen_polls = polls_cache.get(chosen.app_id, chosen.comp_100_count) - chosen.comp_100_count = chosen_polls - - finished_polls = [ - (polls_cache[aid], aid) - for aid in state.finished_app_ids - if polls_cache.get(aid, 0) > 0 - ] - if not finished_polls: - _echo(f" HLTB confidence: {chosen_polls} polled completionist times") - return - - min_polls, min_aid = min(finished_polls) - name_by_id = {g.app_id: g.name for g in games} - min_name = name_by_id.get(min_aid, f"AppID={min_aid}") - - warning = "" - if 0 < chosen_polls < min_polls: - warning = " ⚠ NEW LOW — estimate may be unreliable" - elif chosen_polls == 0: - warning = " ⚠ no polls recorded — estimate may be unreliable" - - _echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}") - _echo(f" Historical min among finished: {min_polls} ({min_name})") diff --git a/python_pkg/steam_backlog_enforcer/_stats.py b/python_pkg/steam_backlog_enforcer/_stats.py deleted file mode 100644 index 5e7a184..0000000 --- a/python_pkg/steam_backlog_enforcer/_stats.py +++ /dev/null @@ -1,350 +0,0 @@ -"""Backlog completion-time statistics for Steam Backlog Enforcer.""" - -from __future__ import annotations - -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -import logging -import secrets -from typing import TYPE_CHECKING -from urllib.parse import quote_plus - -from python_pkg.steam_backlog_enforcer._hltb_types import ( - HLTB_BASE_URL, - load_hltb_cache, - load_hltb_game_id_cache, - load_hltb_leisure_100h_cache, - load_hltb_rush_cache, -) -from python_pkg.steam_backlog_enforcer._scanning_confidence import ( - _apply_cached_confidence_to_candidates, - _confidence_fail_reasons, - _refresh_candidate_confidence_batch, -) -from python_pkg.steam_backlog_enforcer.config import load_snapshot -from python_pkg.steam_backlog_enforcer.game_install import _echo -from python_pkg.steam_backlog_enforcer.hltb import fetch_hltb_detail_missing -from python_pkg.steam_backlog_enforcer.protondb import ( - ProtonDBRating, - fetch_protondb_ratings, -) -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -if TYPE_CHECKING: - from python_pkg.steam_backlog_enforcer.config import Config, State - -logger = logging.getLogger(__name__) - -_HOURS_PER_DAY_PRESETS = (2.0, 4.0, 6.0, 8.0) - -_LINE = "─" * 70 - -_HLTB_SEARCH_BASE = "https://howlongtobeat.com/?q=" - - -@dataclass -class _GameTimes: - """Per-game time estimates for stats display.""" - - game: GameInfo - worst_hours: float - rush_hours: float - leisure_100h: float - hltb_game_id: int = field(default=0) - - -def _filter_qualifying_games( - games: list[GameInfo], - state: State, -) -> tuple[list[_GameTimes], int, int, int]: - """Return qualifying incomplete games with their time estimates. - - Applies the same HLTB-confidence and Linux-compatibility filters as the - game picker. The current game and already-finished games are excluded. - - Returns: - (qualified_list, hltb_skipped, linux_skipped, no_data_skipped) - """ - rush_cache = load_hltb_rush_cache() - leisure_100h_cache = load_hltb_leisure_100h_cache() - game_id_cache = load_hltb_game_id_cache() - hours_cache = load_hltb_cache() - - exclude = set(state.finished_app_ids) - if state.current_app_id is not None: - exclude.add(state.current_app_id) - - candidates = [g for g in games if not g.is_complete and g.app_id not in exclude] - _apply_cached_confidence_to_candidates(candidates) - _refresh_candidate_confidence_batch(candidates) - - hltb_skipped = 0 - linux_skipped = 0 - no_data_skipped = 0 - app_ids_to_check: list[int] = [] - - conf_ok: list[GameInfo] = [] - for game in candidates: - if _confidence_fail_reasons(game): - hltb_skipped += 1 - continue - conf_ok.append(game) - app_ids_to_check.append(game.app_id) - - ratings: dict[int, ProtonDBRating] = {} - if app_ids_to_check: - ratings = fetch_protondb_ratings(app_ids_to_check) - - qualified: list[_GameTimes] = [] - for game in conf_ok: - rating = ratings.get(game.app_id, ProtonDBRating(app_id=game.app_id)) - if not rating.is_playable: - linux_skipped += 1 - continue - - rush = rush_cache.get(game.app_id, -1) - leisure = leisure_100h_cache.get(game.app_id, -1) - - # worst_hours = max of: snapshot completionist, HLTB hours cache (fallback - # when snapshot is stale/missing), and leisure_100h (slowest 100% time). - snap_hours = game.completionist_hours if game.completionist_hours > 0 else -1 - cache_hours = hours_cache.get(game.app_id, -1) - worst_candidates = [v for v in (snap_hours, cache_hours, leisure) if v > 0] - worst = max(worst_candidates) if worst_candidates else -1 - - if worst <= 0 and rush <= 0 and leisure <= 0: - no_data_skipped += 1 - continue - - qualified.append( - _GameTimes( - game=game, - worst_hours=worst, - rush_hours=rush, - leisure_100h=leisure, - hltb_game_id=game_id_cache.get(game.app_id, 0), - ) - ) - - return qualified, hltb_skipped, linux_skipped, no_data_skipped - - -def _ensure_rush_data(qualified: list[_GameTimes]) -> bool: - """Auto-fetch rush/leisure detail for games that are missing it. - - Returns True when a fetch was performed; the caller should then re-run - ``_filter_qualifying_games`` to pick up the updated caches. - """ - total_q = len(qualified) - missing = sum(1 for e in qualified if e.rush_hours <= 0) - if not qualified or not missing: - return False - _echo(f"Fetching HLTB detail for {missing}/{total_q} games missing rush/leisure...") - game_pairs = [(e.game.app_id, e.game.name) for e in qualified] - fetch_hltb_detail_missing(game_pairs) - return True - - -def _print_worst_example(entries: list[_GameTimes]) -> None: - """Print a randomly selected example from the worst-case qualified games.""" - examples = [e for e in entries if e.worst_hours > 0] - if not examples: - return - example = secrets.choice(examples) - _echo(f"\n Example game: {example.game.name!r}") - _echo(f" Worst case: {example.worst_hours:.1f} h") - if example.rush_hours > 0: - _echo(f" Rush: {example.rush_hours:.1f} h") - if example.leisure_100h > 0: - _echo(f" Leisure: {example.leisure_100h:.1f} h") - if example.hltb_game_id > 0: - _echo(f" HLTB: {HLTB_BASE_URL}/game/{example.hltb_game_id}") - else: - _echo(f" HLTB: {_HLTB_SEARCH_BASE}{quote_plus(example.game.name)}") - - -def _sum_hours(entries: list[_GameTimes], attr: str) -> tuple[float, int]: - """Sum a time attribute across entries; return (total_hours, missing_count). - - Games where the attribute is ≤ 0 contribute 0 to the sum and are counted - in ``missing_count`` so the user knows the estimate may be an undercount. - """ - total = 0.0 - missing = 0 - for e in entries: - val: float = getattr(e, attr) - if val > 0: - total += val - else: - missing += 1 - return round(total, 1), missing - - -def _format_completion_date(hours: float, daily_hours: float) -> str: - """Return 'N days (YYYY-MM-DD)' for finishing hours at daily_hours per day.""" - if hours <= 0 or daily_hours <= 0: - return "N/A" - days = int(hours / daily_hours) - target = datetime.now(timezone.utc) + timedelta(days=days) - return f"{days} days ({target.strftime('%Y-%m-%d')})" - - -def _print_scenario( - label: str, - total_hours: float, - missing: int, - total_games: int, -) -> None: - """Print a single time-scenario block.""" - _echo(f"\n {label}") - if total_hours <= 0: - _echo(" No data available.") - return - - missing_note = ( - f" ({missing}/{total_games} games had no data, hours underestimated)" - if missing - else "" - ) - _echo(f" Total: {total_hours:,.1f} h{missing_note}") - for daily in _HOURS_PER_DAY_PRESETS: - estimate = _format_completion_date(total_hours, daily) - _echo(f" @ {daily:.0f} h/day → {estimate}") - - -def _print_pace_scenario(state: State, remaining: int, games_done: int) -> None: - """Print the pace-based completion estimate. - - ``games_done`` should be the count of 100%-complete games in the library - snapshot (``sum(1 for g in games if g.is_complete)``), not the enforcer's - own ``finished_app_ids`` list, which misses games completed outside the - enforcer flow. - """ - _echo("\n 1. AT YOUR CURRENT PACE") - if not state.enforcement_started_at: - _echo(" No start date recorded.") - _echo(" Set enforcement_started_at in state.json (ISO-8601 UTC)") - _echo(" to enable this estimate.") - return - - try: - started = datetime.fromisoformat(state.enforcement_started_at) - except ValueError: - _echo(f" Invalid enforcement_started_at: {state.enforcement_started_at!r}") - return - - now = datetime.now(timezone.utc) - days_elapsed = max(1, (now - started).days) - - if games_done == 0: - _echo(f" Started: {started.strftime('%Y-%m-%d')}") - _echo(" No games finished yet — pace cannot be estimated.") - return - - rate = games_done / days_elapsed - _echo(f" Started: {started.strftime('%Y-%m-%d')}") - _echo(f" Finished: {games_done} games in {days_elapsed} days") - _echo( - f" Pace: {rate:.4f} games/day (1 game every {1 / rate:.1f} days)" - ) - _echo(f" Remaining: {remaining} games") - - days_to_go = int(remaining / rate) - finish = now + timedelta(days=days_to_go) - _echo(f" Est. complete: {days_to_go} days ({finish.strftime('%Y-%m-%d')})") - - -def cmd_stats(_config: Config, state: State) -> None: - """Display backlog completion-time statistics. - - Filters games by the same HLTB-confidence and Linux-compatibility rules - used when picking the next game. Auto-fetches missing rush/leisure detail - data before printing. Shows four scenarios: - - 1. At your current pace (games finished per day since enforcement started). - 2. Rush — avg comp_100 + DLC completion time per HLTB. - 3. Leisure — comp_100_h (slowest 100 %) + DLC leisure per HLTB. - 4. Worst — absolute maximum recorded time (any category) per HLTB. - """ - snapshot = load_snapshot() - if snapshot is None: - _echo("No snapshot found. Run 'scan' first.") - return - - games = [GameInfo.from_snapshot(d) for d in snapshot] - # Count all 100%-achievement games in library (more accurate than - # finished_app_ids, which only tracks enforcer-assigned completions). - games_done = sum(1 for g in games if g.is_complete) - - qualified, hltb_skip, linux_skip, no_data_skip = _filter_qualifying_games( - games, state - ) - if _ensure_rush_data(qualified): - # Re-filter picks up updated rush/leisure caches; ProtonDB is now cached. - qualified, hltb_skip, linux_skip, no_data_skip = _filter_qualifying_games( - games, state - ) - total_q = len(qualified) - - _echo(f"\n{'═' * 70}") - _echo(" BACKLOG COMPLETION ESTIMATES") - _echo(f"{'═' * 70}") - _echo(f"\n Qualifying games: {total_q}") - if hltb_skip: - _echo(f" HLTB-skipped: {hltb_skip} (confidence too low)") - if linux_skip: - _echo(f" Linux-skipped: {linux_skip} (poor ProtonDB rating)") - if no_data_skip: - _echo(f" No-data-skipped: {no_data_skip} (no HLTB hours at all)") - - missing_rush_final = sum(1 for e in qualified if e.rush_hours <= 0) - if missing_rush_final: - _echo( - f"\n Note: {missing_rush_final}/{total_q} games still missing" - " rush/leisure data (HLTB search may not have matched them)." - ) - elif total_q: - _echo( - f"\n Detail data: rush + leisure available for all {total_q}" - " qualifying games." - ) - - if state.current_app_id: - _echo( - f"\n Current game: {state.current_game_name} (excluded from totals)" - ) - _echo(f" Finished games: {games_done} (excluded from totals)") - - _echo(f"\n{_LINE}") - _print_pace_scenario(state, total_q, games_done) - - worst_total, worst_missing = _sum_hours(qualified, "worst_hours") - rush_total, rush_missing = _sum_hours(qualified, "rush_hours") - leisure_total, leisure_missing = _sum_hours(qualified, "leisure_100h") - - _echo(f"\n{_LINE}") - _print_scenario( - "2. RUSH (avg comp_100 + DLC — typical fast completionist)", - rush_total, - rush_missing, - total_q, - ) - - _echo(f"\n{_LINE}") - _print_scenario( - "3. LEISURE (comp_100_h + DLC — slow/comfortable 100 %)", - leisure_total, - leisure_missing, - total_q, - ) - - _echo(f"\n{_LINE}") - _print_scenario( - "4. WORST CASE (max recorded time, any category, + DLC)", - worst_total, - worst_missing, - total_q, - ) - _print_worst_example(qualified) - - _echo(f"\n{_LINE}\n") diff --git a/python_pkg/steam_backlog_enforcer/_whitelist.py b/python_pkg/steam_backlog_enforcer/_whitelist.py deleted file mode 100644 index 3c262f1..0000000 --- a/python_pkg/steam_backlog_enforcer/_whitelist.py +++ /dev/null @@ -1,347 +0,0 @@ -"""Whitelist hardening: time-locked exceptions, reason validation, immutability.""" - -from __future__ import annotations - -from collections import Counter -import contextlib -import json -import logging -import math -import re -import shutil -import subprocess -import time -from typing import TYPE_CHECKING, cast - -from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write - -if TYPE_CHECKING: - from pathlib import Path - -logger = logging.getLogger(__name__) - -# ────────────────────────────────────────────────────────────── -# File paths (patched in tests via conftest) -# ────────────────────────────────────────────────────────────── - -PENDING_EXCEPTIONS_FILE: Path = CONFIG_DIR / "pending_exceptions.json" -APPROVED_EXCEPTIONS_FILE: Path = CONFIG_DIR / "approved_exceptions.json" -EXCEPTION_AUDIT_LOG: Path = CONFIG_DIR / "exception_audit.log" - -# ────────────────────────────────────────────────────────────── -# Constants -# ────────────────────────────────────────────────────────────── - -WHITELIST_COOLDOWN_SECONDS: int = 86400 # 24 hours - -_MIN_REASON_WORDS: int = 5 -_MIN_REASON_LENGTH: int = 25 -_MIN_ENTROPY: float = 3.0 -# Reject runs of the same character longer than this (e.g. "aaaa"). -_MAX_CHAR_RUN: int = 3 - - -# ────────────────────────────────────────────────────────────── -# Reason validation -# ────────────────────────────────────────────────────────────── - - -def _shannon_entropy(text: str) -> float: - """Return Shannon entropy (bits per character) for *text*. - - Whitespace is excluded before counting so spaces don't inflate entropy. - - Args: - text: Input string to measure. - - Returns: - Entropy in bits per character, or 0.0 for empty input. - """ - chars = [c.lower() for c in text if not c.isspace()] - if not chars: - return 0.0 - total = len(chars) - counts = Counter(chars) - return -sum((c / total) * math.log2(c / total) for c in counts.values()) - - -def validate_reason(reason: str) -> str | None: - """Validate that a whitelist exception reason is genuine. - - Returns None when the reason is acceptable, or a human-readable error - string that explains why it was rejected. - - Args: - reason: User-supplied justification text. - - Returns: - None if valid, or an error message string if invalid. - """ - stripped = reason.strip() - - if len(stripped) < _MIN_REASON_LENGTH: - return ( - f"Reason is too short ({len(stripped)} chars; " - f"need at least {_MIN_REASON_LENGTH})." - ) - - words = stripped.split() - if len(words) < _MIN_REASON_WORDS: - return ( - f"Reason must contain at least {_MIN_REASON_WORDS} words " - f"(got {len(words)})." - ) - - entropy = _shannon_entropy(stripped) - if entropy < _MIN_ENTROPY: - return ( - f"Reason appears to be random characters " - f"(entropy {entropy:.2f} < {_MIN_ENTROPY}). " - "Write a genuine justification." - ) - - # Reject runs of the same character: aaaa, bbbbbb, etc. - if re.search(r"(.)\1{3,}", stripped, re.IGNORECASE): - return "Reason contains repeated characters. Write a genuine justification." - - # Reject simple two-character alternating patterns: ababab, asasas, etc. - if re.search(r"(..)(\1){3,}", stripped, re.IGNORECASE): - return "Reason contains repetitive patterns. Write a genuine justification." - - return None - - -# ────────────────────────────────────────────────────────────── -# Immutability helpers -# ────────────────────────────────────────────────────────────── - - -def _try_set_immutable(path: Path, *, immutable: bool) -> None: - """Silently attempt to set or clear the immutable flag on *path*. - - This is a best-effort operation — it fails silently if chattr is not - available, the process lacks the required capability, or the filesystem - does not support the flag. - - Args: - path: File to modify. - immutable: True to set +i, False to clear -i. - """ - if not path.exists(): - return - chattr = shutil.which("chattr") - if chattr is None: - return - flag = "+i" if immutable else "-i" - with contextlib.suppress(OSError, subprocess.TimeoutExpired): - subprocess.run( - [chattr, flag, str(path)], - capture_output=True, - check=False, - timeout=5, - ) - - -def lock_enforcement_files(config_file: Path) -> None: - """Apply chattr +i to enforcement-critical config files. - - Called at the end of each enforce-loop iteration. Requires that the - daemon is running as root (or has CAP_LINUX_IMMUTABLE). - - Args: - config_file: Path to the main config.json. - """ - _try_set_immutable(config_file, immutable=True) - _try_set_immutable(APPROVED_EXCEPTIONS_FILE, immutable=True) - - -def unlock_for_write(path: Path) -> None: - """Clear the immutable flag before writing *path*. - - Args: - path: File to unlock. - """ - _try_set_immutable(path, immutable=False) - - -# ────────────────────────────────────────────────────────────── -# Persistence helpers -# ────────────────────────────────────────────────────────────── - - -def _load_pending() -> list[dict[str, object]]: - """Load pending exception entries from disk.""" - if not PENDING_EXCEPTIONS_FILE.exists(): - return [] - try: - data: object = json.loads(PENDING_EXCEPTIONS_FILE.read_text(encoding="utf-8")) - if isinstance(data, list): - return cast("list[dict[str, object]]", data) - except (json.JSONDecodeError, OSError, ValueError): - pass - return [] - - -def _save_pending(entries: list[dict[str, object]]) -> None: - """Persist pending exception entries to disk.""" - _atomic_write(PENDING_EXCEPTIONS_FILE, json.dumps(entries, indent=2) + "\n") - - -def _load_approved() -> list[dict[str, object]]: - """Load approved exception entries from disk.""" - if not APPROVED_EXCEPTIONS_FILE.exists(): - return [] - try: - data: object = json.loads(APPROVED_EXCEPTIONS_FILE.read_text(encoding="utf-8")) - if isinstance(data, list): - return cast("list[dict[str, object]]", data) - except (json.JSONDecodeError, OSError, ValueError): - pass - return [] - - -def _save_approved(entries: list[dict[str, object]]) -> None: - """Persist approved exception entries to disk.""" - unlock_for_write(APPROVED_EXCEPTIONS_FILE) - _atomic_write(APPROVED_EXCEPTIONS_FILE, json.dumps(entries, indent=2) + "\n") - _try_set_immutable(APPROVED_EXCEPTIONS_FILE, immutable=True) - - -def _append_audit_log(app_id: int, reason: str, event: str) -> None: - """Append one line to the append-only audit log. - - Each line has the format:: - - ISO-TIMESTAMP | EVENT | app_id=NNN | reason='...' - - Args: - app_id: Steam application ID involved. - reason: Justification text supplied by the user. - event: Short event label such as ``REQUESTED`` or ``APPROVED``. - """ - timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()) - line = f"{timestamp} | {event} | app_id={app_id} | reason={reason!r}\n" - EXCEPTION_AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True) - with EXCEPTION_AUDIT_LOG.open("a", encoding="utf-8") as fh: - fh.write(line) - - -# ────────────────────────────────────────────────────────────── -# Public API -# ────────────────────────────────────────────────────────────── - - -def add_pending_exception(app_id: int, reason: str) -> str: - """Request a new whitelist exception for *app_id*. - - The entry becomes active only after ``WHITELIST_COOLDOWN_SECONDS`` have - elapsed (24 h by default). Returns a human-readable status message. - - Args: - app_id: Steam application ID to add. - reason: Validated justification text (must pass :func:`validate_reason`). - - Returns: - Human-readable confirmation or remaining-cooldown message. - - Raises: - ValueError: If the reason fails validation or the ID is already approved. - """ - err = validate_reason(reason) - if err is not None: - raise ValueError(err) - - approved = _load_approved() - if any(int(e["app_id"]) == app_id for e in approved): - msg = f"AppID {app_id} is already in the approved exceptions list." - raise ValueError(msg) - - pending = _load_pending() - for entry in pending: - if int(entry["app_id"]) == app_id: - elapsed = time.time() - float(entry["requested_at"]) - remaining = WHITELIST_COOLDOWN_SECONDS - elapsed - if remaining > 0: - hours = int(remaining // 3600) - mins = int((remaining % 3600) // 60) - return ( - f"AppID {app_id} is already pending; approves in {hours}h {mins}m." - ) - # Cooldown already elapsed for this pending entry — promote now. - break - - entry_new: dict[str, object] = { - "app_id": app_id, - "reason": reason, - "requested_at": time.time(), - } - pending.append(entry_new) - _save_pending(pending) - _append_audit_log(app_id, reason, "REQUESTED") - - hours = WHITELIST_COOLDOWN_SECONDS // 3600 - return ( - f"Exception requested for AppID {app_id}. " - f"Will become active in {hours}h. Reason logged." - ) - - -def promote_pending_exceptions() -> list[int]: - """Move cooldown-elapsed pending entries to the approved list. - - Called by the enforce daemon on each loop iteration. Returns the list - of app IDs that were promoted this call. - - Returns: - List of newly approved app IDs (may be empty). - """ - pending = _load_pending() - now = time.time() - still_pending: list[dict[str, object]] = [] - newly_approved: list[int] = [] - - for entry in pending: - elapsed = now - float(entry["requested_at"]) - if elapsed >= WHITELIST_COOLDOWN_SECONDS: - app_id = int(entry["app_id"]) - approved = _load_approved() - if not any(int(e["app_id"]) == app_id for e in approved): - approved.append( - { - "app_id": app_id, - "reason": entry["reason"], - "approved_at": now, - } - ) - _save_approved(approved) - _append_audit_log(app_id, str(entry["reason"]), "APPROVED") - newly_approved.append(app_id) - else: - still_pending.append(entry) - - if len(still_pending) != len(pending): - _save_pending(still_pending) - - return newly_approved - - -def get_approved_exception_ids() -> frozenset[int]: - """Return the frozenset of currently approved exception app IDs. - - Does NOT trigger promotion — call :func:`promote_pending_exceptions` - explicitly when timely promotion is required (e.g. the enforce loop). - - Returns: - Frozenset of approved app IDs. - """ - approved = _load_approved() - return frozenset(int(e["app_id"]) for e in approved) - - -def list_pending_exceptions() -> list[dict[str, object]]: - """Return a copy of the current pending exception list. - - Returns: - List of pending exception dicts with keys app_id, reason, requested_at. - """ - return list(_load_pending()) diff --git a/python_pkg/steam_backlog_enforcer/config.py b/python_pkg/steam_backlog_enforcer/config.py deleted file mode 100644 index 4425180..0000000 --- a/python_pkg/steam_backlog_enforcer/config.py +++ /dev/null @@ -1,180 +0,0 @@ -"""Configuration management for Steam Backlog Enforcer.""" - -from __future__ import annotations - -import contextlib -from dataclasses import dataclass, field -from datetime import datetime, timedelta, timezone -import json -import logging -import os -from pathlib import Path -import sys -import tempfile -from typing import Any - -CONFIG_DIR = Path.home() / ".config" / "steam_backlog_enforcer" -CONFIG_FILE = CONFIG_DIR / "config.json" -STATE_FILE = CONFIG_DIR / "state.json" -SNAPSHOT_FILE = CONFIG_DIR / "snapshot.json" -LOG_FILE = CONFIG_DIR / "enforcer.log" - -# Steam store domains to block. -BLOCKED_DOMAINS = [ - "store.steampowered.com", - "checkout.steampowered.com", - "store.akamai.steamstatic.com", - "storefront.steampowered.com", - "store.cloudflare.steamstatic.com", -] - -HOSTS_FILE = Path("/etc/hosts") - -logger = logging.getLogger(__name__) - - -def _atomic_write(path: Path, data: str) -> None: - """Write data to a file atomically via a temporary file + rename.""" - path.parent.mkdir(parents=True, exist_ok=True) - fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp") - tmp_path = Path(tmp) - try: - os.write(fd, data.encode("utf-8")) - os.close(fd) - tmp_path.replace(path) - except BaseException: - with contextlib.suppress(OSError): - os.close(fd) - with contextlib.suppress(OSError): - tmp_path.unlink() - raise - - -@dataclass -class Config: - """User configuration.""" - - steam_api_key: str = "" - steam_id: str = "" - block_store: bool = True - kill_unauthorized_games: bool = True - uninstall_other_games: bool = True - desktop_notifications: bool = True - - def save(self) -> None: - """Persist config to disk.""" - _atomic_write( - CONFIG_FILE, - json.dumps(self.__dict__, indent=2) + "\n", - ) - - @classmethod - def load(cls) -> Config: - """Load config from disk, or return defaults.""" - if CONFIG_FILE.exists(): - data = json.loads(CONFIG_FILE.read_text(encoding="utf-8")) - return cls( - **{k: v for k, v in data.items() if k in cls.__dataclass_fields__} - ) - return cls() - - -@dataclass -class State: - """Persistent state across runs.""" - - current_app_id: int | None = None - current_game_name: str = "" - finished_app_ids: list[int] = field(default_factory=list) - skipped_until: dict[str, str] = field(default_factory=dict) - enforcement_started_at: str = "" - """ISO-8601 UTC timestamp set on the first game assignment.""" - """Map of ``str(app_id)`` → ISO-8601 UTC timestamp when the skip expires. - - Games in this map are excluded from auto-assignment until the timestamp - elapses. Populated when the user declines a freshly-picked game via the - interactive prompt in ``cmd_done``. - """ - - def skip_for_days(self, app_id: int, days: int) -> None: - """Mark ``app_id`` as skipped for ``days`` days from now (UTC).""" - expires = datetime.now(timezone.utc) + timedelta(days=days) - self.skipped_until[str(app_id)] = expires.isoformat() - - def active_skipped_ids(self) -> set[int]: - """Return currently-skipped app IDs and prune expired entries. - - Mutates ``self.skipped_until`` to drop expired or malformed entries. - Callers should ``save()`` if they want the prune persisted. - """ - now = datetime.now(timezone.utc) - active: set[int] = set() - to_remove: list[str] = [] - for aid_str, ts in self.skipped_until.items(): - try: - expiry = datetime.fromisoformat(ts) - except ValueError: - to_remove.append(aid_str) - continue - if expiry > now: - active.add(int(aid_str)) - else: - to_remove.append(aid_str) - for aid_str in to_remove: - del self.skipped_until[aid_str] - return active - - def save(self) -> None: - """Persist state to disk.""" - _atomic_write( - STATE_FILE, - json.dumps(self.__dict__, indent=2) + "\n", - ) - - @classmethod - def load(cls) -> State: - """Load state from disk, or return defaults.""" - if STATE_FILE.exists(): - try: - data = json.loads(STATE_FILE.read_text(encoding="utf-8")) - except (json.JSONDecodeError, OSError, ValueError): - logger.warning("Corrupt state file, using defaults.") - return cls() - return cls( - **{k: v for k, v in data.items() if k in cls.__dataclass_fields__} - ) - return cls() - - -def save_snapshot(data: list[dict[str, Any]]) -> None: - """Save an achievement snapshot to disk.""" - _atomic_write( - SNAPSHOT_FILE, - json.dumps(data, indent=2) + "\n", - ) - - -def load_snapshot() -> list[dict[str, Any]] | None: - """Load the cached achievement snapshot, or None if absent.""" - if SNAPSHOT_FILE.exists(): - result: list[dict[str, Any]] = json.loads( - SNAPSHOT_FILE.read_text(encoding="utf-8") - ) - return result - return None - - -def interactive_setup() -> Config: - """Run first-time interactive setup.""" - api_key = input("Enter your Steam Web API key: ").strip() - if not api_key: - sys.exit(1) - - steam_id = input("Enter your Steam64 ID: ").strip() - if not steam_id: - sys.exit(1) - - config = Config(steam_api_key=api_key, steam_id=steam_id) - config.save() - CONFIG_FILE.chmod(0o600) - return config diff --git a/python_pkg/steam_backlog_enforcer/enforcer.py b/python_pkg/steam_backlog_enforcer/enforcer.py deleted file mode 100644 index ddf775d..0000000 --- a/python_pkg/steam_backlog_enforcer/enforcer.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Enforce that only the assigned game may run.""" - -from __future__ import annotations - -import logging -import os -from pathlib import Path -import shutil -import signal -import subprocess - -from python_pkg.steam_backlog_enforcer.game_install import ( - is_protected_app, -) - -logger = logging.getLogger(__name__) - - -def get_running_steam_game_pids() -> dict[int, int]: - """Scan /proc to find running Steam game processes. - - Returns: dict mapping PID -> SteamAppId. - """ - running: dict[int, int] = {} - proc = Path("/proc") - - for entry in proc.iterdir(): - if not entry.name.isdigit(): - continue - try: - environ = (entry / "environ").read_bytes() - pairs = environ.split(b"\x00") - for pair in pairs: - if pair.startswith(b"SteamAppId="): - value = pair.split(b"=", 1)[1].decode("utf-8", errors="replace") - if value.isdigit(): - running[int(entry.name)] = int(value) - break - except (PermissionError, OSError, ValueError): - continue - - return running - - -def enforce_allowed_game( - allowed_app_id: int | None, - *, - kill_unauthorized: bool = True, -) -> list[tuple[int, int]]: - """Check running games; optionally kill unauthorized ones. - - Returns list of (pid, app_id) that were killed or detected. - """ - if allowed_app_id is None: - return [] - running = get_running_steam_game_pids() - violations: list[tuple[int, int]] = [] - - for pid, app_id in running.items(): - if allowed_app_id is not None and app_id == allowed_app_id: - continue - # Skip Steam client itself (app_id 0 or very low IDs). - if app_id == 0: - continue - if is_protected_app(app_id): - continue - - violations.append((pid, app_id)) - if kill_unauthorized: - kill_process(pid, app_id) - - return violations - - -def kill_process(pid: int, app_id: int) -> None: - """Kill a process by PID.""" - try: - logger.warning("Killing unauthorized game (AppID=%d, PID=%d)", app_id, pid) - os.kill(pid, signal.SIGTERM) - except ProcessLookupError: - logger.debug("Process %d already gone.", pid) - except PermissionError: - logger.exception("No permission to kill PID %d.", pid) - - -def send_notification(title: str, body: str) -> None: - """Send a desktop notification.""" - _notify_send = shutil.which("notify-send") or "/usr/bin/notify-send" - try: - subprocess.run( - [_notify_send, title, body, "--icon=dialog-warning"], - capture_output=True, - timeout=5, - check=False, - ) - except (FileNotFoundError, OSError): - logger.debug("notify-send not available.") diff --git a/python_pkg/steam_backlog_enforcer/game_install.py b/python_pkg/steam_backlog_enforcer/game_install.py deleted file mode 100644 index c786f5c..0000000 --- a/python_pkg/steam_backlog_enforcer/game_install.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Game installation and uninstallation management.""" - -from __future__ import annotations - -import contextlib -import logging -import os -from pathlib import Path -import pwd -import re -import shutil -import subprocess -import sys -import time - -from python_pkg.steam_backlog_enforcer._whitelist import get_approved_exception_ids - -logger = logging.getLogger(__name__) - -# Real Steam directory — used as a safety check to block destructive -# operations that leak through during testing. -_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser() - - -def _assert_not_real_steam(path: Path) -> None: - """Raise if *path* is inside the real Steam directory during tests. - - Defence-in-depth guard: when running under pytest, even if test - fixtures fail to redirect ``STEAMAPPS_PATH``, destructive - operations (uninstall, rmtree, unlink) will refuse to touch - real files. In production runs this is a no-op. - """ - if "PYTEST_CURRENT_TEST" not in os.environ: - return # production run — real Steam paths are expected - try: - path.resolve().relative_to(_REAL_STEAMAPPS.resolve()) - except ValueError: - return # path is NOT under real Steam — safe to proceed - if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve(): - msg = ( - f"SAFETY: refusing destructive operation on real Steam path " - f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures" - ) - raise RuntimeError(msg) - - -def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None: - """Write user-facing CLI output to stdout. - - Args: - msg: Text to output. - end: String appended after the message. - flush: Whether to flush stdout immediately. - """ - sys.stdout.write(msg + end) - if flush: - sys.stdout.flush() - - -# Steam infrastructure app IDs that should NEVER be uninstalled. -PROTECTED_APP_IDS = { - 228980, # Steamworks Common Redistributables - 1070560, # Steam Linux Runtime 1.0 (scout) - 1391110, # Steam Linux Runtime 2.0 (soldier) - 1628350, # Steam Linux Runtime 3.0 (sniper) - 961940, # Steam Linux Runtime (legacy) - # Proton versions (never uninstall these) - 858280, # Proton 3.7 (Beta) - 930400, # Proton 3.16 (Beta) - 1054830, # Proton 4.2 - 1113280, # Proton 4.11 - 1245040, # Proton 5.0 - 1420170, # Proton 5.13 - 1580130, # Proton 6.3 - 1887720, # Proton 7.0 - 2230260, # Proton 7.0 (alt) - 2348590, # Proton 8.0 - 2805730, # Proton 9.0 - 3201940, # Proton 9.0 (alt) - 3658110, # Proton 10.0 - 2180100, # Proton Hotfix - 1493710, # Proton Experimental - 1161040, # Proton BattlEye Runtime - 1007020, # Proton EasyAntiCheat Runtime -} - -STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser() - - -def _trigger_steam_install(app_id: int, label: str) -> bool: - """Ask Steam to install a game via the ``steam://install`` URI. - - Returns True if the URI handler was invoked successfully. - """ - xdg_open = shutil.which("xdg-open") or "/usr/bin/xdg-open" - try: - subprocess.run( - [xdg_open, f"steam://install/{app_id}"], - capture_output=True, - timeout=15, - check=False, - ) - except (FileNotFoundError, OSError, subprocess.TimeoutExpired): - return False - else: - logger.info("Triggered Steam install for %s via protocol handler", label) - return True - - -# ────────────────────────────────────────────────────────────── -# Game install management -# ────────────────────────────────────────────────────────────── - - -def _get_real_user() -> str | None: - """Get the real (non-root) user when running under sudo.""" - return os.environ.get("SUDO_USER") or os.environ.get("USER") - - -def _get_uid_gid_for_user(username: str) -> tuple[int, int]: - """Get (uid, gid) for a username.""" - try: - pw = pwd.getpwnam(username) - except KeyError: - return 1000, 1000 - else: - return pw.pw_uid, pw.pw_gid - - -def is_game_installed(app_id: int) -> bool: - """Check if a game is installed by looking for its appmanifest. - - A manifest with StateFlags != 4 (FullyInstalled) means the game is - still downloading or queued, which still counts as "install triggered". - """ - manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" - return manifest.exists() - - -def _ensure_steam_running() -> None: - """Start the Steam client if it is not already running.""" - # Check if any steam process is running (main client, not just helpers). - try: - result = subprocess.run( - ["/usr/bin/pgrep", "-f", "steam.sh"], - capture_output=True, - text=True, - check=False, - ) - if result.returncode == 0: - logger.debug("Steam client already running") - return - except FileNotFoundError: - pass - - real_user = _get_real_user() - logger.info("Starting Steam client...") - - try: - if os.geteuid() == 0 and real_user and real_user != "root": - uid, _ = _get_uid_gid_for_user(real_user) - dbus_default = f"unix:path=/run/user/{uid}/bus" - dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default) - xauth_default = f"/home/{real_user}/.Xauthority" - xauth = os.environ.get("XAUTHORITY", xauth_default) - cmd = [ - "sudo", - "-u", - real_user, - "env", - f"DISPLAY={os.environ.get('DISPLAY', ':0')}", - f"XAUTHORITY={xauth}", - f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}", - "steam", - "-silent", - ] - else: - cmd = ["steam", "-silent"] - - subprocess.Popen( - cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) - # Give Steam time to initialize and start scanning manifests. - time.sleep(15) - except FileNotFoundError: - logger.exception("Steam executable not found") - - -def install_game( - app_id: int, - game_name: str, - steam_id: str, - *, - use_steam_protocol: bool = False, -) -> bool: - """Install a game by triggering a Steam download. - - When *use_steam_protocol* is True the ``steam://install`` URI handler - is used, which lets Steam determine the correct install directory from - its own metadata. This avoids mismatches between the display name and - the canonical ``installdir`` that can cause "Missing game executable" - errors. Falls back to writing a fabricated appmanifest if the URI - handler is unavailable. - - When *use_steam_protocol* is False (the default) a minimal - appmanifest with StateFlags=1026 is written directly. This is - suitable for non-interactive / daemon contexts where opening a Steam - dialog is undesirable. - - Args: - app_id: Steam application ID. - game_name: Human-readable game name. - steam_id: Steam64 ID of the account that owns the game. - use_steam_protocol: Prefer the ``steam://install`` URI handler. - - Returns True if the install was triggered successfully. - """ - label = game_name or f"AppID={app_id}" - - if is_game_installed(app_id): - logger.info("Game already installed: %s", label) - return True - - if use_steam_protocol: - _ensure_steam_running() - if _trigger_steam_install(app_id, label): - return True - logger.debug("steam:// protocol failed; falling back to manifest") - - # Build a minimal appmanifest. StateFlags 1026 = UpdateRequired (2) + - # UpdateStarted (1024), which tells Steam "this app needs downloading". - manifest_content = ( - '"AppState"\n' - "{\n" - f'\t"appid"\t\t"{app_id}"\n' - '\t"universe"\t\t"1"\n' - f'\t"name"\t\t"{game_name}"\n' - '\t"StateFlags"\t\t"1026"\n' - f'\t"installdir"\t\t"{game_name}"\n' - '\t"LastUpdated"\t\t"0"\n' - '\t"LastPlayed"\t\t"0"\n' - '\t"SizeOnDisk"\t\t"0"\n' - '\t"StagingSize"\t\t"0"\n' - '\t"buildid"\t\t"0"\n' - f'\t"LastOwner"\t\t"{steam_id}"\n' - '\t"UpdateResult"\t\t"0"\n' - '\t"BytesToDownload"\t\t"0"\n' - '\t"BytesDownloaded"\t\t"0"\n' - '\t"BytesToStage"\t\t"0"\n' - '\t"BytesStaged"\t\t"0"\n' - '\t"TargetBuildID"\t\t"0"\n' - '\t"AutoUpdateBehavior"\t\t"0"\n' - '\t"AllowOtherDownloadsWhileRunning"\t\t"0"\n' - '\t"ScheduledAutoUpdate"\t\t"0"\n' - '\t"InstalledDepots"\n' - "\t{\n" - "\t}\n" - '\t"UserConfig"\n' - "\t{\n" - "\t}\n" - '\t"MountedConfig"\n' - "\t{\n" - "\t}\n" - "}\n" - ) - - manifest_path = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" - - try: - with manifest_path.open("w", encoding="utf-8") as fh: - fh.write(manifest_content) - - # Fix ownership so the Steam client (running as the real user) can - # read and update the manifest. - real_user = _get_real_user() - if os.geteuid() == 0 and real_user and real_user != "root": - uid, gid = _get_uid_gid_for_user(real_user) - os.chown(manifest_path, uid, gid) - - logger.info("Created appmanifest for %s — Steam will auto-download", label) - except OSError: - logger.exception("Failed to create appmanifest for %s", label) - return False - - # Make sure Steam is running so it picks up the manifest. - _ensure_steam_running() - - return True - - -# ────────────────────────────────────────────────────────────── -# Game uninstall management -# ────────────────────────────────────────────────────────────── - - -def get_installed_games() -> list[tuple[int, str]]: - """Parse appmanifest files to find installed games. - - Returns: list of (app_id, game_name) tuples. - """ - installed: list[tuple[int, str]] = [] - - for manifest_file in STEAMAPPS_PATH.glob("appmanifest_*.acf"): - with contextlib.suppress(OSError): - content = manifest_file.read_text(encoding="utf-8") - app_id_match = re.search(r'"appid"\s+"(\d+)"', content) - name_match = re.search(r'"name"\s+"([^"]+)"', content) - if app_id_match: - app_id = int(app_id_match.group(1)) - name = name_match.group(1) if name_match else f"Unknown ({app_id})" - installed.append((app_id, name)) - - installed.sort(key=lambda x: x[1].lower()) - return installed - - -def _read_install_dir(manifest: Path) -> Path | None: - """Read installdir from a game's appmanifest file.""" - if not manifest.exists(): - return None - try: - content = manifest.read_text(encoding="utf-8") - match = re.search(r'"installdir"\s+"([^"]+)"', content) - if match: - return STEAMAPPS_PATH / "common" / match.group(1) - except OSError: - pass - return None - - -def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool: - """Remove a game manifest file. - - Args: - manifest: Path to the appmanifest file. - game_name: Human-readable game name for logging. - app_id: Steam application ID. - """ - _assert_not_real_steam(manifest) - try: - if manifest.exists(): - manifest.unlink() - logger.info( - "Removed manifest for %s (AppID=%d)", game_name or app_id, app_id - ) - except OSError: - logger.exception("Failed to remove manifest for AppID=%d", app_id) - return False - return True - - -def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool: - """Remove game installation directory and cache directories. - - Args: - install_dir: Path to the game's install directory, or None. - app_id: Steam application ID. - """ - success = True - if install_dir and install_dir.is_dir(): - _assert_not_real_steam(install_dir) - try: - shutil.rmtree(install_dir) - logger.info("Removed game files: %s", install_dir) - except OSError: - logger.exception("Failed to remove game dir %s", install_dir) - success = False - - for subdir in ("shadercache", "compatdata"): - cache_path = STEAMAPPS_PATH / subdir / str(app_id) - if cache_path.is_dir(): - _assert_not_real_steam(cache_path) - with contextlib.suppress(OSError): - shutil.rmtree(cache_path) - logger.debug("Removed %s/%d", subdir, app_id) - - return success - - -def uninstall_game(app_id: int, game_name: str = "") -> bool: - """Uninstall a single game by removing its manifest and game files. - - Uses direct file removal instead of ``steam://uninstall`` URI to avoid - GUI popups and to work when Steam is not running. - """ - manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf" - install_dir = _read_install_dir(manifest) - success = _remove_manifest(manifest, game_name, app_id) - if not _remove_game_dirs(install_dir, app_id): - success = False - return success - - -def uninstall_other_games(allowed_app_id: int | None) -> int: - """Uninstall all installed games except the assigned one and protected IDs. - - Returns: number of games uninstalled. - """ - installed = get_installed_games() - count = 0 - - for app_id, name in installed: - if app_id == allowed_app_id: - logger.info("KEEPING assigned game: %s (AppID=%d)", name, app_id) - continue - if is_protected_app(app_id): - logger.debug("Skipping protected: %s (AppID=%d)", name, app_id) - continue - - logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id) - if uninstall_game(app_id, name): - count += 1 - - return count - - -def is_protected_app(app_id: int) -> bool: - """Return True if *app_id* must never be uninstalled. - - Combines the hardcoded Steam infrastructure set with any app IDs that - have been approved via the time-locked exception mechanism. - - Args: - app_id: Steam application ID to check. - - Returns: - True if the app should be left alone by the enforcer. - """ - return app_id in PROTECTED_APP_IDS or app_id in get_approved_exception_ids() diff --git a/python_pkg/steam_backlog_enforcer/hltb.py b/python_pkg/steam_backlog_enforcer/hltb.py deleted file mode 100644 index 16f8a84..0000000 --- a/python_pkg/steam_backlog_enforcer/hltb.py +++ /dev/null @@ -1,359 +0,0 @@ -"""HowLongToBeat integration for estimating game completion times. - -Fetches leisure completionist hour estimates from howlongtobeat.com with: -- direct API calls (bypassing the slow howlongtobeatpy per-request setup) -- single shared aiohttp session for all requests -- concurrent requests with configurable concurrency -- live progress reporting via callback -- incremental disk-cache saves so crashes don't lose work -- leisure time (upper-bound play time) from individual game pages -- DLC time aggregation (base game + all DLC leisure times combined) -""" - -from __future__ import annotations - -import asyncio -import logging -import time - -import aiohttp - -from python_pkg.steam_backlog_enforcer._hltb_search import ( - _fetch_batch, - _get_auth_info, - _get_hltb_search_url, - _search_one, - _SearchCtx, -) -from python_pkg.steam_backlog_enforcer._hltb_types import ( - HLTB_BASE_URL, - MAX_CONCURRENT, - HLTBResult, - ProgressCb, - _HLTBExtras, - load_hltb_cache, - load_hltb_count_comp_cache, - load_hltb_game_id_cache, - load_hltb_leisure_100h_cache, - load_hltb_polls_cache, - load_hltb_rush_cache, - save_hltb_cache, -) - -logger = logging.getLogger(__name__) - - -# ────────────────────────────────────────────────────────────── -# Confidence-only batch fetch (no leisure/DLC detail pages) -# ────────────────────────────────────────────────────────────── -async def _fetch_batch_confidence_only( - games: list[tuple[int, str]], - cache: dict[int, float], - polls: dict[int, int], - progress_cb: ProgressCb | None, - count_comp: dict[int, int] | None = None, -) -> list[HLTBResult]: - """Fetch only search-level HLTB data (hours + confidence), no detail pages.""" - # 1. Discover the search URL (sync, one-time). - search_url = _get_hltb_search_url() - logger.info("HLTB search URL: %s", search_url) - - timeout = aiohttp.ClientTimeout(total=20, sock_read=15) - - # 2. Get auth info (separate session — avoids reuse issues). - async with aiohttp.ClientSession(timeout=timeout) as init_session: - auth = await _get_auth_info(search_url, init_session) - if auth is None: - logger.warning("Could not get HLTB auth info, aborting fetch.") - return [] - logger.info("HLTB auth token acquired.") - - # 3. Build shared headers for all search requests. - headers: dict[str, str] = { - "content-type": "application/json", - "accept": "*/*", - "User-Agent": ( - "Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0" - ), - "referer": "https://howlongtobeat.com/", - "x-auth-token": auth.token, - } - if auth.hp_key: - headers["x-hp-key"] = auth.hp_key - headers["x-hp-val"] = auth.hp_val - - # 4. Fire all searches through a single persistent session. - sem = asyncio.Semaphore(MAX_CONCURRENT) - counter = {"done": 0, "found": 0} - total = len(games) - - if count_comp is None: - count_comp = {} - - connector = aiohttp.TCPConnector( - limit=MAX_CONCURRENT, - keepalive_timeout=30, - ) - async with aiohttp.ClientSession( - timeout=timeout, - connector=connector, - ) as session: - ctx = _SearchCtx( - session=session, - search_url=search_url, - headers=headers, - cache=cache, - polls=polls, - count_comp=count_comp, - auth=auth, - counter=counter, - total=total, - progress_cb=progress_cb, - ) - tasks = [ - _search_one( - sem, - ctx, - app_id, - name, - ) - for app_id, name in games - ] - results = await asyncio.gather(*tasks) - - return [r for r in results if r is not None] - - -def fetch_hltb_times( - games: list[tuple[int, str]], - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: ProgressCb | None = None, - extras: _HLTBExtras | None = None, -) -> list[HLTBResult]: - """Synchronous wrapper: fetch HLTB times for games.""" - if not games: - return [] - if cache is None: - cache = {} - if polls is None: - polls = {} - return asyncio.run( - _fetch_batch( - games, - cache, - polls, - progress_cb, - extras=extras, - ) - ) - - -def fetch_hltb_confidence( - games: list[tuple[int, str]], - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: ProgressCb | None = None, - count_comp: dict[int, int] | None = None, -) -> list[HLTBResult]: - """Fetch only HLTB search-level data (hours + confidence metrics).""" - if not games: - return [] - if cache is None: - cache = {} - if polls is None: - polls = {} - if count_comp is None: - count_comp = {} - return asyncio.run( - _fetch_batch_confidence_only( - games, - cache, - polls, - progress_cb, - count_comp=count_comp, - ) - ) - - -def fetch_hltb_times_cached( - games: list[tuple[int, str]], - progress_cb: ProgressCb | None = None, -) -> dict[int, float]: - """Fetch HLTB times, using disk cache for already-known games. - - Args: - games: list of (app_id, name) tuples to look up. - progress_cb: optional callback(done, total, found, game_name). - - Returns: dict mapping app_id -> completionist_hours. - """ - cache = load_hltb_cache() - polls = load_hltb_polls_cache() - extras = _HLTBExtras( - count_comp=load_hltb_count_comp_cache(), - rush=load_hltb_rush_cache(), - leisure_100h=load_hltb_leisure_100h_cache(), - ) - uncached = [(app_id, name) for app_id, name in games if app_id not in cache] - - if uncached: - logger.info( - "Fetching HLTB data for %d uncached games (%d cached)...", - len(uncached), - len(games) - len(uncached), - ) - t0 = time.monotonic() - fetch_hltb_times( - uncached, - cache=cache, - polls=polls, - progress_cb=progress_cb, - extras=extras, - ) - elapsed = time.monotonic() - t0 - - # Final save. - save_hltb_cache(cache, polls, extras) - - found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0) - rate = len(uncached) / elapsed if elapsed > 0 else 0 - logger.info( - "HLTB fetch done: %d/%d found in %.1fs (%.0f games/s)", - found, - len(uncached), - elapsed, - rate, - ) - else: - logger.info("All %d games found in HLTB cache.", len(games)) - - return cache - - -def fetch_hltb_confidence_cached( - games: list[tuple[int, str]], - progress_cb: ProgressCb | None = None, -) -> dict[int, float]: - """Fetch HLTB search-level confidence data, using disk cache for known IDs.""" - cache = load_hltb_cache() - polls = load_hltb_polls_cache() - count_comp = load_hltb_count_comp_cache() - uncached = [(app_id, name) for app_id, name in games if app_id not in cache] - - if uncached: - logger.info( - "Fetching HLTB confidence for %d uncached games (%d cached)...", - len(uncached), - len(games) - len(uncached), - ) - t0 = time.monotonic() - fetch_hltb_confidence( - uncached, - cache=cache, - polls=polls, - progress_cb=progress_cb, - count_comp=count_comp, - ) - elapsed = time.monotonic() - t0 - - save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp)) - - found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0) - rate = len(uncached) / elapsed if elapsed > 0 else 0 - logger.info( - "HLTB confidence fetch done: %d/%d found in %.1fs (%.0f games/s)", - found, - len(uncached), - elapsed, - rate, - ) - else: - logger.info("All %d games found in HLTB cache.", len(games)) - - return cache - - -def fetch_hltb_detail_missing( - games: list[tuple[int, str]], - progress_cb: ProgressCb | None = None, -) -> int: - """Fetch HLTB detail (rush + leisure) for games that are missing it. - - Games already in the rush cache are skipped. For the rest, temporarily - removes them from the hours cache so ``fetch_hltb_times`` will visit their - detail pages. Restores prior hours for any game the re-fetch doesn't find. - - Args: - games: list of (app_id, name) tuples to check. - progress_cb: optional progress callback. - - Returns: - Number of games that now have rush-hour data after the fetch. - """ - rush = load_hltb_rush_cache() - missing = [(app_id, name) for app_id, name in games if rush.get(app_id, -1) <= 0] - if not missing: - return 0 - - cache = load_hltb_cache() - polls = load_hltb_polls_cache() - extras = _HLTBExtras( - count_comp=load_hltb_count_comp_cache(), - rush=rush, - leisure_100h=load_hltb_leisure_100h_cache(), - hltb_game_id=load_hltb_game_id_cache(), - ) - - # Remove from hours cache so fetch_hltb_times will visit the detail page. - prior_hours: dict[int, float] = {} - for app_id, _ in missing: - prior_hours[app_id] = cache.pop(app_id, -1.0) - - logger.info( - "Fetching HLTB detail for %d games missing rush/leisure data...", - len(missing), - ) - t0 = time.monotonic() - fetch_hltb_times( - missing, - cache=cache, - polls=polls, - progress_cb=progress_cb, - extras=extras, - ) - elapsed = time.monotonic() - t0 - - # Restore prior hours for games the detail fetch didn't re-find. - for app_id, old_hours in prior_hours.items(): - if old_hours > 0 and cache.get(app_id, -1.0) <= 0: - cache[app_id] = old_hours - - save_hltb_cache(cache, polls, extras) - - fetched = sum(1 for app_id, _ in missing if extras.rush.get(app_id, -1) > 0) - rate = len(missing) / elapsed if elapsed > 0 else 0 - logger.info( - "HLTB detail fetch done: %d/%d got rush data in %.1fs (%.0f games/s)", - fetched, - len(missing), - elapsed, - rate, - ) - return fetched - - -def get_hltb_submit_url(game_name: str) -> str | None: - """Look up a game on HLTB and return its submit page URL. - - Args: - game_name: Name of the game to search for. - - Returns: - URL like ``https://howlongtobeat.com/submit/game/12345``, - or ``None`` if the game wasn't found. - """ - results = fetch_hltb_times([(0, game_name)]) - if results and results[0].hltb_game_id: - return f"{HLTB_BASE_URL}/submit/game/{results[0].hltb_game_id}" - return None diff --git a/python_pkg/steam_backlog_enforcer/install.sh b/python_pkg/steam_backlog_enforcer/install.sh deleted file mode 100755 index 78c9762..0000000 --- a/python_pkg/steam_backlog_enforcer/install.sh +++ /dev/null @@ -1,41 +0,0 @@ -#!/usr/bin/env bash -# Install script for Steam Backlog Enforcer. -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)" - -echo "=== Steam Backlog Enforcer Installer ===" -echo - -# Install Python deps. -echo "Installing Python dependencies..." -pip3 install --break-system-packages requests howlongtobeatpy 2>/dev/null \ - || pip3 install requests howlongtobeatpy - -# Install systemd service (system-level, runs as root). -read -rp "Install systemd enforce service? [y/N] " ans -if [[ "${ans,,}" == "y" ]]; then - if [[ $EUID -ne 0 ]]; then - echo "Error: systemd service install needs root. Re-run with sudo." - exit 1 - fi - - SERVICE_SRC="$SCRIPT_DIR/steam-backlog-enforcer.service" - SERVICE_DST="/etc/systemd/system/steam-backlog-enforcer.service" - - # Set the correct working directory in the service file. - sed "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$SERVICE_SRC" \ - > "$SERVICE_DST" - - systemctl daemon-reload - systemctl enable steam-backlog-enforcer - echo "Service installed and enabled." - echo " Start now: sudo systemctl start steam-backlog-enforcer" - echo " Check: sudo systemctl status steam-backlog-enforcer" - echo " Logs: sudo journalctl -u steam-backlog-enforcer -f" -fi - -echo -echo "Done! Run manually with:" -echo " sudo python3 -m python_pkg.steam_backlog_enforcer.main enforce" diff --git a/python_pkg/steam_backlog_enforcer/library_hider.py b/python_pkg/steam_backlog_enforcer/library_hider.py deleted file mode 100644 index e4f62d2..0000000 --- a/python_pkg/steam_backlog_enforcer/library_hider.py +++ /dev/null @@ -1,387 +0,0 @@ -"""Hide / unhide games in the Steam library via Chrome DevTools Protocol. - -Modern Steam clients (2023+) use an internal ``collectionStore`` JS -object running inside the CEF (Chromium Embedded Framework) browser. -Game collections (including "hidden") are synced to Steam Cloud and -can only be reliably modified through this API. - -This module connects to Steam's ``SharedJSContext`` page over CDP -(Chrome DevTools Protocol) on a local debug port and evaluates -JavaScript to call ``collectionStore.SetAppsAsHidden()``. - -Steam must be running with ``-cef-enable-debugging`` and -``-devtools-port=`` for this to work. If it isn't, the module -will shut Steam down and relaunch it with the required flags. -""" - -from __future__ import annotations - -import asyncio -import json -import logging -import os -import pwd -import shutil -import subprocess -import time - -import requests -import websockets - -logger = logging.getLogger(__name__) - -_CDP_PORT = 8080 -_CDP_TIMEOUT = 120 -_STEAM_STARTUP_WAIT = 45 - - -# ────────────────────────────────────────────────────────────── -# CDP (Chrome DevTools Protocol) helpers -# ────────────────────────────────────────────────────────────── - - -def _get_shared_js_ws_url() -> str | None: - """Query the CDP HTTP endpoint and return the SharedJSContext WS URL.""" - try: - resp = requests.get(f"http://127.0.0.1:{_CDP_PORT}/json", timeout=5) - targets = resp.json() - except (OSError, ValueError): - return None - - for target in targets: - if target.get("title") == "SharedJSContext": - ws_url: str = target["webSocketDebuggerUrl"] - return ws_url - return None - - -async def _evaluate_js_async(ws_url: str, expression: str) -> dict: - """Connect to a CDP WebSocket target and evaluate *expression*.""" - async with websockets.connect(ws_url) as ws: - msg = json.dumps( - { - "id": 1, - "method": "Runtime.evaluate", - "params": { - "expression": expression, - "returnByValue": True, - "awaitPromise": True, - }, - } - ) - await ws.send(msg) - resp = await asyncio.wait_for(ws.recv(), timeout=_CDP_TIMEOUT) - return json.loads(resp) - - -def _evaluate_js(expression: str) -> dict: - """Synchronous wrapper around :func:`_evaluate_js_async`.""" - ws_url = _get_shared_js_ws_url() - if ws_url is None: - msg = "SharedJSContext not found on CDP port" - raise RuntimeError(msg) - return asyncio.run(_evaluate_js_async(ws_url, expression)) - - -def _cdp_result_value(result: dict) -> str: - """Extract the return value from a CDP Runtime.evaluate response.""" - outer = result.get("result", {}) - inner = outer.get("result", {}) - if "exceptionDetails" in outer: - exc_details = outer["exceptionDetails"] - exc = exc_details.get("exception", {}) - desc = ( - inner.get("description") - or exc.get("description") - or exc_details.get("text") - or repr(exc_details) - ) - logger.debug("CDP exception details: %s", exc_details) - msg = f"JS evaluation error: {desc}" - raise RuntimeError(msg) - value: str = inner.get("value", "") - return value - - -# ────────────────────────────────────────────────────────────── -# Ensure Steam is running with devtools port -# ────────────────────────────────────────────────────────────── - - -def _is_steam_running() -> bool: - """Check whether any Steam process is alive.""" - pgrep = shutil.which("pgrep") or "/usr/bin/pgrep" - result = subprocess.run( - [pgrep, "-x", "steam"], - capture_output=True, - check=False, - ) - return result.returncode == 0 - - -def _steam_has_debug_port() -> bool: - """Check whether steamwebhelper is listening on the CDP port.""" - return _get_shared_js_ws_url() is not None - - -def _wait_for_cdp_ready() -> bool: - """Wait up to *_STEAM_STARTUP_WAIT* seconds for CDP to become ready.""" - for _ in range(_STEAM_STARTUP_WAIT): - if _get_shared_js_ws_url() is not None: - return True - time.sleep(1) - return False - - -def _wait_for_collections_ready() -> bool: - """Wait until ``collectionStore`` is fully initialised. - - Right after Steam starts, the CDP port may be open but the - internal collection data hasn't loaded yet. Poll a lightweight - JS check until ``GetCollection`` stops throwing. - """ - js = ( - "(() => { try { collectionStore.GetCollection('hidden');" - " return 'ok'; } catch(e) { return 'not_ready'; } })()" - ) - for _ in range(_STEAM_STARTUP_WAIT): - try: - result = _evaluate_js(js) - if _cdp_result_value(result) == "ok": - return True - except RuntimeError: - pass - time.sleep(1) - return False - - -def _shutdown_steam() -> None: - """Send ``steam -shutdown`` and wait for the process to exit.""" - real_user = os.environ.get("SUDO_USER") or os.environ.get("USER") - try: - _run_as_user(["steam", "-shutdown"], real_user) - except FileNotFoundError: - return - - pgrep = shutil.which("pgrep") or "/usr/bin/pgrep" - for _ in range(30): - result = subprocess.run( - [pgrep, "-x", "steam"], - capture_output=True, - check=False, - ) - if result.returncode != 0: - return - time.sleep(1) - - -def _launch_steam_with_debug() -> None: - """Launch Steam with CEF debugging enabled.""" - real_user = os.environ.get("SUDO_USER") or os.environ.get("USER") - _run_as_user( - [ - "steam", - "-cef-enable-debugging", - f"-devtools-port={_CDP_PORT}", - "-silent", - ], - real_user, - ) - - -def ensure_steam_debug_port() -> None: - """Make sure Steam is running with the CDP debug port open. - - If Steam is running without the port, it is restarted. - If Steam is not running, it is launched. - """ - if _steam_has_debug_port(): - logger.debug("Steam CDP port already available.") - return - - logger.info("Steam CDP port not available — (re)starting Steam...") - if _is_steam_running(): - _shutdown_steam() - - _launch_steam_with_debug() - - if not _wait_for_cdp_ready(): - msg = "Timed out waiting for Steam CDP port to become ready" - raise RuntimeError(msg) - logger.info("Steam CDP port ready.") - - if not _wait_for_collections_ready(): - msg = "Timed out waiting for Steam collections to initialise" - raise RuntimeError(msg) - logger.info("Steam collection store ready.") - - -# ────────────────────────────────────────────────────────────── -# Hide / unhide logic -# ────────────────────────────────────────────────────────────── - - -_HIDE_BATCH_SIZE = 50 -_MAX_HIDE_PASSES = 30 -_SETTLE_DELAY_MS = 200 - - -def hide_other_games( - owned_app_ids: list[int], - allowed_app_id: int | None, -) -> int: - """Hide every game except *allowed_app_id* in the Steam library. - - Uses the Chrome DevTools Protocol to call - ``collectionStore.SetAppsAsHidden()`` in Steam's JS context. - - The entire retry loop runs inside a single JS evaluation to avoid - WebSocket round-trip overhead. ``SetAppsAsHidden`` is unreliable - in a single pass for large libraries, so the JS loop retries until - ``visibleApps`` converges to only the allowed game. - - On the first pass, caller-provided *owned_app_ids* are included to - cover games that might not yet appear in ``visibleApps`` due to - stale MobX state. - - Returns the total number of games hidden across all passes. - """ - ensure_steam_debug_port() - - allowed_js = str(allowed_app_id) if allowed_app_id is not None else "null" - extra_ids = sorted(aid for aid in owned_app_ids if aid != allowed_app_id) - extra_json = json.dumps(extra_ids) - js = f""" - (async () => {{ - const allowed = {allowed_js}; - const coll = collectionStore.allGamesCollection; - const extraIds = {extra_json}; - let totalHidden = 0; - const maxPasses = {_MAX_HIDE_PASSES}; - const batchSize = {_HIDE_BATCH_SIZE}; - - async function safeHide(ids) {{ - if (ids.length === 0) return 0; - try {{ - await collectionStore.SetAppsAsHidden(ids, true); - return ids.length; - }} catch(e) {{ - if (ids.length === 1) return 0; - const mid = Math.floor(ids.length / 2); - return (await safeHide(ids.slice(0, mid))) + - (await safeHide(ids.slice(mid))); - }} - }} - - for (let pass = 0; pass < maxPasses; pass++) {{ - let visible = coll && coll.visibleApps - ? coll.visibleApps.map(a => a.appid).filter(id => id !== allowed) - : []; - - if (pass === 0) {{ - const visSet = new Set(visible); - for (const id of extraIds) {{ - if (!visSet.has(id)) visible.push(id); - }} - }} - - if (visible.length === 0) break; - - for (let i = 0; i < visible.length; i += batchSize) {{ - const batch = visible.slice(i, i + batchSize); - totalHidden += await safeHide(batch); - }} - - await new Promise(r => setTimeout(r, {_SETTLE_DELAY_MS})); - }} - - if (allowed !== null) {{ - await collectionStore.SetAppsAsHidden([allowed], false); - }} - - return JSON.stringify({{ totalHidden }}); - }})() - """ - - result = _evaluate_js(js) - value = _cdp_result_value(result) - parsed = json.loads(value) - count: int = parsed["totalHidden"] - logger.info("Hid %d games via CDP.", count) - return count - - -def unhide_all_games(owned_app_ids: list[int]) -> int: - """Remove all games from the hidden collection. - - Returns the number of games that were unhidden. - """ - ensure_steam_debug_port() - - json.dumps(sorted(owned_app_ids)) - js = """ - (async () => { - const hidden = collectionStore.GetCollection('hidden'); - if (!hidden || !hidden.allApps) return JSON.stringify({ count: 0 }); - const hiddenIds = hidden.allApps.map(a => a.appid); - if (hiddenIds.length === 0) return JSON.stringify({ count: 0 }); - await collectionStore.SetAppsAsHidden(hiddenIds, false); - return JSON.stringify({ count: hiddenIds.length }); - })() - """ - - result = _evaluate_js(js) - value = _cdp_result_value(result) - parsed = json.loads(value) - count: int = parsed["count"] - logger.info("Unhidden %d games via CDP.", count) - return count - - -# ────────────────────────────────────────────────────────────── -# Steam restart helper -# ────────────────────────────────────────────────────────────── - - -def restart_steam() -> None: - """Gracefully restart the Steam client with CEF debugging enabled.""" - logger.info("Restarting Steam client with debug port...") - _shutdown_steam() - _launch_steam_with_debug() - - if not _wait_for_cdp_ready(): - logger.warning("Steam restarted but CDP port not ready.") - else: - logger.info("Steam restarted with CDP port ready.") - - -def _run_as_user(cmd: list[str], user: str | None) -> None: - """Run a command, dropping to *user* if currently root.""" - if os.geteuid() == 0 and user and user != "root": - try: - pw = pwd.getpwnam(user) - uid = pw.pw_uid - except KeyError: - uid = 1000 - - dbus_default = f"unix:path=/run/user/{uid}/bus" - dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default) - xauth = os.environ.get("XAUTHORITY", f"/home/{user}/.Xauthority") - full_cmd = [ - "sudo", - "-u", - user, - "env", - f"DISPLAY={os.environ.get('DISPLAY', ':0')}", - f"XAUTHORITY={xauth}", - f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}", - *cmd, - ] - else: - full_cmd = cmd - - subprocess.Popen( - full_cmd, - stdout=subprocess.DEVNULL, - stderr=subprocess.DEVNULL, - ) diff --git a/python_pkg/steam_backlog_enforcer/main.py b/python_pkg/steam_backlog_enforcer/main.py deleted file mode 100644 index fa9ec87..0000000 --- a/python_pkg/steam_backlog_enforcer/main.py +++ /dev/null @@ -1,443 +0,0 @@ -"""Main CLI for Steam Backlog Enforcer.""" - -from __future__ import annotations - -import logging -import sys -import time -from typing import TYPE_CHECKING - -from python_pkg.steam_backlog_enforcer._cmd_done import cmd_done -from python_pkg.steam_backlog_enforcer._enforce_loop import ( - do_enforce, - get_all_owned_app_ids, -) -from python_pkg.steam_backlog_enforcer._hltb_types import load_hltb_cache -from python_pkg.steam_backlog_enforcer._stats import cmd_stats -from python_pkg.steam_backlog_enforcer._whitelist import ( - WHITELIST_COOLDOWN_SECONDS, - add_pending_exception, - list_pending_exceptions, - validate_reason, -) -from python_pkg.steam_backlog_enforcer.config import ( - Config, - State, - interactive_setup, - load_snapshot, -) -from python_pkg.steam_backlog_enforcer.game_install import ( - _echo, - get_installed_games, - install_game, - is_game_installed, - is_protected_app, - uninstall_other_games, -) -from python_pkg.steam_backlog_enforcer.library_hider import ( - hide_other_games, - restart_steam, - unhide_all_games, -) -from python_pkg.steam_backlog_enforcer.scanning import ( - do_check, - do_scan, - pick_next_game, -) -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo -from python_pkg.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 - - -# ────────────────────────────────────────────────────────────── -# CLI commands -# ────────────────────────────────────────────────────────────── - - -def cmd_status(_config: Config, state: State) -> None: - """Show current status.""" - _echo("=== Steam Backlog Enforcer ===\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}") - - -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.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 - - installed = get_installed_games() - to_remove = [ - (aid, n) - for aid, n in installed - if aid != state.current_app_id and not is_protected_app(aid) - ] - - if not to_remove: - _echo("No games to uninstall (only assigned game and runtimes installed).") - return - - _echo(f"\nWill uninstall {len(to_remove)} games, keeping:") - _echo(f" - {state.current_game_name} (AppID={state.current_app_id})") - _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(state.current_app_id) - _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' - f"Exceptions become active after a {WHITELIST_COOLDOWN_SECONDS // 3600}h " - "cooldown." -) - - -def cmd_add_exception(args: list[str]) -> None: - """Request a time-locked whitelist exception. - - Usage: add-exception --reason "" - - The exception becomes active after a 24-hour 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) - - # Show current pending list. - pending = list_pending_exceptions() - if pending: - _echo(f"\nPending exceptions ({len(pending)}):") - now = time.time() - for entry in pending: - aid = int(entry["app_id"]) - elapsed = now - float(entry["requested_at"]) - remaining = max(0.0, WHITELIST_COOLDOWN_SECONDS - elapsed) - hrs = int(remaining // 3600) - mins = int((remaining % 3600) // 60) - status = "ready" if remaining == 0.0 else f"approves in {hrs}h {mins}m" - _echo(f" AppID={aid} [{status}]") - - -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 = hide_other_games(owned_ids, state.current_app_id) - _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 = hide_other_games(owned_ids, state.current_app_id) - if hidden > 0: - _echo(f"\n Library: hid {hidden} games") - - -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), - "enforce": ("Run enforcer: block, uninstall, kill, hide", do_enforce), - "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), -} - -# 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)", -} - -_ALL_COMMANDS: dict[str, str] = { - name: desc for name, (desc, _) in COMMANDS.items() -} | _EXTRA_COMMAND_DESCRIPTIONS - - -def main() -> None: - """CLI entry point.""" - if len(sys.argv) < _MIN_CLI_ARGS or sys.argv[1] not in _ALL_COMMANDS: - _echo("Steam Backlog Enforcer\n") - _echo("Usage: python -m python_pkg.steam_backlog_enforcer.main \n") - _echo("Commands:") - for name, desc in _ALL_COMMANDS.items(): - _echo(f" {name:<14s} {desc}") - sys.exit(1) - - command = sys.argv[1] - - # add-exception has its own argument structure; handle before config load. - if command == "add-exception": - cmd_add_exception(sys.argv[2:]) - return - - config = Config.load() - - if command != "setup" and not config.steam_api_key: - _echo("Not configured. Run 'setup' first.") - sys.exit(1) - - state = State.load() - _, func = COMMANDS[command] - func(config, state) - - -if __name__ == "__main__": - main() diff --git a/python_pkg/steam_backlog_enforcer/protondb.py b/python_pkg/steam_backlog_enforcer/protondb.py deleted file mode 100644 index fc52100..0000000 --- a/python_pkg/steam_backlog_enforcer/protondb.py +++ /dev/null @@ -1,221 +0,0 @@ -"""ProtonDB integration for Linux compatibility ratings. - -Fetches game compatibility tiers from ProtonDB's public API to filter out -games that don't work well on Linux. Ratings are cached locally so repeated -lookups are free. - -Tier hierarchy (best → worst): native, platinum, gold, silver, bronze, borked. -""" - -from __future__ import annotations - -import asyncio -from dataclasses import dataclass -import json -import logging -from typing import Any - -import aiohttp - -from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write - -logger = logging.getLogger(__name__) - -PROTONDB_CACHE_FILE = CONFIG_DIR / "protondb_cache.json" -_PROTONDB_API = "https://www.protondb.com/api/v1/reports/summaries/{app_id}.json" -MAX_CONCURRENT = 30 # parallel requests - be polite to the CDN - -HTTP_NOT_FOUND = 404 - -# Tier ordering from best to worst. -TIER_ORDER: dict[str, int] = { - "native": 0, - "platinum": 1, - "gold": 2, - "silver": 3, - "bronze": 4, - "borked": 5, - "pending": 6, -} - -# Games at or below this tier are skipped. -MIN_PLAYABLE_TIER = "gold" - - -@dataclass -class ProtonDBRating: - """ProtonDB compatibility rating for a game.""" - - app_id: int - tier: str = "" - trending_tier: str = "" - score: float = 0.0 - confidence: str = "" - total_reports: int = 0 - - @property - def is_playable(self) -> bool: - """True if the game has at least gold-tier compatibility. - - A game is considered unplayable when: - - Its tier is silver, bronze, or borked. - - Both reported ratings are available, but one is below silver. - - Both reported ratings are available, but neither reaches gold. - - If both ``tier`` and ``trending_tier`` exist, the acceptance rule is: - at least one rating must be gold-or-better and the other must be - silver-or-better. - """ - if not self.tier or self.tier == "pending": - return True # No data / pending → don't block; user can skip manually. - tier_rank = TIER_ORDER.get(self.tier, 99) - min_rank = TIER_ORDER[MIN_PLAYABLE_TIER] - silver_rank = TIER_ORDER["silver"] - - if not self.trending_tier: - return tier_rank <= min_rank - - trend_rank = TIER_ORDER.get(self.trending_tier, 99) - if tier_rank > silver_rank or trend_rank > silver_rank: - # Bronze, borked, unknown tier in either field → skip. - return False - - # At least one rating must still be gold-or-better. - return not (tier_rank > min_rank and trend_rank > min_rank) - - @property - def unplayable_reason(self) -> str: - """Return a human-readable reason when ``is_playable`` is false.""" - if self.is_playable: - return "" - - tier_rank = TIER_ORDER.get(self.tier, 99) - TIER_ORDER[MIN_PLAYABLE_TIER] - silver_rank = TIER_ORDER["silver"] - - if not self.trending_tier: - return f"tier<{MIN_PLAYABLE_TIER} ({self.tier})" - - trend_rank = TIER_ORDER.get(self.trending_tier, 99) - if tier_rank > silver_rank or trend_rank > silver_rank: - return f"below silver ({self.tier}/{self.trending_tier})" - return f"no gold tier ({self.tier}/{self.trending_tier})" - - -def _load_cache() -> dict[str, Any]: - """Load the on-disk ProtonDB cache.""" - if PROTONDB_CACHE_FILE.exists(): - data: dict[str, Any] = json.loads( - PROTONDB_CACHE_FILE.read_text(encoding="utf-8"), - ) - return data - return {} - - -def _save_cache(cache: dict[str, Any]) -> None: - """Persist the ProtonDB cache.""" - _atomic_write( - PROTONDB_CACHE_FILE, - json.dumps(cache, indent=2) + "\n", - ) - - -async def _fetch_one( - session: aiohttp.ClientSession, - sem: asyncio.Semaphore, - app_id: int, -) -> ProtonDBRating | None: - """Fetch a single game's ProtonDB rating. - - Returns None on network/server errors (not cached, will retry next run). - Returns ProtonDBRating with empty tier on HTTP 404 (no ProtonDB data). - """ - url = _PROTONDB_API.format(app_id=app_id) - async with sem: - try: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as r: - if r.status == HTTP_NOT_FOUND: - return ProtonDBRating(app_id=app_id) - r.raise_for_status() - data = await r.json(content_type=None) - return ProtonDBRating( - app_id=app_id, - tier=data.get("tier", ""), - trending_tier=data.get("trendingTier", ""), - score=data.get("score", 0.0), - confidence=data.get("confidence", ""), - total_reports=data.get("total", 0), - ) - except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e: - logger.warning("ProtonDB fetch failed for AppID=%d: %s", app_id, e) - return None # Don't cache transient failures — retry next run. - - -async def _fetch_batch(app_ids: list[int]) -> list[ProtonDBRating]: - """Fetch ProtonDB ratings for a batch of app IDs concurrently.""" - sem = asyncio.Semaphore(MAX_CONCURRENT) - async with aiohttp.ClientSession() as session: - tasks = [_fetch_one(session, sem, aid) for aid in app_ids] - results = await asyncio.gather(*tasks) - return [r for r in results if r is not None] - - -def _rating_to_dict(r: ProtonDBRating) -> dict[str, Any]: - """Serialize a rating to a cache-friendly dict.""" - return { - "tier": r.tier, - "trending_tier": r.trending_tier, - "score": r.score, - "confidence": r.confidence, - "total_reports": r.total_reports, - } - - -def _rating_from_cache(app_id: int, data: dict[str, Any]) -> ProtonDBRating: - """Deserialize a rating from cached data.""" - return ProtonDBRating( - app_id=app_id, - tier=data.get("tier", ""), - trending_tier=data.get("trending_tier", ""), - score=data.get("score", 0.0), - confidence=data.get("confidence", ""), - total_reports=data.get("total_reports", 0), - ) - - -def fetch_protondb_ratings( - app_ids: list[int], -) -> dict[int, ProtonDBRating]: - """Fetch ProtonDB ratings with local caching. - - Returns a dict mapping app_id → ProtonDBRating for every requested ID. - Cached results are reused; only missing IDs are fetched from the network. - """ - cache = _load_cache() - - # Separate cached vs. uncached. - results: dict[int, ProtonDBRating] = {} - to_fetch: list[int] = [] - for aid in app_ids: - key = str(aid) - if key in cache: - results[aid] = _rating_from_cache(aid, cache[key]) - else: - to_fetch.append(aid) - - if to_fetch: - logger.info( - "Fetching ProtonDB ratings for %d games (%d cached)...", - len(to_fetch), - len(results), - ) - fetched = asyncio.run(_fetch_batch(to_fetch)) - for r in fetched: - results[r.app_id] = r - cache[str(r.app_id)] = _rating_to_dict(r) - _save_cache(cache) - logger.info("ProtonDB: fetched %d, total cached %d", len(fetched), len(cache)) - else: - logger.info("All %d ProtonDB ratings found in cache.", len(results)) - - return results diff --git a/python_pkg/steam_backlog_enforcer/run.sh b/python_pkg/steam_backlog_enforcer/run.sh deleted file mode 100755 index 46d9efc..0000000 --- a/python_pkg/steam_backlog_enforcer/run.sh +++ /dev/null @@ -1,7 +0,0 @@ -#!/usr/bin/env bash -# Launcher for the Steam Backlog Enforcer. -# Usage: ./run.sh [command] (defaults to "done" if no command given) -set -euo pipefail - -cd "$(dirname "$0")/../.." -exec python -m python_pkg.steam_backlog_enforcer.main "${1:-done}" diff --git a/python_pkg/steam_backlog_enforcer/scanning.py b/python_pkg/steam_backlog_enforcer/scanning.py deleted file mode 100644 index 0d4c995..0000000 --- a/python_pkg/steam_backlog_enforcer/scanning.py +++ /dev/null @@ -1,521 +0,0 @@ -"""Game scanning, selection, checking, and enforcement daemon.""" - -from __future__ import annotations - -from datetime import datetime, timezone -import logging -import time -from typing import TYPE_CHECKING, Any - -from python_pkg.steam_backlog_enforcer._hltb_types import ( - load_hltb_count_comp_cache, - load_hltb_polls_cache, -) -from python_pkg.steam_backlog_enforcer._scanning_confidence import ( - _apply_cached_confidence_to_candidates, - _candidate_passes_hltb_confidence, - _report_poll_confidence, -) -from python_pkg.steam_backlog_enforcer.config import ( - Config, - State, - load_snapshot, - save_snapshot, -) -from python_pkg.steam_backlog_enforcer.enforcer import ( - send_notification, -) -from python_pkg.steam_backlog_enforcer.game_install import ( - _echo, - install_game, - is_game_installed, - uninstall_other_games, -) -from python_pkg.steam_backlog_enforcer.hltb import ( - fetch_hltb_times_cached, -) -from python_pkg.steam_backlog_enforcer.protondb import ( - ProtonDBRating, - fetch_protondb_ratings, -) -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient - -if TYPE_CHECKING: - from collections.abc import Callable - -logger = logging.getLogger(__name__) - -_TAMPER_CHECK_LIMIT = 3 - -# ────────────────────────────────────────────────────────────── -# Scanning & game selection -# ────────────────────────────────────────────────────────────── - - -def do_scan(config: Config, state: State) -> list[GameInfo]: - """Full library scan: Steam API + HLTB times.""" - client = SteamAPIClient(config.steam_api_key, config.steam_id) - - start = time.time() - done_count = 0 - - def progress(current: int, total: int) -> None: - nonlocal done_count - done_count = current - if current % 50 == 0 or current == total: - _echo(f"\r Scanning achievements: {current}/{total}", end="", flush=True) - - _echo("Scanning Steam library...") - games = client.build_game_list( - progress_callback=progress, - ) - elapsed = time.time() - start - _echo(f"\n Scanned {len(games)} games with achievements in {elapsed:.1f}s") - - # Fetch HLTB times (cached). - incomplete = [(g.app_id, g.name) for g in games if not g.is_complete] - if incomplete: - _echo(f"Fetching HLTB completion times for {len(incomplete)} games...") - - def hltb_progress(done: int, total: int, found: int, name: str) -> None: - pct = done * 100 // total - bar_w = 30 - filled = bar_w * done // total - bar = "█" * filled + "░" * (bar_w - filled) - _echo( - f"\r HLTB [{bar}] {done}/{total} ({pct}%) " - f"| {found} found | {name[:30]:<30s}", - end="", - flush=True, - ) - - hltb_cache = fetch_hltb_times_cached(incomplete, progress_cb=hltb_progress) - _echo("") # newline after progress bar - polls_cache = load_hltb_polls_cache() - count_comp_cache = load_hltb_count_comp_cache() - for g in games: - hours = hltb_cache.get(g.app_id, -1) - g.completionist_hours = hours - g.comp_100_count = polls_cache.get(g.app_id, 0) - g.count_comp = count_comp_cache.get(g.app_id, 0) - found = sum(1 for h in hltb_cache.values() if h > 0) - _echo(f" HLTB data: {found} games have completion estimates") - - # Save snapshot. - save_snapshot([g.to_snapshot() for g in games]) - - complete = [g for g in games if g.is_complete] - incomplete_games = [g for g in games if not g.is_complete] - _echo(f"\nResults: {len(complete)} complete, {len(incomplete_games)} incomplete") - - # Auto-pick a game if none assigned. - if state.current_app_id is None: - pick_next_game(games, state, config) - else: - # Show confidence info for the already-assigned game too. - current = next( - (g for g in games if g.app_id == state.current_app_id), - None, - ) - if current is not None: - _echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})") - _report_poll_confidence(current, games, state) - - return games - - -# How many candidates to check per ProtonDB batch. -_PROTONDB_BATCH_SIZE = 20 - - -def _pick_playable_candidate( - candidates: list[GameInfo], -) -> GameInfo | None: - """Return the first candidate with an acceptable ProtonDB rating. - - Checks candidates in batches (sorted by HLTB hours, shortest first). - Games rated silver-or-worse, or gold-trending-down, are skipped. - """ - offset = 0 - while offset < len(candidates): - batch = candidates[offset : offset + _PROTONDB_BATCH_SIZE] - app_ids = [g.app_id for g in batch] - ratings = fetch_protondb_ratings(app_ids) - - for game in batch: - rating = ratings.get(game.app_id, ProtonDBRating(app_id=game.app_id)) - if rating.is_playable: - if offset > 0 or game is not batch[0]: - _echo( - f" Skipped {offset + batch.index(game)} game(s) " - f"with poor Linux compatibility" - ) - return game - logger.info( - "Skipping %s (AppID=%d): ProtonDB %s (trending %s)", - game.name, - game.app_id, - rating.tier, - rating.trending_tier, - ) - - offset += _PROTONDB_BATCH_SIZE - - return None - - -_PICK_LIST_SIZE = 10 - -_NO_CONF_MSG = ( - "\nNo assignable games found " - "(HLTB confidence thresholds: comp_100 polls>=3, " - "count_comp>=15, sum>=18)." -) - - -def _sort_key(g: GameInfo) -> tuple[int, float]: - """Sort by known HLTB time (shortest first), then unknown games.""" - if g.completionist_hours > 0: - return (0, g.completionist_hours) - return (1, g.name.lower().encode().hex().__hash__()) - - -def _collect_qualified_candidates( - candidates: list[GameInfo], -) -> tuple[list[GameInfo], int, int]: - """Collect up to _PICK_LIST_SIZE playable, HLTB-confident candidates.""" - qualified: list[GameInfo] = [] - confidence_skipped = 0 - linux_skipped = 0 - for game in candidates: - if len(qualified) >= _PICK_LIST_SIZE: - break - if not _candidate_passes_hltb_confidence(game): - confidence_skipped += 1 - continue - playable = _pick_playable_candidate([game]) - if playable is not None: - qualified.append(playable) - else: - linux_skipped += 1 - return qualified, confidence_skipped, linux_skipped - - -def _prompt_user_pick(qualified: list[GameInfo]) -> int: - """Present numbered list, return 0-based index of user's choice.""" - for i, g in enumerate(qualified, 1): - hours_str = ( - f" (~{g.completionist_hours:.1f}h)" if g.completionist_hours > 0 else "" - ) - _echo(f" {i}. {g.name} (AppID={g.app_id}){hours_str}") - while True: - raw = input("Select game number: ") - try: - idx = int(raw) - except ValueError: - _echo(f"Invalid input: {raw!r}") - continue - if idx < 1 or idx > len(qualified): - _echo(f"Out of range: {idx}") - continue - return idx - 1 - - -def _assign_chosen_game( - chosen: GameInfo, - games: list[GameInfo], - state: State, - config: Config, -) -> None: - """Save assignment, announce it, and handle install/uninstall.""" - state.current_app_id = chosen.app_id - state.current_game_name = chosen.name - if not state.enforcement_started_at: - state.enforcement_started_at = datetime.now(timezone.utc).isoformat() - state.save() - hours_str = ( - f" (~{chosen.completionist_hours:.1f}h leisure+dlc)" - if chosen.completionist_hours > 0 - else "" - ) - _echo(f"\n>>> ASSIGNED: {chosen.name} (AppID={chosen.app_id}){hours_str}") - _echo( - f" Progress: {chosen.unlocked_achievements}/{chosen.total_achievements}" - f" ({chosen.completion_pct:.1f}%)" - ) - _report_poll_confidence(chosen, games, state) - if config.uninstall_other_games: - count = uninstall_other_games(chosen.app_id) - if count: - _echo(f"\n Uninstalled {count} non-assigned games") - if not is_game_installed(chosen.app_id): - _echo(f"\n Auto-installing {chosen.name}...") - install_game( - chosen.app_id, chosen.name, config.steam_id, use_steam_protocol=True - ) - - -def _pick_next_game_sequential( - games: list[GameInfo], - state: State, - config: Config, - on_select: Callable[[GameInfo], bool], -) -> None: - """Pick the next-shortest playable game, asking the user per candidate. - - ``on_select`` is called with each prospective pick. Returning ``True`` - accepts the assignment; returning ``False`` records a 7-day skip on - ``state`` for that game and the next candidate is evaluated. - """ - while True: - skip = set(state.finished_app_ids) | state.active_skipped_ids() - candidates = [g for g in games if not g.is_complete and g.app_id not in skip] - if not candidates: - _echo(_NO_CONF_MSG) - state.current_app_id = None - state.current_game_name = "" - state.save() - return - - candidates.sort(key=_sort_key) - _apply_cached_confidence_to_candidates(candidates) - chosen, confidence_skipped, linux_skipped = _pick_next_shortest_candidate( - candidates - ) - if chosen is None: - _echo( - _NO_CONF_MSG - if confidence_skipped > 0 and linux_skipped == 0 - else "\nNo playable games left (all have poor ProtonDB ratings)!" - ) - state.current_app_id = None - state.current_game_name = "" - state.save() - return - - if not on_select(chosen): - state.skip_for_days(chosen.app_id, 7) - state.save() - _echo(f"\n Skipped {chosen.name} for 7 days; picking next...") - continue - - _assign_chosen_game(chosen, games, state, config) - return - - -def pick_next_game( - games: list[GameInfo], - state: State, - config: Config, - *, - on_select: Callable[[GameInfo], bool] | None = None, -) -> None: - """Present a ranked list of eligible games and let the user pick one. - - Games are ranked by shortest completionist time first. Games with - silver-or-worse ProtonDB ratings (or gold trending downward) are - excluded as unplayable on Linux. - - If ``on_select`` is provided, the legacy 10-candidate picker is - bypassed: the function instead presents the shortest playable - candidate to ``on_select`` (typically a yes/no prompt) and, if the - callback rejects it, records a 7-day skip and re-evaluates. - """ - if on_select is not None: - _pick_next_game_sequential(games, state, config, on_select) - return - - skip = set(state.finished_app_ids) | state.active_skipped_ids() - candidates = [g for g in games if not g.is_complete and g.app_id not in skip] - - if not candidates: - _echo(_NO_CONF_MSG) - state.current_app_id = None - state.current_game_name = "" - state.save() - return - - candidates.sort(key=_sort_key) - _apply_cached_confidence_to_candidates(candidates) - qualified, confidence_skipped, linux_skipped = _collect_qualified_candidates( - candidates - ) - - if not qualified: - _echo( - _NO_CONF_MSG - if confidence_skipped > 0 and linux_skipped == 0 - else "\nNo playable games left (all have poor ProtonDB ratings)!" - ) - state.current_app_id = None - state.current_game_name = "" - state.save() - return - - idx = _prompt_user_pick(qualified) - _assign_chosen_game(qualified[idx], games, state, config) - - -def _pick_next_shortest_candidate( - candidates: list[GameInfo], -) -> tuple[GameInfo | None, int, int]: - """Pick next game by checking confidence one candidate at a time. - - The list must be pre-sorted by desired priority (shortest first). - """ - confidence_skipped = 0 - linux_skipped = 0 - for game in candidates: - if not _candidate_passes_hltb_confidence(game): - confidence_skipped += 1 - continue - - # Reuse existing ProtonDB compatibility gate for one candidate. - playable = _pick_playable_candidate([game]) - if playable is not None: - if linux_skipped > 0: - _echo( - f" Skipped {linux_skipped} game(s) with poor Linux compatibility" - ) - return playable, confidence_skipped, linux_skipped - linux_skipped += 1 - - if linux_skipped > 0: - _echo(f" Skipped {linux_skipped} game(s) with poor Linux compatibility") - return None, confidence_skipped, linux_skipped - - -def _collect_top_candidates( - candidates: list[GameInfo], - n: int = 3, -) -> tuple[list[GameInfo], int, int]: - """Collect up to n candidates that pass the Linux compatibility gate. - - Args: - candidates: Pre-sorted list of candidate games. - n: Maximum number of qualified games to collect. - - Returns: - Tuple of (qualified_list, conf_skipped, linux_skipped). - """ - qualified: list[GameInfo] = [] - linux_skipped = 0 - for game in candidates: - if len(qualified) >= n: - break - playable = _pick_playable_candidate([game]) - if playable is not None: - qualified.append(playable) - else: - linux_skipped += 1 - if linux_skipped > 0: - _echo(f" Skipped {linux_skipped} game(s) with poor Linux compatibility") - return qualified, 0, linux_skipped - - -# ────────────────────────────────────────────────────────────── -# Checking & tampering detection -# ────────────────────────────────────────────────────────────── - - -def do_check(config: Config, state: State) -> None: - """Check assigned game completion status; detect tampering.""" - if state.current_app_id is None: - _echo("No game currently assigned. Run 'scan' first.") - return - - client = SteamAPIClient(config.steam_api_key, config.steam_id) - _echo(f"Checking {state.current_game_name} (AppID={state.current_app_id})...") - - game = client.refresh_single_game(state.current_app_id, state.current_game_name) - if game is None: - _echo(" Could not fetch achievement data.") - return - - _echo( - f" Progress: {game.unlocked_achievements}/{game.total_achievements}" - f" ({game.completion_pct:.1f}%)" - ) - - if game.is_complete: - _echo(f"\n COMPLETED: {state.current_game_name}!") - state.finished_app_ids.append(state.current_app_id) - send_notification( - "Game Complete!", - f"You finished {state.current_game_name}! Picking next game...", - ) - - # Load snapshot and pick next. - snapshot_data = load_snapshot() - if snapshot_data: - games = [GameInfo.from_snapshot(d) for d in snapshot_data] - pick_next_game(games, state, config) - else: - state.current_app_id = None - state.current_game_name = "" - state.save() - _echo(" Run 'scan' to pick the next game.") - else: - remaining = game.total_achievements - game.unlocked_achievements - _echo(f" {remaining} achievements remaining. Keep going!") - - # Tampering detection on snapshot. - detect_tampering(config, state) - - -def _check_game_tampering( - client: SteamAPIClient, - entry: dict[str, Any], - state: State, -) -> tuple[str, int, int] | None: - """Check if a single game has unexpected achievement progress. - - Args: - client: Steam API client. - entry: Snapshot entry for the game. - state: Current enforcer state. - - Returns: - Tuple of (name, app_id, diff) if tampering detected, else None. - """ - app_id = entry["app_id"] - if app_id == state.current_app_id: - return None - if entry["unlocked_achievements"] >= entry["total_achievements"]: - return None - if entry.get("playtime_minutes", 0) <= 0: - return None - game = client.refresh_single_game( - app_id, entry["name"], entry.get("playtime_minutes", 0) - ) - if game and game.unlocked_achievements > entry["unlocked_achievements"]: - diff = game.unlocked_achievements - entry["unlocked_achievements"] - return (entry["name"], app_id, diff) - return None - - -def detect_tampering(config: Config, state: State) -> None: - """Check if achievements were unlocked on non-assigned games.""" - old_snapshot = load_snapshot() - if old_snapshot is None: - return - - client = SteamAPIClient(config.steam_api_key, config.steam_id) - - # Quick check: only re-fetch a few random non-assigned games. - suspicious: list[tuple[str, int, int]] = [] - for entry in old_snapshot: - result = _check_game_tampering(client, entry, state) - if result: - suspicious.append(result) - if len(suspicious) >= _TAMPER_CHECK_LIMIT: - break - - if suspicious: - _echo("\n TAMPERING DETECTED:") - for name, app_id, diff in suspicious: - _echo(f" {name} (AppID={app_id}): +{diff} new achievements!") - send_notification( - "Tampering Detected!", - f"Achievements unlocked on {len(suspicious)} non-assigned games!", - ) diff --git a/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service b/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service deleted file mode 100644 index 258c9d7..0000000 --- a/python_pkg/steam_backlog_enforcer/steam-backlog-enforcer.service +++ /dev/null @@ -1,19 +0,0 @@ -[Unit] -Description=Steam Backlog Enforcer -After=network-online.target -Wants=network-online.target - -[Service] -Type=simple -WorkingDirectory=/home/kuhy/testsAndMisc -ExecStart=/usr/bin/python3 -m python_pkg.steam_backlog_enforcer.main enforce -Restart=always -RestartSec=5 -Environment=PYTHONUNBUFFERED=1 -Environment=PYTHONPATH=/home/kuhy/testsAndMisc:/home/kuhy/.local/lib/python3.14/site-packages -Environment=HOME=/home/kuhy -# Hardening: enforcer must not be easily killed. -OOMScoreAdjust=-900 - -[Install] -WantedBy=multi-user.target diff --git a/python_pkg/steam_backlog_enforcer/steam_api.py b/python_pkg/steam_backlog_enforcer/steam_api.py deleted file mode 100644 index 187f149..0000000 --- a/python_pkg/steam_backlog_enforcer/steam_api.py +++ /dev/null @@ -1,281 +0,0 @@ -"""Steam Web API client for fetching games and achievement data.""" - -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor, as_completed -from dataclasses import dataclass, field -import logging -import threading -import time -from typing import TYPE_CHECKING, Any - -import requests - -if TYPE_CHECKING: - from collections.abc import Callable - -logger = logging.getLogger(__name__) - -STEAM_API_BASE = "https://api.steampowered.com" -MAX_WORKERS = 20 - - -@dataclass -class AchievementInfo: - """Single achievement state.""" - - api_name: str - display_name: str - achieved: bool - unlock_time: int - - -@dataclass -class GameInfo: - """Info about an owned Steam game.""" - - app_id: int - name: str - total_achievements: int - unlocked_achievements: int - playtime_minutes: int - achievements: list[AchievementInfo] = field(default_factory=list) - completionist_hours: float = -1 - comp_100_count: int = 0 - count_comp: int = 0 - - @property - def completion_pct(self) -> float: - """Achievement completion percentage.""" - if self.total_achievements == 0: - return 100.0 - return (self.unlocked_achievements / self.total_achievements) * 100.0 - - @property - def is_complete(self) -> bool: - """True if all achievements are unlocked.""" - return ( - self.total_achievements > 0 - and self.unlocked_achievements >= self.total_achievements - ) - - def to_snapshot(self) -> dict[str, Any]: - """Serialize to JSON-safe dict.""" - return { - "app_id": self.app_id, - "name": self.name, - "total_achievements": self.total_achievements, - "unlocked_achievements": self.unlocked_achievements, - "playtime_minutes": self.playtime_minutes, - "completionist_hours": self.completionist_hours, - "comp_100_count": self.comp_100_count, - "count_comp": self.count_comp, - "achievements": [ - { - "api_name": a.api_name, - "display_name": a.display_name, - "achieved": a.achieved, - "unlock_time": a.unlock_time, - } - for a in self.achievements - ], - } - - @classmethod - def from_snapshot(cls, data: dict[str, Any]) -> GameInfo: - """Deserialize from a cached snapshot dict.""" - achievements = [ - AchievementInfo( - api_name=a["api_name"], - display_name=a.get("display_name", a["api_name"]), - achieved=a["achieved"], - unlock_time=a.get("unlock_time", 0), - ) - for a in data.get("achievements", []) - ] - return cls( - app_id=data["app_id"], - name=data["name"], - total_achievements=data["total_achievements"], - unlocked_achievements=data["unlocked_achievements"], - playtime_minutes=data.get("playtime_minutes", 0), - completionist_hours=data.get("completionist_hours", -1), - comp_100_count=data.get("comp_100_count", 0), - count_comp=data.get("count_comp", 0), - achievements=achievements, - ) - - -class SteamAPIError(Exception): - """Raised when the Steam API returns an error.""" - - -class SteamAPIClient: - """Client for interacting with the Steam Web API.""" - - def __init__(self, api_key: str, steam_id: str) -> None: - """Initialize the Steam API client. - - Args: - api_key: Steam Web API key. - steam_id: Steam64 ID of the user. - """ - self.api_key = api_key - self.steam_id = steam_id - self.session = requests.Session() - adapter = requests.adapters.HTTPAdapter( - pool_maxsize=MAX_WORKERS, - pool_connections=MAX_WORKERS, - ) - self.session.mount("https://", adapter) - self.session.headers["Accept"] = "application/json" - self._rate_lock = threading.Lock() - self._request_times: list[float] = [] - self._max_rps = 18 - - def _rate_limit(self) -> None: - """Enforce rate limit across threads.""" - while True: - with self._rate_lock: - now = time.time() - self._request_times = [t for t in self._request_times if now - t < 1.0] - if len(self._request_times) < self._max_rps: - self._request_times.append(now) - return - time.sleep(0.06) - - def _get(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any]: - """Rate-limited GET request.""" - self._rate_limit() - if params is None: - params = {} - params["key"] = self.api_key - try: - resp = self.session.get(url, params=params, timeout=30) - resp.raise_for_status() - result: dict[str, Any] = resp.json() - except requests.RequestException as e: - msg = f"Steam API request failed: {e}" - raise SteamAPIError(msg) from e - else: - return result - - def get_owned_games(self) -> list[dict[str, Any]]: - """Fetch all games owned by the user.""" - url = f"{STEAM_API_BASE}/IPlayerService/GetOwnedGames/v1/" - data = self._get( - url, - { - "steamid": self.steam_id, - "include_appinfo": "true", - "include_played_free_games": "true", - "format": "json", - }, - ) - games: list[dict[str, Any]] = data.get("response", {}).get("games", []) - logger.info("Found %d owned games.", len(games)) - return games - - def get_achievement_details(self, app_id: int) -> list[AchievementInfo]: - """Fetch per-achievement detail for a game.""" - url = f"{STEAM_API_BASE}/ISteamUserStats/GetPlayerAchievements/v1/" - try: - data = self._get( - url, - { - "steamid": self.steam_id, - "appid": str(app_id), - "l": "english", - "format": "json", - }, - ) - except SteamAPIError: - return [] - - stats = data.get("playerstats", {}) - if not stats.get("success", False): - return [] - - raw: list[dict[str, Any]] = stats.get("achievements", []) - return [ - AchievementInfo( - api_name=a.get("apiname", ""), - display_name=a.get("name", a.get("apiname", "")), - achieved=bool(a.get("achieved", 0)), - unlock_time=a.get("unlocktime", 0), - ) - for a in raw - ] - - def _fetch_one_game(self, game_dict: dict[str, Any]) -> GameInfo | None: - """Fetch achievement data for one game. Thread-safe.""" - app_id = game_dict["appid"] - - achievements = self.get_achievement_details(app_id) - if not achievements: - return None - - name = game_dict.get("name", f"Unknown ({app_id})") - total = len(achievements) - unlocked = sum(1 for a in achievements if a.achieved) - - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=game_dict.get("playtime_forever", 0), - achievements=achievements, - ) - - def build_game_list( - self, - progress_callback: Callable[[int, int], None] | None = None, - ) -> list[GameInfo]: - """Build full game list with achievement data (parallel).""" - owned = self.get_owned_games() - games: list[GameInfo] = [] - done_count = 0 - total = len(owned) - lock = threading.Lock() - - with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool: - futures = {pool.submit(self._fetch_one_game, g): g for g in owned} - for future in as_completed(futures): - try: - result = future.result() - except ( - KeyError, - TypeError, - ValueError, - SteamAPIError, - requests.RequestException, - ): - result = None - with lock: - done_count += 1 - if progress_callback: - progress_callback(done_count, total) - if result is not None: - games.append(result) - - games.sort(key=lambda g: g.name.lower()) - return games - - def refresh_single_game( - self, app_id: int, name: str, playtime: int = 0 - ) -> GameInfo | None: - """Re-fetch achievement data for one game.""" - achievements = self.get_achievement_details(app_id) - if not achievements: - return None - total = len(achievements) - unlocked = sum(1 for a in achievements if a.achieved) - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=playtime, - achievements=achievements, - ) diff --git a/python_pkg/steam_backlog_enforcer/store_blocker.py b/python_pkg/steam_backlog_enforcer/store_blocker.py deleted file mode 100644 index 827321d..0000000 --- a/python_pkg/steam_backlog_enforcer/store_blocker.py +++ /dev/null @@ -1,431 +0,0 @@ -"""Block Steam Store access via /etc/hosts (hosts install script) and iptables. - -The system uses a dedicated hosts install script at -linux_configuration/hosts/install.sh that manages /etc/hosts with: - - chattr +ia (immutable + append-only) - - read-only bind mount - - protection against removing entries (only adding is easy) - -This module checks if the Steam Store domains are already blocked in -/etc/hosts. If not, it runs the hosts install.sh (which must already -contain the Steam Store entries in its heredoc). As a belt-and-suspenders -fallback, it also blocks via iptables. -""" - -from __future__ import annotations - -import contextlib -import logging -from pathlib import Path -import shutil -import socket -import subprocess - -from python_pkg.steam_backlog_enforcer.config import ( - BLOCKED_DOMAINS, - HOSTS_FILE, -) - -logger = logging.getLogger(__name__) - -# Path to the hosts install script (relative to repo root). -_REPO_ROOT = Path(__file__).resolve().parents[2] -HOSTS_INSTALL_SCRIPT = _REPO_ROOT / "linux_configuration" / "hosts" / "install.sh" - -# iptables chain name for our blocking rules. -IPTABLES_CHAIN = "STEAM_ENFORCER" - -# Resolved absolute paths for executables (avoids S607 partial-path warnings). -_SUDO = shutil.which("sudo") or "/usr/bin/sudo" -_IPTABLES = shutil.which("iptables") or "/usr/sbin/iptables" -_BASH = shutil.which("bash") or "/usr/bin/bash" -_CHATTR = shutil.which("chattr") or "/usr/bin/chattr" -_SYSTEMCTL = shutil.which("systemctl") or "/usr/bin/systemctl" -_UMOUNT = shutil.which("umount") or "/usr/bin/umount" -_MOUNT = shutil.which("mount") or "/usr/bin/mount" -_FINDMNT = shutil.which("findmnt") or "/usr/bin/findmnt" -_CP = shutil.which("cp") or "/usr/bin/cp" -_CHMOD = shutil.which("chmod") or "/usr/bin/chmod" -_TEE = shutil.which("tee") or "/usr/bin/tee" - -# IP address used in /etc/hosts for blocking domains. -_HOSTS_REDIRECT_IP = ".".join(["0"] * 4) - - -def _sudo_write_hosts(content: str) -> None: - """Write *content* to /etc/hosts via ``sudo tee``.""" - subprocess.run( - [_SUDO, _TEE, str(HOSTS_FILE)], - input=content.encode(), - stdout=subprocess.DEVNULL, - timeout=10, - check=True, - ) - - -def is_store_blocked() -> bool: - """Check if Steam Store domains are blocked in /etc/hosts.""" - try: - content = HOSTS_FILE.read_text(encoding="utf-8") - # Check for at least the primary store domain. - if "store.steampowered.com" in content: - # Verify it's actually blocked (not commented out). - for line in content.splitlines(): - stripped = line.strip() - if ( - not stripped.startswith("#") - and "store.steampowered.com" in stripped - and stripped.startswith(_HOSTS_REDIRECT_IP) - ): - return True - except OSError: - pass - - return _is_iptables_blocked() - - -def block_store() -> bool: - """Block Steam Store: uncomment hosts entries, or run install script. - - Returns True if at least one blocking method succeeded. - """ - if is_store_blocked(): - logger.info("Steam Store already blocked in /etc/hosts.") - return True - - # Try quick re-block (uncomment lines) first. - if _reblock_hosts() and is_store_blocked(): - _block_store_iptables() - flush_dns_cache() - return True - - # Fall back to the full hosts install script. - hosts_ok = _block_via_hosts_install() - ipt_ok = _block_store_iptables() - - if hosts_ok or ipt_ok: - flush_dns_cache() - return True - - logger.error("All store-blocking methods failed.") - return False - - -def _block_via_hosts_install() -> bool: - """Run the hosts install.sh to apply /etc/hosts with Steam Store entries. - - The install script handles: immutable flag removal, bind mount remounting, - writing the file, re-applying protections, and DoH disabling. - """ - if is_store_blocked(): - logger.info("Steam Store already blocked in /etc/hosts.") - return True - - if not HOSTS_INSTALL_SCRIPT.exists(): - logger.error("hosts install script not found at %s", HOSTS_INSTALL_SCRIPT) - return False - - try: - logger.info("Running hosts install script to block Steam Store...") - result = subprocess.run( - [_SUDO, _BASH, str(HOSTS_INSTALL_SCRIPT), "--no-flush-dns"], - capture_output=True, - text=True, - timeout=120, - check=False, - ) - except (OSError, subprocess.SubprocessError): - logger.exception("Failed to run hosts install script") - return False - else: - if result.returncode == 0: - logger.info("hosts install script succeeded.") - return True - logger.error( - "hosts install script failed (rc=%d): %s", - result.returncode, - result.stderr[-500:] if result.stderr else result.stdout[-500:], - ) - return False - - -def _is_iptables_blocked() -> bool: - """Check if our iptables chain exists and has rules.""" - try: - result = subprocess.run( - [_SUDO, _IPTABLES, "-L", IPTABLES_CHAIN, "-n"], - capture_output=True, - text=True, - timeout=5, - check=False, - ) - except (OSError, subprocess.SubprocessError): - return False - else: - return result.returncode == 0 and "DROP" in result.stdout - - -def _block_store_iptables() -> bool: - """Block Steam Store domains using iptables (IP-based).""" - try: - # Create chain if it doesn't exist. - subprocess.run( - [_SUDO, _IPTABLES, "-N", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=False, - ) - # Flush existing rules in our chain. - subprocess.run( - [_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=True, - ) - - # Resolve domains and block their IPs. - blocked_ips: set[str] = set() - for domain in BLOCKED_DOMAINS: - with contextlib.suppress(socket.gaierror): - ips = socket.getaddrinfo(domain, 443, socket.AF_INET) - for _, _, _, _, addr in ips: - blocked_ips.add(addr[0]) - - for ip in blocked_ips: - subprocess.run( - [ - _SUDO, - _IPTABLES, - "-A", - IPTABLES_CHAIN, - "-d", - ip, - "-j", - "DROP", - ], - capture_output=True, - timeout=5, - check=True, - ) - - # Hook our chain into OUTPUT if not already there. - result = subprocess.run( - [_SUDO, _IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=False, - ) - if result.returncode != 0: - subprocess.run( - [_SUDO, _IPTABLES, "-I", "OUTPUT", "-j", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=True, - ) - except (OSError, subprocess.SubprocessError): - logger.exception("Failed to block store via iptables") - return False - else: - logger.info("Steam Store blocked via iptables (%d IPs).", len(blocked_ips)) - return True - - -def unblock_store() -> bool: - """Remove Steam Store blocks from both iptables and /etc/hosts.""" - ipt_ok = _unblock_store_iptables() - hosts_ok = _unblock_hosts() - flush_dns_cache() - - if not ipt_ok: - logger.warning("Failed to remove iptables rules.") - if not hosts_ok: - logger.warning("Failed to remove /etc/hosts entries.") - - return ipt_ok or hosts_ok - - -def _unblock_store_iptables() -> bool: - """Remove iptables-based block.""" - try: - subprocess.run( - [_SUDO, _IPTABLES, "-D", "OUTPUT", "-j", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=False, - ) - subprocess.run( - [_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=False, - ) - subprocess.run( - [_SUDO, _IPTABLES, "-X", IPTABLES_CHAIN], - capture_output=True, - timeout=5, - check=False, - ) - except (OSError, subprocess.SubprocessError): - logger.exception("Failed to unblock iptables") - return False - else: - logger.info("Steam Store unblocked from iptables.") - return True - - -def flush_dns_cache() -> None: - """Flush the system DNS cache.""" - commands = [ - ["systemd-resolve", "--flush-caches"], - ["resolvectl", "flush-caches"], - ["nscd", "--invalidate=hosts"], - ] - for cmd in commands: - with contextlib.suppress(FileNotFoundError, OSError): - subprocess.run( - cmd, - capture_output=True, - timeout=5, - check=False, - ) - - -# ────────────────────────────────────────────────────────────── -# /etc/hosts protection helpers -# ────────────────────────────────────────────────────────────── - -_GUARD_SERVICES = ("hosts-bind-mount.service", "hosts-guard.path") -_LOCKED_HOSTS_COPY = Path("/usr/local/share/locked-hosts") - - -def _disable_hosts_protection() -> None: - """Stop guard services, unmount bind mount, remove chattr flags.""" - for svc in _GUARD_SERVICES: - subprocess.run( - [_SUDO, _SYSTEMCTL, "stop", svc], - capture_output=True, - timeout=10, - check=False, - ) - - # Unmount bind mount if active. - result = subprocess.run( - [_FINDMNT, str(HOSTS_FILE)], - capture_output=True, - timeout=5, - check=False, - ) - if result.returncode == 0: - subprocess.run( - [_SUDO, _UMOUNT, str(HOSTS_FILE)], - capture_output=True, - timeout=5, - check=False, - ) - - # Remove immutable + append-only attributes. - subprocess.run( - [_SUDO, _CHATTR, "-i", "-a", str(HOSTS_FILE)], - capture_output=True, - timeout=5, - check=False, - ) - - -def _enable_hosts_protection() -> None: - """Re-apply chattr flags and restart guard services.""" - subprocess.run( - [_SUDO, _CHMOD, "644", str(HOSTS_FILE)], - capture_output=True, - timeout=5, - check=False, - ) - subprocess.run( - [_SUDO, _CHATTR, "+ia", str(HOSTS_FILE)], - capture_output=True, - timeout=5, - check=False, - ) - - # Update the canonical copy so the guard doesn't revert changes. - if _LOCKED_HOSTS_COPY.exists(): - subprocess.run( - [_SUDO, _CP, str(HOSTS_FILE), str(_LOCKED_HOSTS_COPY)], - capture_output=True, - timeout=5, - check=False, - ) - - for svc in _GUARD_SERVICES: - subprocess.run( - [_SUDO, _SYSTEMCTL, "start", svc], - capture_output=True, - timeout=10, - check=False, - ) - - -def _unblock_hosts() -> bool: - """Comment out Steam Store entries in /etc/hosts.""" - if not is_store_blocked(): - logger.info("Steam Store not blocked in /etc/hosts, nothing to do.") - return True - - try: - _disable_hosts_protection() - content = HOSTS_FILE.read_text(encoding="utf-8") - new_lines = [] - changed = False - for line in content.splitlines(keepends=True): - stripped = line.strip() - if ( - not stripped.startswith("#") - and stripped.startswith(_HOSTS_REDIRECT_IP) - and any(d in stripped for d in BLOCKED_DOMAINS) - ): - new_lines.append(f"# {line}" if line.endswith("\n") else f"# {line}\n") - changed = True - else: - new_lines.append(line) - - if changed: - _sudo_write_hosts("".join(new_lines)) - logger.info("Commented out Steam Store entries in /etc/hosts.") - - _enable_hosts_protection() - except OSError: - logger.exception("Failed to modify /etc/hosts") - return False - else: - return True - - -def _reblock_hosts() -> bool: - """Uncomment Steam Store entries in /etc/hosts.""" - try: - _disable_hosts_protection() - content = HOSTS_FILE.read_text(encoding="utf-8") - new_lines = [] - changed = False - for line in content.splitlines(keepends=True): - stripped = line.strip() - if stripped.startswith("# ") and any( - d in stripped for d in BLOCKED_DOMAINS - ): - # Remove the '# ' prefix. - uncommented = line.replace("# ", "", 1) - new_lines.append(uncommented) - changed = True - else: - new_lines.append(line) - - if changed: - _sudo_write_hosts("".join(new_lines)) - logger.info("Re-enabled Steam Store entries in /etc/hosts.") - - _enable_hosts_protection() - except OSError: - logger.exception("Failed to modify /etc/hosts") - return False - else: - return True diff --git a/python_pkg/steam_backlog_enforcer/tests/__init__.py b/python_pkg/steam_backlog_enforcer/tests/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/python_pkg/steam_backlog_enforcer/tests/conftest.py b/python_pkg/steam_backlog_enforcer/tests/conftest.py deleted file mode 100644 index 021dfd8..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/conftest.py +++ /dev/null @@ -1,155 +0,0 @@ -"""Safety conftest: prevent tests from touching real Steam/config files. - -Redirects all filesystem paths used by the steam_backlog_enforcer package -to temporary directories. This stops tests from accidentally: - - Deleting real game files via uninstall_other_games / uninstall_game - - Overwriting ~/.config/steam_backlog_enforcer/state.json (losing the - user's current assignment) - - Reading real appmanifest files from ~/.local/share/Steam/steamapps - - Modifying /etc/hosts via the store blocker - - Corrupting the HLTB cache on disk - - Launching real Steam or calling real subprocess commands -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from collections.abc import Iterator - from pathlib import Path - - -@pytest.fixture(autouse=True) -def _isolate_filesystem(tmp_path: Path) -> Iterator[None]: - """Redirect all real filesystem paths to a temporary directory. - - Individual tests that also patch these paths will simply override - this fixture's patches for the duration of their own ``with`` block. - """ - fake_config = tmp_path / "config" - fake_config.mkdir() - fake_steamapps = tmp_path / "steamapps" - fake_steamapps.mkdir() - fake_hosts = tmp_path / "hosts" - - with ( - # Config / state / snapshot paths (used by State.save, Config.save, etc.) - patch( - "python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", - fake_config, - ), - patch( - "python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", - fake_config / "config.json", - ), - patch( - "python_pkg.steam_backlog_enforcer.config.STATE_FILE", - fake_config / "state.json", - ), - patch( - "python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", - fake_config / "snapshot.json", - ), - # Steam game manifests / install dirs - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - fake_steamapps, - ), - # HLTB cache file (computed at import time from CONFIG_DIR, so - # patching CONFIG_DIR alone does not redirect it) - patch( - "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", - fake_config / "hltb_cache.json", - ), - # /etc/hosts (store blocker) - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", - fake_hosts, - ), - patch( - "python_pkg.steam_backlog_enforcer.config.HOSTS_FILE", - fake_hosts, - ), - # Whitelist exception files (_whitelist module-level constants) - patch( - "python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE", - fake_config / "pending_exceptions.json", - ), - patch( - "python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - fake_config / "approved_exceptions.json", - ), - patch( - "python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", - fake_config / "exception_audit.log", - ), - # _enforce_loop imports CONFIG_FILE directly; patch the local binding so - # lock_enforcement_files() uses the tmp path instead of the real one. - patch( - "python_pkg.steam_backlog_enforcer._enforce_loop.CONFIG_FILE", - fake_config / "config.json", - ), - ): - yield - - -@pytest.fixture(autouse=True) -def _block_real_subprocesses() -> Iterator[None]: - """Block subprocess calls that could launch real Steam or modify system. - - Individual tests that need to test subprocess behaviour should - patch the specific module's ``subprocess.run`` / ``subprocess.Popen`` - themselves — their local patch will override this one. - """ - noop_run = MagicMock(return_value=MagicMock(returncode=1)) - noop_popen = MagicMock() - - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - noop_run, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen", - noop_popen, - ), - patch( - "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", - noop_run, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - noop_run, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", - noop_run, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.Popen", - noop_popen, - ), - ): - yield - - -@pytest.fixture(autouse=True) -def _no_real_sleep() -> Iterator[None]: - """No-op every ``time.sleep`` used by the package. - - Several modules call ``time.sleep`` for Steam-launch / install-retry / - rate-limit pacing. Individual tests that need to observe sleep - behaviour can override these patches inside their own ``with`` block. - """ - noop = MagicMock() - with ( - patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep", noop), - patch("python_pkg.steam_backlog_enforcer.library_hider.time.sleep", noop), - patch("python_pkg.steam_backlog_enforcer.steam_api.time.sleep", noop), - patch("python_pkg.steam_backlog_enforcer._enforce_loop.time.sleep", noop), - ): - yield diff --git a/python_pkg/steam_backlog_enforcer/tests/test_cmd_done_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_cmd_done_part2.py deleted file mode 100644 index b247c54..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_cmd_done_part2.py +++ /dev/null @@ -1,87 +0,0 @@ -"""Tests for _cmd_done module (part 2): _prompt_keep_or_skip.""" - -from __future__ import annotations - -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer._cmd_done import _prompt_keep_or_skip -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done" - - -class TestPromptKeepOrSkip: - """Tests for _prompt_keep_or_skip.""" - - def _game(self, hours: float = 5.0) -> GameInfo: - return GameInfo( - app_id=42, - name="Test", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=60, - completionist_hours=hours, - ) - - def test_non_tty_accepts_silently(self) -> None: - with patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin: - mock_stdin.isatty.return_value = False - assert _prompt_keep_or_skip(self._game()) is True - - def test_yes_answers_accept(self) -> None: - for answer in ("y", "Y", "yes", "YES", ""): - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch(f"{CMD_DONE_PKG}._echo"), - patch("builtins.input", return_value=answer), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is True, answer - - def test_no_answers_reject(self) -> None: - for answer in ("n", "N", "no", "NO"): - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch(f"{CMD_DONE_PKG}._echo"), - patch("builtins.input", return_value=answer), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is False, answer - - def test_invalid_then_yes(self) -> None: - echoed: list[str] = [] - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch( - f"{CMD_DONE_PKG}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("builtins.input", side_effect=["maybe", "y"]), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is True - assert any("answer 'y' or 'n'" in line for line in echoed) - - def test_eof_accepts(self) -> None: - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch(f"{CMD_DONE_PKG}._echo"), - patch("builtins.input", side_effect=EOFError), - ): - mock_stdin.isatty.return_value = True - assert _prompt_keep_or_skip(self._game()) is True - - def test_zero_hours_omits_hours_string(self) -> None: - echoed: list[str] = [] - with ( - patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin, - patch( - f"{CMD_DONE_PKG}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("builtins.input", return_value="y"), - ): - mock_stdin.isatty.return_value = True - _prompt_keep_or_skip(self._game(hours=0.0)) - # Without hours, the printed line should not contain "~" - assert not any("~" in line for line in echoed if "Next pick" in line) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_config.py b/python_pkg/steam_backlog_enforcer/tests/test_config.py deleted file mode 100644 index aba7925..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_config.py +++ /dev/null @@ -1,264 +0,0 @@ -"""Tests for config module.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING, Any -from unittest.mock import patch - -import pytest - -from python_pkg.steam_backlog_enforcer.config import ( - Config, - State, - _atomic_write, - interactive_setup, - load_snapshot, - save_snapshot, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestAtomicWrite: - """Tests for _atomic_write.""" - - def test_writes_file(self, tmp_path: Path) -> None: - target = tmp_path / "out.json" - _atomic_write(target, '{"key": "value"}\n') - assert target.read_text(encoding="utf-8") == '{"key": "value"}\n' - - def test_creates_parent_dirs(self, tmp_path: Path) -> None: - target = tmp_path / "sub" / "deep" / "out.json" - _atomic_write(target, "data") - assert target.read_text(encoding="utf-8") == "data" - - def test_cleanup_on_write_error(self, tmp_path: Path) -> None: - target = tmp_path / "out.json" - with ( - patch( - "python_pkg.steam_backlog_enforcer.config.os.write", - side_effect=OSError("disk full"), - ), - pytest.raises(OSError, match="disk full"), - ): - _atomic_write(target, "data") - assert not target.exists() - tmp_files = list(tmp_path.glob("*.tmp")) - assert tmp_files == [] - - def test_cleanup_on_replace_error(self, tmp_path: Path) -> None: - target = tmp_path / "out.json" - with ( - patch.object( - type(target), - "replace", - side_effect=OSError("no perm"), - ), - pytest.raises(OSError, match="no perm"), - ): - _atomic_write(target, "data") - assert not target.exists() - tmp_files = list(tmp_path.glob("*.tmp")) - assert tmp_files == [] - - -class TestConfig: - """Tests for Config dataclass.""" - - def test_defaults(self) -> None: - cfg = Config() - assert cfg.steam_api_key == "" - assert cfg.steam_id == "" - assert cfg.block_store is True - assert cfg.kill_unauthorized_games is True - assert cfg.uninstall_other_games is True - assert cfg.desktop_notifications is True - - def test_save(self, tmp_path: Path) -> None: - cfg = Config(steam_api_key="abc", steam_id="123") - config_dir = tmp_path / "cfg" - config_file = config_dir / "config.json" - with ( - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file), - ): - cfg.save() - data = json.loads(config_file.read_text(encoding="utf-8")) - assert data["steam_api_key"] == "abc" - assert data["steam_id"] == "123" - - def test_load_existing(self, tmp_path: Path) -> None: - config_file = tmp_path / "config.json" - config_file.write_text( - json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n", - encoding="utf-8", - ) - with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file): - cfg = Config.load() - assert cfg.steam_api_key == "key1" - assert cfg.steam_id == "id1" - - def test_load_missing(self, tmp_path: Path) -> None: - config_file = tmp_path / "nonexistent.json" - with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file): - cfg = Config.load() - assert cfg.steam_api_key == "" - - def test_load_extra_fields_ignored(self, tmp_path: Path) -> None: - config_file = tmp_path / "config.json" - config_file.write_text( - json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n", - encoding="utf-8", - ) - with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file): - cfg = Config.load() - assert cfg.steam_api_key == "k" - - -class TestState: - """Tests for State dataclass.""" - - def test_defaults(self) -> None: - state = State() - assert state.current_app_id is None - assert state.current_game_name == "" - assert state.finished_app_ids == [] - - def test_save(self, tmp_path: Path) -> None: - state = State(current_app_id=100, current_game_name="TestGame") - config_dir = tmp_path / "cfg" - state_file = config_dir / "state.json" - with ( - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file), - ): - state.save() - data = json.loads(state_file.read_text(encoding="utf-8")) - assert data["current_app_id"] == 100 - assert data["current_game_name"] == "TestGame" - - def test_load_existing(self, tmp_path: Path) -> None: - state_file = tmp_path / "state.json" - state_file.write_text( - json.dumps( - { - "current_app_id": 50, - "current_game_name": "G", - "finished_app_ids": [1, 2], - } - ) - + "\n", - encoding="utf-8", - ) - with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file): - st = State.load() - assert st.current_app_id == 50 - assert st.finished_app_ids == [1, 2] - - def test_load_missing(self, tmp_path: Path) -> None: - state_file = tmp_path / "nonexistent.json" - with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file): - st = State.load() - assert st.current_app_id is None - - def test_load_corrupt(self, tmp_path: Path) -> None: - state_file = tmp_path / "state.json" - state_file.write_text("not valid json{{", encoding="utf-8") - with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file): - st = State.load() - assert st.current_app_id is None - assert st.current_game_name == "" - - def test_skip_for_days_records_iso_timestamp(self) -> None: - state = State() - state.skip_for_days(42, 7) - assert "42" in state.skipped_until - # Round-trip parse and check ~7 days in the future. - from datetime import datetime, timezone - - expiry = datetime.fromisoformat(state.skipped_until["42"]) - delta = (expiry - datetime.now(timezone.utc)).total_seconds() - assert 6 * 86400 < delta <= 7 * 86400 + 1 - - def test_active_skipped_ids_returns_active(self) -> None: - from datetime import datetime, timedelta, timezone - - state = State() - future = datetime.now(timezone.utc) + timedelta(days=3) - state.skipped_until["100"] = future.isoformat() - assert state.active_skipped_ids() == {100} - # Active entry retained. - assert "100" in state.skipped_until - - def test_active_skipped_ids_prunes_expired(self) -> None: - from datetime import datetime, timedelta, timezone - - state = State() - past = datetime.now(timezone.utc) - timedelta(days=1) - state.skipped_until["50"] = past.isoformat() - assert state.active_skipped_ids() == set() - assert "50" not in state.skipped_until - - def test_active_skipped_ids_prunes_malformed(self) -> None: - state = State() - state.skipped_until["77"] = "not-a-date" - assert state.active_skipped_ids() == set() - assert "77" not in state.skipped_until - - -class TestSnapshot: - """Tests for snapshot save/load.""" - - def test_save_and_load(self, tmp_path: Path) -> None: - config_dir = tmp_path / "cfg" - snap_file = config_dir / "snapshot.json" - with ( - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file), - ): - data: list[dict[str, Any]] = [{"app_id": 1, "name": "G1"}] - save_snapshot(data) - loaded = load_snapshot() - assert loaded == data - - def test_load_none(self, tmp_path: Path) -> None: - snap_file = tmp_path / "nonexistent.json" - with patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file): - assert load_snapshot() is None - - -class TestInteractiveSetup: - """Tests for interactive_setup.""" - - def test_success(self, tmp_path: Path) -> None: - config_dir = tmp_path / "cfg" - config_file = config_dir / "config.json" - with ( - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file), - patch("builtins.input", side_effect=["mykey", "myid"]), - ): - cfg = interactive_setup() - assert cfg.steam_api_key == "mykey" - assert cfg.steam_id == "myid" - assert config_file.exists() - - def test_empty_api_key_exits(self) -> None: - with ( - patch("builtins.input", return_value=""), - pytest.raises(SystemExit), - ): - interactive_setup() - - def test_empty_steam_id_exits(self, tmp_path: Path) -> None: - config_dir = tmp_path / "cfg" - config_file = config_dir / "config.json" - with ( - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir), - patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file), - patch("builtins.input", side_effect=["key", ""]), - pytest.raises(SystemExit), - ): - interactive_setup() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py b/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py deleted file mode 100644 index 9d87682..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop.py +++ /dev/null @@ -1,373 +0,0 @@ -"""Tests for _enforce_loop module.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer._enforce_loop import ( - _enforce_auto_install, - _enforce_hide_games, - _enforce_setup, - _guard_installed_games, - _load_owned_app_ids_cache, - _save_owned_app_ids_cache, - get_all_owned_app_ids, -) -from python_pkg.steam_backlog_enforcer.config import Config, State - -if TYPE_CHECKING: - from pathlib import Path - -PKG = "python_pkg.steam_backlog_enforcer._enforce_loop" - - -class TestGetAllOwnedAppIds: - """Tests for get_all_owned_app_ids.""" - - def test_snapshot_used_when_api_fails(self) -> None: - snap = [{"app_id": 1}, {"app_id": 2}] - with ( - patch(f"{PKG}.load_snapshot", return_value=snap), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", side_effect=OSError("boom")), - ): - assert get_all_owned_app_ids(Config()) == [1, 2] - - def test_no_snapshot_falls_back_to_api(self) -> None: - mock_client = MagicMock() - mock_client.get_owned_games.return_value = [ - {"appid": 10}, - {"appid": 20}, - ] - with ( - patch(f"{PKG}.load_snapshot", return_value=None), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), - ): - result = get_all_owned_app_ids( - Config(steam_api_key="k", steam_id="i"), - ) - assert result == [10, 20] - - def test_api_fails(self) -> None: - with ( - patch(f"{PKG}.load_snapshot", return_value=None), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch( - f"{PKG}.SteamAPIClient", - side_effect=OSError("fail"), - ), - ): - assert get_all_owned_app_ids(Config()) == [] - - def test_empty_snapshot_falls_through_to_api(self) -> None: - mock_client = MagicMock() - mock_client.get_owned_games.return_value = [{"appid": 5}] - with ( - patch(f"{PKG}.load_snapshot", return_value=[]), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), - ): - assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5] - - def test_merges_snapshot_with_api_results(self) -> None: - mock_client = MagicMock() - mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}] - with ( - patch( - f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}] - ), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), - ): - assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [ - 10, - 20, - 30, - ] - - def test_uses_owned_ids_cache_without_api_call(self) -> None: - with ( - patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 30}]), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20]), - patch(f"{PKG}.SteamAPIClient") as mock_client, - ): - result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) - - assert result == [10, 20, 30] - mock_client.assert_not_called() - - def test_cached_ids_merge_deduplicates_entries(self) -> None: - with ( - patch( - f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}] - ), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20, 20]), - patch(f"{PKG}.SteamAPIClient") as mock_client, - ): - result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) - - assert result == [10, 20, 30] - mock_client.assert_not_called() - - def test_api_success_saves_owned_ids_cache(self) -> None: - mock_client = MagicMock() - mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}] - with ( - patch(f"{PKG}.load_snapshot", return_value=[]), - patch(f"{PKG}._load_owned_app_ids_cache", return_value=None), - patch(f"{PKG}.SteamAPIClient", return_value=mock_client), - patch(f"{PKG}._save_owned_app_ids_cache") as mock_save, - ): - result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) - - assert result == [10, 20] - mock_save.assert_called_once_with("i", [10, 20]) - - -class TestOwnedIdsCacheHelpers: - """Tests for owned app IDs cache helper functions.""" - - def test_load_cache_no_steam_id(self, tmp_path: Path) -> None: - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"): - assert _load_owned_app_ids_cache("") is None - - def test_load_cache_missing_file(self, tmp_path: Path) -> None: - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"): - assert _load_owned_app_ids_cache("sid") is None - - def test_load_cache_invalid_json(self, tmp_path: Path) -> None: - cache_file = tmp_path / "owned.json" - cache_file.write_text("{invalid", encoding="utf-8") - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file): - assert _load_owned_app_ids_cache("sid") is None - - def test_load_cache_wrong_steam_id(self, tmp_path: Path) -> None: - cache_file = tmp_path / "owned.json" - cache_file.write_text( - json.dumps({"steam_id": "other", "fetched_at": 1e12, "app_ids": [1]}), - encoding="utf-8", - ) - with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file): - assert _load_owned_app_ids_cache("sid") is None - - def test_load_cache_stale(self, tmp_path: Path) -> None: - cache_file = tmp_path / "owned.json" - cache_file.write_text( - json.dumps({"steam_id": "sid", "fetched_at": 0, "app_ids": [1]}), - encoding="utf-8", - ) - with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), - patch(f"{PKG}.time.time", return_value=10_000.0), - patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), - ): - assert _load_owned_app_ids_cache("sid") is None - - def test_load_cache_non_list_ids(self, tmp_path: Path) -> None: - cache_file = tmp_path / "owned.json" - cache_file.write_text( - json.dumps({"steam_id": "sid", "fetched_at": 10_000.0, "app_ids": 1}), - encoding="utf-8", - ) - with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), - patch(f"{PKG}.time.time", return_value=10_010.0), - patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), - ): - assert _load_owned_app_ids_cache("sid") is None - - def test_load_cache_valid(self, tmp_path: Path) -> None: - cache_file = tmp_path / "owned.json" - cache_file.write_text( - json.dumps( - {"steam_id": "sid", "fetched_at": 10_000.0, "app_ids": ["1", 2]} - ), - encoding="utf-8", - ) - with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), - patch(f"{PKG}.time.time", return_value=10_010.0), - patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60), - ): - assert _load_owned_app_ids_cache("sid") == [1, 2] - - def test_save_cache_writes_atomic_payload(self, tmp_path: Path) -> None: - cache_file = tmp_path / "owned.json" - with ( - patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file), - patch(f"{PKG}.time.time", return_value=123.0), - patch(f"{PKG}._atomic_write") as mock_atomic, - ): - _save_owned_app_ids_cache("sid", [10, 20]) - - mock_atomic.assert_called_once() - path_arg = mock_atomic.call_args.args[0] - payload_arg = mock_atomic.call_args.args[1] - assert path_arg == cache_file - assert '"steam_id": "sid"' in payload_arg - assert '"app_ids": [\n 10,\n 20\n ]' in payload_arg - - -class TestGuardInstalledGames: - """Tests for _guard_installed_games.""" - - def test_removes_unauthorized(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(999, "Bad Game")], - ), - patch(f"{PKG}.uninstall_game", return_value=True), - patch(f"{PKG}.send_notification"), - ): - assert _guard_installed_games(440) == 1 - - def test_skips_allowed(self) -> None: - with patch( - f"{PKG}.get_installed_games", - return_value=[(440, "TF2")], - ): - assert _guard_installed_games(440) == 0 - - def test_skips_protected(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(228980, "Runtime")], - ), - patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980), - ): - assert _guard_installed_games(440) == 0 - - def test_uninstall_fails(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(999, "Bad")], - ), - patch(f"{PKG}.uninstall_game", return_value=False), - ): - assert _guard_installed_games(440) == 0 - - def test_allowed_none_skips(self) -> None: - assert _guard_installed_games(None) == 0 - - -class TestEnforceSetup: - """Tests for _enforce_setup.""" - - def test_block_store_success(self) -> None: - config = Config(block_store=True, uninstall_other_games=False) - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.block_store", return_value=True), - patch(f"{PKG}._echo"), - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - - def test_block_store_fail(self) -> None: - config = Config(block_store=True, uninstall_other_games=False) - state = State() - with ( - patch(f"{PKG}.block_store", return_value=False), - patch(f"{PKG}._echo") as mock_echo, - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - assert any("FAILED" in str(c) for c in mock_echo.call_args_list) - - def test_no_block_store(self) -> None: - config = Config(block_store=False, uninstall_other_games=False) - state = State() - with ( - patch(f"{PKG}.block_store") as mock_block, - patch(f"{PKG}._echo"), - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - mock_block.assert_not_called() - - def test_uninstall_other_games(self) -> None: - config = Config(uninstall_other_games=True, block_store=False) - state = State(current_app_id=1) - with ( - patch(f"{PKG}.uninstall_other_games", return_value=3), - patch(f"{PKG}._echo"), - patch(f"{PKG}._enforce_auto_install"), - patch(f"{PKG}._enforce_hide_games"), - ): - _enforce_setup(config, state) - - -class TestEnforceAutoInstall: - """Tests for _enforce_auto_install.""" - - def test_no_app_id(self) -> None: - _enforce_auto_install(Config(), State()) - - 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"), - ): - _enforce_auto_install(Config(), state) - - def test_installs_successfully(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}.send_notification"), - patch(f"{PKG}._echo"), - ): - _enforce_auto_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") as mock_echo, - ): - _enforce_auto_install(Config(steam_id="i"), state) - assert any("manually" in str(c) for c in mock_echo.call_args_list) - - -class TestEnforceHideGames: - """Tests for _enforce_hide_games.""" - - def test_hides_some(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), - patch(f"{PKG}.hide_other_games", return_value=2), - patch(f"{PKG}._echo"), - ): - _enforce_hide_games(Config(), state) - - def test_already_hidden(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]), - patch(f"{PKG}.hide_other_games", return_value=0), - patch(f"{PKG}._echo") as mock_echo, - ): - _enforce_hide_games(Config(), state) - assert any("already" in str(c) for c in mock_echo.call_args_list) - - def test_no_owned_ids(self) -> None: - state = State(current_app_id=1) - with ( - patch(f"{PKG}.get_all_owned_app_ids", return_value=[]), - patch(f"{PKG}._echo") as mock_echo, - ): - _enforce_hide_games(Config(), state) - assert any("skipped" in str(c) for c in mock_echo.call_args_list) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop_part2.py deleted file mode 100644 index 222c47b..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_enforce_loop_part2.py +++ /dev/null @@ -1,195 +0,0 @@ -"""Tests for _enforce_loop module (part 2).""" - -from __future__ import annotations - -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer._enforce_loop import ( - _enforce_loop_iteration, - do_enforce, -) -from python_pkg.steam_backlog_enforcer.config import Config, State - -PKG = "python_pkg.steam_backlog_enforcer._enforce_loop" - - -class TestEnforceLoopIteration: - """Tests for _enforce_loop_iteration.""" - - def test_kills_unauthorized(self) -> None: - config = Config( - kill_unauthorized_games=True, - uninstall_other_games=False, - ) - state = State(current_app_id=1, current_game_name="G") - with ( - patch( - f"{PKG}.enforce_allowed_game", - return_value=[(1234, 999)], - ), - patch(f"{PKG}.send_notification"), - patch(f"{PKG}._echo"), - patch(f"{PKG}.is_game_installed", return_value=True), - ): - _enforce_loop_iteration(config, state) - - def test_no_kill(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"{PKG}.enforce_allowed_game") as mock_enforce, - patch(f"{PKG}.is_game_installed", return_value=True), - ): - _enforce_loop_iteration(config, state) - mock_enforce.assert_not_called() - - def test_guards_installed(self) -> None: - config = Config( - kill_unauthorized_games=False, - uninstall_other_games=True, - ) - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}._guard_installed_games", return_value=1), - patch(f"{PKG}._echo"), - patch(f"{PKG}.is_game_installed", return_value=True), - ): - _enforce_loop_iteration(config, state) - - def test_guard_removes_zero(self) -> None: - config = Config( - kill_unauthorized_games=False, - uninstall_other_games=True, - ) - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}._guard_installed_games", return_value=0), - patch(f"{PKG}.is_game_installed", return_value=True), - ): - _enforce_loop_iteration(config, state) - - def test_reinstalls_missing(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"{PKG}.is_game_installed", return_value=False), - patch(f"{PKG}.install_game") as mock_install, - ): - _enforce_loop_iteration(config, state) - mock_install.assert_called_once() - - def test_no_app_id_skip_reinstall(self) -> None: - config = Config( - kill_unauthorized_games=False, - uninstall_other_games=False, - ) - state = State(current_app_id=None) - with ( - patch(f"{PKG}.enforce_allowed_game") as mock_enforce, - patch(f"{PKG}._guard_installed_games") as mock_guard, - patch(f"{PKG}.is_game_installed") as mock_installed, - ): - _enforce_loop_iteration(config, state) - mock_enforce.assert_not_called() - mock_guard.assert_not_called() - mock_installed.assert_not_called() - - def test_promotes_newly_approved_exceptions(self) -> None: - """Loop body at line 286 executes when promote returns non-empty list.""" - config = Config( - kill_unauthorized_games=False, - uninstall_other_games=False, - ) - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}.is_game_installed", return_value=True), - patch( - f"{PKG}.promote_pending_exceptions", - return_value=[440], - ), - ): - _enforce_loop_iteration(config, state) - - -class TestDoEnforce: - """Tests for do_enforce.""" - - def test_no_game(self) -> None: - with patch(f"{PKG}._echo") as mock_echo: - do_enforce(Config(), State()) - assert any("No game" in str(c) for c in mock_echo.call_args_list) - - def test_keyboard_interrupt(self) -> None: - state = State(current_app_id=1, current_game_name="G") - config = Config() - fresh = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{PKG}._enforce_setup"), - patch(f"{PKG}._echo"), - patch.object(State, "load", return_value=fresh), - patch( - f"{PKG}._enforce_loop_iteration", - side_effect=KeyboardInterrupt, - ), - patch(f"{PKG}.time.sleep"), - ): - do_enforce(config, state) - - def test_runs_iterations(self) -> None: - state = State(current_app_id=1, current_game_name="G") - config = Config() - fresh = State(current_app_id=1, current_game_name="G") - call_count = 0 - - def side_effect(*_args: object, **_kwargs: object) -> None: - nonlocal call_count - call_count += 1 - if call_count >= 2: - raise KeyboardInterrupt - - with ( - patch(f"{PKG}._enforce_setup"), - patch(f"{PKG}._echo"), - patch.object(State, "load", return_value=fresh), - patch( - f"{PKG}._enforce_loop_iteration", - side_effect=side_effect, - ), - patch(f"{PKG}.time.sleep"), - ): - do_enforce(config, state) - assert call_count == 2 - - def test_state_load_failure_continues(self) -> None: - """Corrupt state file should not crash the daemon.""" - import json as json_mod - - state = State(current_app_id=1, current_game_name="G") - config = Config() - call_count = 0 - - def load_side_effect() -> State: - nonlocal call_count - call_count += 1 - if call_count == 1: - msg = "bad" - raise json_mod.JSONDecodeError(msg, "", 0) - if call_count == 2: - raise KeyboardInterrupt - return State(current_app_id=1) # pragma: no cover - - with ( - patch(f"{PKG}._enforce_setup"), - patch(f"{PKG}._echo"), - patch.object(State, "load", side_effect=load_side_effect), - patch(f"{PKG}._enforce_loop_iteration") as mock_iter, - patch(f"{PKG}.time.sleep"), - ): - do_enforce(config, state) - mock_iter.assert_not_called() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py b/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py deleted file mode 100644 index e2ee1c2..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_enforcer.py +++ /dev/null @@ -1,201 +0,0 @@ -"""Tests for enforcer module.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer.enforcer import ( - enforce_allowed_game, - get_running_steam_game_pids, - kill_process, - send_notification, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestGetRunningPids: - """Tests for get_running_steam_game_pids.""" - - def test_finds_steam_pid(self, tmp_path: Path) -> None: - proc_dir = tmp_path / "proc" - pid_dir = proc_dir / "12345" - pid_dir.mkdir(parents=True) - environ = b"HOME=/home/user\x00SteamAppId=440\x00PATH=/usr/bin" - (pid_dir / "environ").write_bytes(environ) - - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.Path", - return_value=proc_dir, - ): - result = get_running_steam_game_pids() - assert result == {12345: 440} - - def test_skips_non_digit_entries(self, tmp_path: Path) -> None: - proc_dir = tmp_path / "proc" - proc_dir.mkdir(parents=True) - (proc_dir / "self").mkdir() - (proc_dir / "cpuinfo").touch() - - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.Path", - return_value=proc_dir, - ): - result = get_running_steam_game_pids() - assert result == {} - - def test_handles_permission_error(self, tmp_path: Path) -> None: - proc_dir = tmp_path / "proc" - pid_dir = proc_dir / "99" - pid_dir.mkdir(parents=True) - # No environ file -> OSError when reading - - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.Path", - return_value=proc_dir, - ): - result = get_running_steam_game_pids() - assert result == {} - - def test_skips_non_digit_steam_app_id(self, tmp_path: Path) -> None: - proc_dir = tmp_path / "proc" - pid_dir = proc_dir / "100" - pid_dir.mkdir(parents=True) - environ = b"SteamAppId=notanumber\x00" - (pid_dir / "environ").write_bytes(environ) - - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.Path", - return_value=proc_dir, - ): - result = get_running_steam_game_pids() - assert result == {} - - def test_no_steam_env(self, tmp_path: Path) -> None: - proc_dir = tmp_path / "proc" - pid_dir = proc_dir / "200" - pid_dir.mkdir(parents=True) - environ = b"HOME=/home/user\x00PATH=/usr/bin\x00" - (pid_dir / "environ").write_bytes(environ) - - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.Path", - return_value=proc_dir, - ): - result = get_running_steam_game_pids() - assert result == {} - - -class TestEnforceAllowedGame: - """Tests for enforce_allowed_game.""" - - def test_no_violations(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", - return_value={100: 440}, - ): - result = enforce_allowed_game(440) - assert result == [] - - def test_kills_unauthorized(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", - return_value={100: 570, 200: 440}, - ), - patch( - "python_pkg.steam_backlog_enforcer.enforcer.kill_process" - ) as mock_kill, - ): - result = enforce_allowed_game(440, kill_unauthorized=True) - assert result == [(100, 570)] - mock_kill.assert_called_once_with(100, 570) - - def test_skips_app_id_zero(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", - return_value={100: 0}, - ): - result = enforce_allowed_game(440) - assert result == [] - - def test_detects_without_killing(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", - return_value={100: 570}, - ): - result = enforce_allowed_game(440, kill_unauthorized=False) - assert result == [(100, 570)] - - def test_allowed_none(self) -> None: - result = enforce_allowed_game(None, kill_unauthorized=True) - assert result == [] - - def test_skips_protected_app_id(self) -> None: - """Protected IDs must never be killed even if not the assigned game.""" - with ( - patch( - "python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids", - return_value={100: 1331550, 200: 440}, - ), - patch( - "python_pkg.steam_backlog_enforcer.enforcer.is_protected_app", - side_effect=lambda aid: aid == 1331550, - ), - patch( - "python_pkg.steam_backlog_enforcer.enforcer.kill_process" - ) as mock_kill, - ): - result = enforce_allowed_game(440, kill_unauthorized=True) - assert result == [] - mock_kill.assert_not_called() - - -class TestKillProcess: - """Tests for kill_process.""" - - def test_kill_success(self) -> None: - with patch("python_pkg.steam_backlog_enforcer.enforcer.os.kill") as mock_kill: - kill_process(123, 440) - mock_kill.assert_called_once() - - def test_process_already_gone(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.os.kill", - side_effect=ProcessLookupError, - ): - kill_process(123, 440) # Should not raise - - def test_permission_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.os.kill", - side_effect=PermissionError, - ): - kill_process(123, 440) # Should not raise - - -class TestSendNotification: - """Tests for send_notification.""" - - def test_sends(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run" - ) as mock_run: - send_notification("Title", "Body") - mock_run.assert_called_once() - - def test_handles_missing_notify_send(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", - side_effect=FileNotFoundError, - ): - send_notification("Title", "Body") # Should not raise - - def test_handles_os_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.enforcer.subprocess.run", - side_effect=OSError, - ): - send_notification("Title", "Body") # Should not raise diff --git a/python_pkg/steam_backlog_enforcer/tests/test_game_install.py b/python_pkg/steam_backlog_enforcer/tests/test_game_install.py deleted file mode 100644 index 09354fc..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_game_install.py +++ /dev/null @@ -1,280 +0,0 @@ -"""Tests for game_install module.""" - -from __future__ import annotations - -import os -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -from python_pkg.steam_backlog_enforcer.game_install import ( - _assert_not_real_steam, - _echo, - _ensure_steam_running, - _get_real_user, - _get_uid_gid_for_user, - _trigger_steam_install, - is_game_installed, -) - -if TYPE_CHECKING: - from pathlib import Path - - -PKG = "python_pkg.steam_backlog_enforcer.game_install" - - -class TestAssertNotRealSteam: - """Tests for the _assert_not_real_steam safety guard.""" - - def test_allows_tmp_path(self, tmp_path: Path) -> None: - """Non-Steam paths pass through without raising.""" - _assert_not_real_steam(tmp_path / "appmanifest_440.acf") - - def test_raises_when_real_steam_not_redirected(self, tmp_path: Path) -> None: - """Raises when path is under real Steam and STEAMAPPS_PATH is real.""" - real = tmp_path / "real_steam" - real.mkdir() - fake_manifest = real / "appmanifest_440.acf" - fake_manifest.touch() - with ( - patch(f"{PKG}._REAL_STEAMAPPS", real), - patch(f"{PKG}.STEAMAPPS_PATH", real), - pytest.raises(RuntimeError, match="SAFETY"), - ): - _assert_not_real_steam(fake_manifest) - - def test_allows_when_steamapps_redirected(self, tmp_path: Path) -> None: - """No raise when STEAMAPPS_PATH differs from _REAL_STEAMAPPS.""" - real = tmp_path / "real_steam" - real.mkdir() - fake_manifest = real / "appmanifest_440.acf" - fake_manifest.touch() - redirected = tmp_path / "fake_steam" - redirected.mkdir() - with ( - patch(f"{PKG}._REAL_STEAMAPPS", real), - patch(f"{PKG}.STEAMAPPS_PATH", redirected), - ): - _assert_not_real_steam(fake_manifest) - - def test_noop_outside_pytest(self, tmp_path: Path) -> None: - """In production (no PYTEST_CURRENT_TEST) the guard is a no-op.""" - real = tmp_path / "real_steam" - real.mkdir() - fake_manifest = real / "appmanifest_440.acf" - fake_manifest.touch() - env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"} - with ( - patch.dict(os.environ, env, clear=True), - patch(f"{PKG}._REAL_STEAMAPPS", real), - patch(f"{PKG}.STEAMAPPS_PATH", real), - ): - _assert_not_real_steam(fake_manifest) - - -class TestEcho: - """Tests for _echo.""" - - def test_default(self, capsys: pytest.CaptureFixture[str]) -> None: - _echo("hello") - assert capsys.readouterr().out == "hello\n" - - def test_custom_end(self, capsys: pytest.CaptureFixture[str]) -> None: - _echo("hi", end="") - assert capsys.readouterr().out == "hi" - - def test_empty(self, capsys: pytest.CaptureFixture[str]) -> None: - _echo() - assert capsys.readouterr().out == "\n" - - def test_flush(self, capsys: pytest.CaptureFixture[str]) -> None: - _echo("x", flush=True) - assert capsys.readouterr().out == "x\n" - - -class TestTriggerSteamInstall: - """Tests for _trigger_steam_install.""" - - def test_success(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run" - ) as mock_run: - result = _trigger_steam_install(440, "TF2") - assert result is True - mock_run.assert_called_once() - - def test_file_not_found(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - side_effect=FileNotFoundError, - ): - result = _trigger_steam_install(440, "TF2") - assert result is False - - def test_os_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - side_effect=OSError, - ): - result = _trigger_steam_install(440, "TF2") - assert result is False - - def test_timeout(self) -> None: - import subprocess - - with patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - side_effect=subprocess.TimeoutExpired("cmd", 15), - ): - result = _trigger_steam_install(440, "TF2") - assert result is False - - -class TestGetRealUser: - """Tests for _get_real_user.""" - - def test_sudo_user(self) -> None: - with patch.dict(os.environ, {"SUDO_USER": "alice", "USER": "root"}): - assert _get_real_user() == "alice" - - def test_regular_user(self) -> None: - with patch.dict(os.environ, {"USER": "bob"}, clear=False): - env = os.environ.copy() - env.pop("SUDO_USER", None) - with patch.dict(os.environ, env, clear=True): - assert _get_real_user() == "bob" - - -class TestGetUidGid: - """Tests for _get_uid_gid_for_user.""" - - def test_known_user(self) -> None: - mock_pw = MagicMock() - mock_pw.pw_uid = 1001 - mock_pw.pw_gid = 1001 - with patch( - "python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam", - return_value=mock_pw, - ): - assert _get_uid_gid_for_user("alice") == (1001, 1001) - - def test_unknown_user(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam", - side_effect=KeyError, - ): - assert _get_uid_gid_for_user("nobody") == (1000, 1000) - - -class TestIsGameInstalled: - """Tests for is_game_installed.""" - - def test_installed(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.touch() - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - assert is_game_installed(440) is True - - def test_not_installed(self, tmp_path: Path) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - assert is_game_installed(440) is False - - -class TestEnsureSteamRunning: - """Tests for _ensure_steam_running.""" - - def test_already_running(self) -> None: - mock_result = MagicMock(returncode=0) - with patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ): - _ensure_steam_running() - - def test_not_running_starts_as_non_root(self) -> None: - mock_result = MagicMock(returncode=1) - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen" - ) as mock_popen, - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"), - ): - _ensure_steam_running() - mock_popen.assert_called_once() - - def test_not_running_starts_as_root(self) -> None: - mock_result = MagicMock(returncode=1) - mock_pw = MagicMock() - mock_pw.pw_uid = 1000 - mock_pw.pw_gid = 1000 - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen" - ) as mock_popen, - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=0, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._get_real_user", - return_value="alice", - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user", - return_value=(1000, 1000), - ), - patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"), - ): - _ensure_steam_running() - mock_popen.assert_called_once() - - def test_pgrep_not_found(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - side_effect=FileNotFoundError, - ), - patch("python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"), - ): - _ensure_steam_running() - - def test_steam_executable_not_found(self) -> None: - mock_result = MagicMock(returncode=1) - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.run", - return_value=mock_result, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen", - side_effect=FileNotFoundError, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - ): - _ensure_steam_running() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_game_install_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_game_install_part2.py deleted file mode 100644 index 7aa18b1..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_game_install_part2.py +++ /dev/null @@ -1,163 +0,0 @@ -"""Tests for game_install module — part 2 (missing coverage).""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.game_install import ( - _remove_game_dirs, - uninstall_game, - uninstall_other_games, -) - -if TYPE_CHECKING: - from pathlib import Path - -PKG = "python_pkg.steam_backlog_enforcer.game_install" - - -class TestRemoveGameDirs: - """Tests for _remove_game_dirs.""" - - def test_removes_install_dir(self, tmp_path: Path) -> None: - install_dir = tmp_path / "common" / "MyGame" - install_dir.mkdir(parents=True) - (install_dir / "game.exe").touch() - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): - result = _remove_game_dirs(install_dir, 440) - assert result is True - assert not install_dir.exists() - - def test_install_dir_none(self, tmp_path: Path) -> None: - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): - result = _remove_game_dirs(None, 440) - assert result is True - - def test_install_dir_not_exists(self, tmp_path: Path) -> None: - missing = tmp_path / "common" / "Missing" - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): - result = _remove_game_dirs(missing, 440) - assert result is True - - def test_install_dir_remove_fails(self, tmp_path: Path) -> None: - install_dir = tmp_path / "common" / "MyGame" - install_dir.mkdir(parents=True) - with ( - patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), - patch(f"{PKG}.shutil.rmtree", side_effect=OSError("perm")), - ): - result = _remove_game_dirs(install_dir, 440) - assert result is False - - def test_removes_cache_dirs(self, tmp_path: Path) -> None: - for subdir in ("shadercache", "compatdata"): - (tmp_path / subdir / "440").mkdir(parents=True) - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): - result = _remove_game_dirs(None, 440) - assert result is True - assert not (tmp_path / "shadercache" / "440").exists() - assert not (tmp_path / "compatdata" / "440").exists() - - def test_cache_dir_remove_oserror_suppressed(self, tmp_path: Path) -> None: - (tmp_path / "shadercache" / "440").mkdir(parents=True) - call_count = 0 - - def fake_rmtree(_path: object, **_kw: object) -> None: - nonlocal call_count - call_count += 1 - msg = "perm" - raise OSError(msg) - - with ( - patch(f"{PKG}.STEAMAPPS_PATH", tmp_path), - patch(f"{PKG}.shutil.rmtree", side_effect=fake_rmtree), - ): - result = _remove_game_dirs(None, 440) - assert result is True - - -class TestUninstallGame: - """Tests for uninstall_game.""" - - def test_success(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8") - install_dir = tmp_path / "common" / "TF2" - install_dir.mkdir(parents=True) - with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path): - result = uninstall_game(440, "TF2") - assert result is True - - def test_manifest_removal_fails(self) -> None: - mock_manifest = MagicMock() - mock_manifest.exists.return_value = True - mock_manifest.unlink.side_effect = OSError - with ( - patch(f"{PKG}.STEAMAPPS_PATH", MagicMock()), - patch(f"{PKG}._read_install_dir", return_value=None), - patch(f"{PKG}._remove_manifest", return_value=False), - patch(f"{PKG}._remove_game_dirs", return_value=True), - ): - result = uninstall_game(440, "TF2") - assert result is False - - def test_game_dirs_removal_fails(self) -> None: - with ( - patch(f"{PKG}._read_install_dir", return_value=None), - patch(f"{PKG}._remove_manifest", return_value=True), - patch(f"{PKG}._remove_game_dirs", return_value=False), - ): - result = uninstall_game(440, "TF2") - assert result is False - - -class TestUninstallOtherGames: - """Tests for uninstall_other_games.""" - - def test_keeps_allowed(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(440, "TF2"), (730, "CS")], - ), - patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall, - ): - count = uninstall_other_games(440) - assert count == 1 - mock_uninstall.assert_called_once_with(730, "CS") - - def test_skips_protected(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(228980, "Redist")], - ), - patch(f"{PKG}.uninstall_game") as mock_uninstall, - ): - count = uninstall_other_games(None) - assert count == 0 - mock_uninstall.assert_not_called() - - def test_uninstall_fails(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(999, "GameX")], - ), - patch(f"{PKG}.uninstall_game", return_value=False), - ): - count = uninstall_other_games(None) - assert count == 0 - - def test_all_allowed_or_protected(self) -> None: - with ( - patch( - f"{PKG}.get_installed_games", - return_value=[(440, "TF2"), (228980, "Redist")], - ), - patch(f"{PKG}.uninstall_game") as mock_uninstall, - ): - count = uninstall_other_games(440) - assert count == 0 - mock_uninstall.assert_not_called() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_game_install_part3.py b/python_pkg/steam_backlog_enforcer/tests/test_game_install_part3.py deleted file mode 100644 index b79eb4c..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_game_install_part3.py +++ /dev/null @@ -1,263 +0,0 @@ -"""Tests for game_install module (part 3 — install, get, read, remove).""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.game_install import ( - _read_install_dir, - _remove_manifest, - get_installed_games, - install_game, -) - -if TYPE_CHECKING: - from pathlib import Path - - -PKG = "python_pkg.steam_backlog_enforcer.game_install" - - -class TestInstallGame: - """Tests for install_game.""" - - def test_already_installed(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.touch() - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - assert install_game(440, "TF2", "steam123") is True - - def test_use_steam_protocol_success(self, tmp_path: Path) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install", - return_value=True, - ), - ): - assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True - - def test_use_steam_protocol_fallback(self, tmp_path: Path) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - ): - assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True - assert (tmp_path / "appmanifest_440.acf").exists() - - def test_manifest_write_as_root(self, tmp_path: Path) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=0, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._get_real_user", - return_value="alice", - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user", - return_value=(1001, 1001), - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.chown" - ) as mock_chown, - ): - assert install_game(440, "TF2", "s1") is True - mock_chown.assert_called_once() - - def test_manifest_write_failure(self, tmp_path: Path) -> None: - # Make steamapps path not writable - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path / "nonexistent" / "deep", - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - ): - assert install_game(440, "TF2", "s1") is False - - def test_empty_game_name(self, tmp_path: Path) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - ): - assert install_game(440, "", "s1") is True - - def test_manifest_not_root_no_chown(self, tmp_path: Path) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=1000, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.chown" - ) as mock_chown, - ): - assert install_game(440, "TF2", "s1") is True - mock_chown.assert_not_called() - - def test_root_user_is_root(self, tmp_path: Path) -> None: - """When real user IS root, don't chown.""" - with ( - patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", - tmp_path, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running" - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.geteuid", - return_value=0, - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install._get_real_user", - return_value="root", - ), - patch( - "python_pkg.steam_backlog_enforcer.game_install.os.chown" - ) as mock_chown, - ): - assert install_game(440, "TF2", "s1") is True - mock_chown.assert_not_called() - - -class TestGetInstalledGames: - """Tests for get_installed_games.""" - - def test_parses_manifests(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n') - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - result = get_installed_games() - assert result == [(440, "Team Fortress 2")] - - def test_no_name(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.write_text('"appid"\t\t"440"\n') - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - result = get_installed_games() - assert result == [(440, "Unknown (440)")] - - def test_empty_dir(self, tmp_path: Path) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - result = get_installed_games() - assert result == [] - - def test_no_appid_match(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.write_text('"name"\t\t"NoAppId"\n') - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - result = get_installed_games() - assert result == [] - - -class TestReadInstallDir: - """Tests for _read_install_dir.""" - - def test_reads_dir(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.write_text('"installdir"\t\t"Team Fortress 2"\n') - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - result = _read_install_dir(manifest) - assert result == tmp_path / "common" / "Team Fortress 2" - - def test_no_match(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.write_text('"appid"\t\t"440"\n') - with patch( - "python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path - ): - assert _read_install_dir(manifest) is None - - def test_missing_file(self, tmp_path: Path) -> None: - manifest = tmp_path / "nonexistent.acf" - assert _read_install_dir(manifest) is None - - def test_os_error(self, tmp_path: Path) -> None: - manifest = MagicMock() - manifest.exists.return_value = True - manifest.read_text.side_effect = OSError - assert _read_install_dir(manifest) is None - - -class TestRemoveManifest: - """Tests for _remove_manifest.""" - - def test_removes(self, tmp_path: Path) -> None: - manifest = tmp_path / "appmanifest_440.acf" - manifest.touch() - assert _remove_manifest(manifest, "TF2", 440) is True - assert not manifest.exists() - - def test_already_gone(self, tmp_path: Path) -> None: - manifest = tmp_path / "nonexistent.acf" - assert _remove_manifest(manifest, "TF2", 440) is True - - def test_os_error(self) -> None: - manifest = MagicMock() - manifest.exists.return_value = True - manifest.unlink.side_effect = OSError - assert _remove_manifest(manifest, "TF2", 440) is False diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb.py deleted file mode 100644 index bb87402..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb.py +++ /dev/null @@ -1,443 +0,0 @@ -"""Tests for hltb module.""" - -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp - -from python_pkg.steam_backlog_enforcer._hltb_search import ( - _AuthInfo, - _build_search_payload, - _get_hltb_search_url, - _pick_best_hltb_entry, - _similarity, -) -from python_pkg.steam_backlog_enforcer.hltb import ( - _get_auth_info, - load_hltb_cache, - save_hltb_cache, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestHltbCache: - """Tests for HLTB cache I/O.""" - - def test_load_cache_exists(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file - ): - result = load_hltb_cache() - assert result == {440: 10.5} - - def test_load_cache_missing(self, tmp_path: Path) -> None: - cache_file = tmp_path / "nonexistent.json" - with patch( - "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file - ): - assert load_hltb_cache() == {} - - def test_load_cache_corrupt(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text("not json", encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file - ): - assert load_hltb_cache() == {} - - def test_save_cache(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - with ( - patch( - "python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", - cache_file, - ), - patch("python_pkg.steam_backlog_enforcer._hltb_types.CONFIG_DIR", tmp_path), - ): - save_hltb_cache({440: 10.5}) - assert cache_file.exists() - - def test_save_cache_os_error(self, tmp_path: Path) -> None: - with patch( - "python_pkg.steam_backlog_enforcer._hltb_types._atomic_write", - side_effect=OSError("disk full"), - ): - save_hltb_cache({440: 10.5}) # Should not raise - - -class TestGetHltbSearchUrl: - """Tests for _get_hltb_search_url.""" - - def test_discovers_url(self) -> None: - mock_info = MagicMock() - mock_info.search_url = "/api/search/abc" - with patch( - "python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests" - ) as mock_html: - mock_html.send_website_request_getcode.return_value = mock_info - mock_html.BASE_URL = "https://howlongtobeat.com" - url = _get_hltb_search_url() - assert url == "https://howlongtobeat.com/api/search/abc" - - def test_fallback_url(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests" - ) as mock_html: - mock_html.send_website_request_getcode.return_value = None - url = _get_hltb_search_url() - assert url == "https://howlongtobeat.com/api/finder" - - def test_first_returns_none_second_returns_info(self) -> None: - mock_info = MagicMock() - mock_info.search_url = "/api/search/xyz" - with patch( - "python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests" - ) as mock_html: - mock_html.send_website_request_getcode.side_effect = [None, mock_info] - mock_html.BASE_URL = "https://howlongtobeat.com" - url = _get_hltb_search_url() - assert url == "https://howlongtobeat.com/api/search/xyz" - - def test_exception_fallback(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests" - ) as mock_html: - mock_html.send_website_request_getcode.side_effect = RuntimeError - url = _get_hltb_search_url() - assert url == "https://howlongtobeat.com/api/finder" - - -class TestGetAuthInfo: - """Tests for _get_auth_info.""" - - def test_success(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.json = AsyncMock( - return_value={"token": "abc123", "hpKey": "ign_x", "hpVal": "ff"} - ) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - result = asyncio.run( - _get_auth_info("https://howlongtobeat.com/api/finder", mock_session) - ) - assert result == _AuthInfo("abc123", "ign_x", "ff") - - def test_success_no_hp(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.json = AsyncMock(return_value={"token": "abc123"}) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - result = asyncio.run( - _get_auth_info("https://howlongtobeat.com/api/finder", mock_session) - ) - assert result == _AuthInfo("abc123") - - def test_no_token_key(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.json = AsyncMock(return_value={"notoken": True}) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - result = asyncio.run( - _get_auth_info("https://howlongtobeat.com/api/finder", mock_session) - ) - assert result is None - - def test_non_200(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 500 - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - result = asyncio.run( - _get_auth_info("https://howlongtobeat.com/api/finder", mock_session) - ) - assert result is None - - def test_client_error(self) -> None: - mock_session = MagicMock() - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError) - ctx.__aexit__ = AsyncMock(return_value=False) - mock_session.get = MagicMock(return_value=ctx) - - result = asyncio.run( - _get_auth_info("https://howlongtobeat.com/api/finder", mock_session) - ) - assert result is None - - -class TestSimilarity: - """Tests for _similarity.""" - - def test_identical(self) -> None: - assert _similarity("hello", "hello") == 1.0 - - def test_different(self) -> None: - assert _similarity("abc", "xyz") < 0.5 - - def test_case_insensitive(self) -> None: - assert _similarity("Hello", "hello") == 1.0 - - -class TestBuildSearchPayload: - """Tests for _build_search_payload.""" - - def test_returns_json(self) -> None: - payload = _build_search_payload("Half-Life 2") - data = json.loads(payload) - assert data["searchType"] == "games" - assert data["searchTerms"] == ["Half-Life", "2"] - - def test_with_auth(self) -> None: - auth = _AuthInfo("t", "ign_x", "ff") - payload = _build_search_payload("TF2", auth=auth) - data = json.loads(payload) - assert data["ign_x"] == "ff" - - def test_with_auth_no_hp_key(self) -> None: - auth = _AuthInfo("t") - payload = _build_search_payload("TF2", auth=auth) - data = json.loads(payload) - assert "" not in data - - -class TestPickBestHltbEntry: - """Tests for _pick_best_hltb_entry.""" - - def test_empty(self) -> None: - assert _pick_best_hltb_entry("game", []) is None - - def test_single(self) -> None: - entry: dict[str, Any] = {"game_name": "Game", "comp_100": 3600} - result = _pick_best_hltb_entry("Game", [(entry, 1.0)]) - assert result is not None - assert result[0]["game_name"] == "Game" - - def test_prefers_full_edition_colon(self) -> None: - demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} - full: dict[str, Any] = { - "game_name": "FAITH: The Unholy Trinity", - "comp_100": 7200, - } - result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FAITH: The Unholy Trinity" - - def test_prefers_full_edition_dash(self) -> None: - demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} - full: dict[str, Any] = {"game_name": "FAITH - Complete", "comp_100": 7200} - result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FAITH - Complete" - - def test_falls_back_to_highest_similarity(self) -> None: - a: dict[str, Any] = {"game_name": "ABC", "comp_100": 3600} - b: dict[str, Any] = {"game_name": "DEF", "comp_100": 7200} - result = _pick_best_hltb_entry("ABC", [(a, 0.9), (b, 0.7)]) - assert result is not None - assert result[1] == 0.9 - - def test_prefers_non_dlc_when_available(self) -> None: - base: dict[str, Any] = { - "game_name": "Helltaker", - "game_type": "game", - "comp_100": 6846, - } - dlc: dict[str, Any] = { - "game_name": "Helltaker - Bonus Chapter: Examtaker", - "game_type": "dlc", - "comp_100": 4075, - } - result = _pick_best_hltb_entry("Helltaker", [(dlc, 0.95), (base, 0.8)]) - assert result is not None - assert result[0]["game_type"] == "game" - - def test_skips_prologue_subset(self) -> None: - """A '- Prologue' entry should not beat the full game.""" - full: dict[str, Any] = { - "game_name": "A Space For The Unbound", - "comp_100": 45000, - } - prologue: dict[str, Any] = { - "game_name": "A Space for the Unbound - Prologue", - "comp_100": 1680, - } - result = _pick_best_hltb_entry( - "A Space for the Unbound", - [(prologue, 0.9), (full, 0.95)], - ) - assert result is not None - assert result[0]["game_name"] == "A Space For The Unbound" - - def test_skips_demo_subset(self) -> None: - """A ': Demo' entry should not beat the full game.""" - full: dict[str, Any] = {"game_name": "MyGame", "comp_100": 36000} - demo: dict[str, Any] = {"game_name": "MyGame: Demo", "comp_100": 1800} - result = _pick_best_hltb_entry("MyGame", [(demo, 0.9), (full, 1.0)]) - assert result is not None - assert result[0]["game_name"] == "MyGame" - - def test_still_prefers_full_edition_over_demo(self) -> None: - """A ': Full Edition' entry should still be preferred (not a subset).""" - short: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800} - full: dict[str, Any] = { - "game_name": "FAITH: The Unholy Trinity", - "comp_100": 7200, - } - result = _pick_best_hltb_entry("FAITH", [(short, 1.0), (full, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FAITH: The Unholy Trinity" - - def test_exact_match_beats_unrelated_subtitle(self) -> None: - """Exact name with more hours wins over an unrelated subtitle entry. - - 'Killing Floor: Toy Master' (1.2 h) must NOT beat 'Killing Floor' - (296 h) just because it starts with 'Killing Floor:'. - """ - base: dict[str, Any] = { - "game_name": "Killing Floor", - "comp_100": 1065600, # 296 h - } - spinoff: dict[str, Any] = { - "game_name": "Killing Floor: Toy Master", - "comp_100": 4320, # 1.2 h - } - result = _pick_best_hltb_entry("Killing Floor", [(spinoff, 0.7), (base, 1.0)]) - assert result is not None - assert result[0]["game_name"] == "Killing Floor" - - def test_alias_exact_match_beats_spinoff(self) -> None: - """Base game found via game_alias beats a spinoff with matching prefix. - - When HLTB renames a game (e.g. 'Needy Streamer Overload' -> - 'NEEDY GIRL OVERDOSE'), the old name lives in game_alias. A spinoff - like 'Needy Streamer Overload: Typing of The Net' must NOT be - preferred just because its game_name starts with the search term. - """ - base: dict[str, Any] = { - "game_name": "NEEDY GIRL OVERDOSE", - "game_alias": "Needy Streamer Overload", - "comp_100": 43200, # 12 h - } - spinoff: dict[str, Any] = { - "game_name": "Needy Streamer Overload: Typing of The Net", - "comp_100": 3600, # 1 h - } - result = _pick_best_hltb_entry( - "NEEDY STREAMER OVERLOAD", - [(spinoff, 0.7), (base, 0.9)], - ) - assert result is not None - assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE" - - def test_exact_match_beats_different_subtitled_game(self) -> None: - """Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h). - - Unlike FAITH where the short name is a demo, here the short name - is the real full game and the subtitled entry is a different, shorter - game. The exact match should win because it has more hours. - """ - base: dict[str, Any] = { - "game_name": "Timberman", - "comp_100": 95400, # 26.5 h - } - other: dict[str, Any] = { - "game_name": "Timberman: The Big Adventure", - "comp_100": 7200, # 2 h - } - timberman_vs: dict[str, Any] = { - "game_name": "Timberman VS", - "comp_100": 23400, # 6.5 h - } - result = _pick_best_hltb_entry( - "Timberman", - [(other, 0.49), (timberman_vs, 0.86), (base, 1.0)], - ) - assert result is not None - assert result[0]["game_name"] == "Timberman" - - def test_exact_match_wins_even_when_extended_appears_first(self) -> None: - """Exact match wins regardless of candidate ordering.""" - base: dict[str, Any] = { - "game_name": "Timberman", - "comp_100": 95400, # 26.5 h - } - other: dict[str, Any] = { - "game_name": "Timberman: The Big Adventure", - "comp_100": 7200, # 2 h - } - # Extended entry appears first in the list. - result = _pick_best_hltb_entry( - "Timberman", - [(other, 0.49), (base, 1.0)], - ) - assert result is not None - assert result[0]["game_name"] == "Timberman" - - def test_exact_only_no_extended(self) -> None: - """Exact match returned when no extended entries exist at all.""" - exact: dict[str, Any] = { - "game_name": "Celeste", - "comp_100": 180000, # 50 h - } - unrelated: dict[str, Any] = { - "game_name": "Unrelated Game", - "comp_100": 7200, - } - result = _pick_best_hltb_entry( - "Celeste", - [(exact, 1.0), (unrelated, 0.6)], - ) - assert result is not None - assert result[0]["game_name"] == "Celeste" - - def test_no_exact_no_extended_falls_back(self) -> None: - """When no exact or extended match exists, fall to highest similarity.""" - a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600} - b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200} - result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)]) - assert result is not None - assert result[0]["game_name"] == "FooBaz" - - def test_extended_only_no_exact(self) -> None: - """Extended entry returned when no exact name match exists.""" - extended: dict[str, Any] = { - "game_name": "Neon: Ultimate Edition", - "comp_100": 36000, - } - unrelated: dict[str, Any] = { - "game_name": "Neon Lights", - "comp_100": 3600, - } - result = _pick_best_hltb_entry( - "Neon", - [(extended, 0.6), (unrelated, 0.7)], - ) - assert result is not None - assert result[0]["game_name"] == "Neon: Ultimate Edition" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py deleted file mode 100644 index 8f3b22d..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_detail.py +++ /dev/null @@ -1,569 +0,0 @@ -"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3.""" - -from __future__ import annotations - -import asyncio -import json -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp -from typing_extensions import Self - -from python_pkg.steam_backlog_enforcer._hltb_detail import ( - _apply_dlc_leisure_overrides, - _as_positive_int, - _collect_dlc_relationships, - _extract_base_leisure_hours, - _extract_comp_100_avg_and_high, - _extract_dlc_relationships, - _fetch_detail_one, - _fetch_dlc_leisure_hours, - _fetch_leisure_times, - _process_game_detail, -) -from python_pkg.steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, - HLTBResult, - _HLTBExtras, -) - - -class TestInternalHelpers: - """Tests for internal helper coverage.""" - - def test_as_positive_int_float(self) -> None: - assert _as_positive_int(1.9) == 1 - - def test_as_positive_int_invalid_type(self) -> None: - assert not _as_positive_int(object()) - - def test_extract_base_leisure_non_dict_game(self) -> None: - data: dict[str, Any] = {"game": [123]} - assert _extract_base_leisure_hours(data) == -1 - - def test_extract_base_leisure_platform_data_comp_high_is_max(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 16063}], - "platformData": [{"platform": "PC", "comp_high": 23760}], - } - assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) - - def test_extract_base_leisure_h_field_exceeds_platform_comp_high(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 25000}], - "platformData": [{"platform": "PC", "comp_high": 23760}], - } - assert _extract_base_leisure_hours(data) == round(25000 / 3600, 2) - - def test_extract_base_leisure_max_of_multiple_platforms(self) -> None: - data: dict[str, Any] = { - "game": [{}], - "platformData": [ - {"platform": "PC", "comp_high": 23760}, - {"platform": "Switch", "comp_high": 18000}, - ], - } - assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) - - def test_extract_base_leisure_platform_data_not_list(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 16063}], - "platformData": "not_a_list", - } - assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2) - - def test_extract_base_leisure_platform_non_dict_entry_skipped(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 16063}], - "platformData": ["bad", {"platform": "PC", "comp_high": 23760}], - } - assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2) - - def test_extract_base_leisure_platform_comp_high_zero_skipped(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 16063}], - "platformData": [{"platform": "PC", "comp_high": 0}], - } - assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2) - - def test_extract_base_leisure_max_of_h_fields(self) -> None: - data: dict[str, Any] = { - "game": [ - { - "comp_main_h": 14951, - "comp_plus_h": 17957, - "comp_100_h": 16063, - "comp_all_h": 17959, - } - ], - } - assert _extract_base_leisure_hours(data) == round(17959 / 3600, 2) - - def test_extract_base_leisure_fallback_to_avg_comp_main(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_main": 10800, "comp_plus": 0, "comp_100": 0}], - } - assert _extract_base_leisure_hours(data) == round(10800 / 3600, 2) - - def test_extract_dlc_relationships_skips_non_dict(self) -> None: - data: dict[str, Any] = { - "relationships": [ - "bad", - {"game_type": "dlc", "game_id": 7, "comp_100": 3600}, - ], - } - assert _extract_dlc_relationships(data) == [(7, 1.0)] - - def test_collect_dlc_relationships_ignores_non_positive_id(self) -> None: - valid = [ - HLTBResult( - app_id=1, - game_name="Game", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=123, - ) - ] - details: list[dict[str, Any] | None] = [ - { - "relationships": [ - {"game_type": "dlc", "game_id": 0, "comp_100": 3600}, - ] - } - ] - by_app, ids = _collect_dlc_relationships(valid, details) - assert by_app[1] == [(0, 1.0)] - assert ids == [] - - def test_apply_dlc_leisure_overrides(self) -> None: - adjusted = _apply_dlc_leisure_overrides( - base_hours=6.0, - dlc_rels=[(10, 1.0), (11, 2.0)], - dlc_hours_by_id={10: 3.0}, - ) - assert adjusted == 8.0 - - def test_fetch_dlc_leisure_hours_empty(self) -> None: - async def _run() -> dict[int, float]: - async with aiohttp.ClientSession() as session: - return await _fetch_dlc_leisure_hours(asyncio.Semaphore(1), session, []) - - assert asyncio.run(_run()) == {} - - def test_fetch_dlc_leisure_hours_skips_none_data(self) -> None: - async def _run() -> dict[int, float]: - async with aiohttp.ClientSession() as session: - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=None, - ): - return await _fetch_dlc_leisure_hours( - asyncio.Semaphore(1), - session, - [1], - ) - - assert asyncio.run(_run()) == {} - - def test_fetch_dlc_leisure_hours_skips_non_positive_leisure(self) -> None: - bad_dlc_data: dict[str, Any] = { - "game": [{"comp_100_h": 0, "comp_100": 0}], - "relationships": [], - } - - async def _run() -> dict[int, float]: - async with aiohttp.ClientSession() as session: - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=bad_dlc_data, - ): - return await _fetch_dlc_leisure_hours( - asyncio.Semaphore(1), - session, - [1], - ) - - assert asyncio.run(_run()) == {} - - -class TestExtractComp100AvgAndHigh: - """Tests for _extract_comp_100_avg_and_high.""" - - def test_returns_minus_one_for_empty_game_list(self) -> None: - assert _extract_comp_100_avg_and_high({"game": []}) == (-1, -1) - - def test_returns_minus_one_for_non_list_game(self) -> None: - assert _extract_comp_100_avg_and_high({"game": "bad"}) == (-1, -1) - - def test_returns_minus_one_when_game0_not_dict(self) -> None: - assert _extract_comp_100_avg_and_high({"game": [42]}) == (-1, -1) - - def test_returns_avg_and_high(self) -> None: - data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 10800}]} - avg_h, high_h = _extract_comp_100_avg_and_high(data) - assert avg_h == round(7200 / 3600, 2) - assert high_h == round(10800 / 3600, 2) - - def test_high_falls_back_to_avg_when_zero(self) -> None: - data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 0}]} - avg_h, high_h = _extract_comp_100_avg_and_high(data) - assert avg_h == round(7200 / 3600, 2) - assert high_h == avg_h - - def test_avg_zero_returns_minus_one_avg(self) -> None: - data: dict[str, Any] = {"game": [{"comp_100": 0, "comp_100_h": 0}]} - avg_h, high_h = _extract_comp_100_avg_and_high(data) - assert avg_h == -1 - assert high_h == -1 - - -class TestProcessGameDetail: - """Tests for _process_game_detail.""" - - def test_returns_leisure_rush_and_l100(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 10800, "comp_100": 7200}], - "relationships": [], - } - leisure, rush_h, l100 = _process_game_detail(data, [], {}) - assert leisure == round(10800 / 3600, 2) - assert rush_h == round(7200 / 3600, 2) - assert l100 == round(10800 / 3600, 2) - - def test_negative_leisure_when_no_data(self) -> None: - leisure, rush_h, l100 = _process_game_detail({"game": []}, [], {}) - assert leisure == -1 - assert rush_h == -1.0 - assert l100 == -1.0 - - def test_rush_includes_dlc_fallback(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100": 7200, "comp_100_h": 0}], - "relationships": [], - } - dlc_rels = [(99, 1.5)] - _leisure, rush_h, _l100 = _process_game_detail(data, dlc_rels, {}) - assert rush_h == round(7200 / 3600 + 1.5, 2) - - def test_l100_uses_dlc_override(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 10800, "comp_100": 7200}], - "relationships": [], - } - dlc_rels = [(77, 2.0)] - dlc_hours_by_id = {77: 3.0} - _leisure, _rush_h, l100 = _process_game_detail(data, dlc_rels, dlc_hours_by_id) - assert l100 == round(10800 / 3600 + (3.0 - 2.0), 2) - - -class _FakeTextResponse: - """Async context manager mimicking aiohttp response for text.""" - - def __init__(self, status: int, text: str = "") -> None: - self.status = status - self._text = text - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: object) -> None: - pass - - async def text(self) -> str: - return self._text - - -class TestFetchDetailOne: - """Tests for _fetch_detail_one.""" - - def test_success(self) -> None: - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243}], - "relationships": [], - } - next_data = {"props": {"pageProps": {"game": {"data": game_data}}}} - html = ( - '" - ) - resp = _FakeTextResponse(200, html) - session = MagicMock() - session.get = MagicMock(return_value=resp) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result == game_data - - def test_non_200(self) -> None: - resp = _FakeTextResponse(404) - session = MagicMock() - session.get = MagicMock(return_value=resp) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result is None - - def test_client_error(self) -> None: - ctx = AsyncMock() - ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError) - ctx.__aexit__ = AsyncMock(return_value=False) - session = MagicMock() - session.get = MagicMock(return_value=ctx) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result is None - - def test_parse_failure(self) -> None: - resp = _FakeTextResponse(200, "no script") - session = MagicMock() - session.get = MagicMock(return_value=resp) - result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345)) - assert result is None - - -class TestFetchLeisureTimes: - """Tests for _fetch_leisure_times.""" - - def test_updates_cache(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243}], - "relationships": [], - } - cache: dict[int, float] = {} - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert cache[440] == round(21243 / 3600, 2) - assert results[0].completionist_hours == round(21243 / 3600, 2) - - def test_no_valid_results(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=0, - ), - ] - cache: dict[int, float] = {} - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert not cache - - def test_empty_results(self) -> None: - cache: dict[int, float] = {} - asyncio.run(_fetch_leisure_times([], cache, {}, None)) - assert not cache - - def test_detail_returns_none(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - cache: dict[int, float] = {} - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=None, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert not cache - assert results[0].completionist_hours == 50.0 - - def test_negative_leisure(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = {"game": [], "relationships": []} - cache: dict[int, float] = {} - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - assert not cache - assert results[0].completionist_hours == 50.0 - - def test_with_progress_cb(self) -> None: - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [], - } - cache: dict[int, float] = {} - cb = MagicMock() - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, cb)) - cb.assert_called_once() - - def test_save_interval(self) -> None: - """Trigger the _SAVE_INTERVAL branch in leisure fetching.""" - results = [ - HLTBResult( - app_id=i, - game_name=f"Game{i}", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=i + 1000, - ) - for i in range(_SAVE_INTERVAL) - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [], - } - cache: dict[int, float] = {} - with ( - patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_detail.save_hltb_cache" - ) as mock_save, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - mock_save.assert_called_once() - - def test_dlc_detail_overrides_relationship_fallback(self) -> None: - results = [ - HLTBResult( - app_id=1289310, - game_name="Helltaker", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=78118, - ), - ] - base_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6846}], - "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], - } - dlc_data: dict[str, Any] = { - "game": [{"comp_100_h": 12298, "comp_100": 4075}], - "relationships": [], - } - cache: dict[int, float] = {} - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - side_effect=[base_data, dlc_data], - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - - expected = round((21243 + 12298) / 3600, 2) - assert cache[1289310] == expected - assert results[0].completionist_hours == expected - - def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None: - results = [ - HLTBResult( - app_id=1289310, - game_name="Helltaker", - completionist_hours=1.0, - similarity=1.0, - hltb_game_id=78118, - ), - ] - base_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6846}], - "relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}], - } - cache: dict[int, float] = {} - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - side_effect=[base_data, None], - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None)) - - expected = round((21243 + 4075) / 3600, 2) - assert cache[1289310] == expected - assert results[0].completionist_hours == expected - - def test_extras_populated_with_rush_and_l100(self) -> None: - """rush_h and l100 are stored in extras when game has comp_100 data.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 10800, "comp_100": 7200}], - "relationships": [], - } - cache: dict[int, float] = {} - extras = _HLTBExtras(count_comp={440: 5}) - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) - assert extras.rush[440] == round(7200 / 3600, 2) - assert extras.leisure_100h[440] == round(10800 / 3600, 2) - - def test_with_explicit_extras(self) -> None: - """Pass a pre-populated _HLTBExtras to cover the non-None extras branch.""" - results = [ - HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ] - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [], - } - cache: dict[int, float] = {} - extras = _HLTBExtras(count_comp={440: 5}) - with patch( - "python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one", - new_callable=AsyncMock, - return_value=game_data, - ): - asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras)) - assert cache[440] == 1.0 diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py deleted file mode 100644 index 683ef25..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_part2.py +++ /dev/null @@ -1,366 +0,0 @@ -"""Tests for hltb module — part 2 (missing coverage).""" - -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from typing_extensions import Self - -from python_pkg.steam_backlog_enforcer._hltb_search import _AuthInfo -from python_pkg.steam_backlog_enforcer.hltb import ( - HLTB_BASE_URL, - HLTBResult, - _fetch_batch_confidence_only, - fetch_hltb_confidence, - fetch_hltb_confidence_cached, - fetch_hltb_detail_missing, - fetch_hltb_times_cached, - get_hltb_submit_url, -) - -if TYPE_CHECKING: - from python_pkg.steam_backlog_enforcer._hltb_types import _HLTBExtras - -PKG = "python_pkg.steam_backlog_enforcer.hltb" - - -class TestFetchHltbTimesCached: - """Tests for fetch_hltb_times_cached.""" - - def test_all_cached(self) -> None: - with ( - patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}), - ): - result = fetch_hltb_times_cached([(440, "TF2")]) - assert result == {440: 50.0} - - def test_uncached_games_fetched(self) -> None: - with ( - patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}), - patch(f"{PKG}.fetch_hltb_times") as mock_fetch, - patch(f"{PKG}.save_hltb_cache") as mock_save, - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]), - ): - # fetch_hltb_times modifies cache in-place - def add_to_cache( - _games: object, - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, - extras: _HLTBExtras | None = None, - ) -> list[object]: - if cache is not None: - cache[730] = 20.0 - if polls is not None: - polls[730] = 0 - if extras is not None: - extras.count_comp[730] = 0 - return [] - - mock_fetch.side_effect = add_to_cache - result = fetch_hltb_times_cached( - [(440, "TF2"), (730, "CS")], - ) - assert result[440] == 50.0 - assert result[730] == 20.0 - mock_save.assert_called_once() - - def test_uncached_with_progress_cb(self) -> None: - cb = MagicMock() - with ( - patch(f"{PKG}.load_hltb_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times") as mock_fetch, - patch(f"{PKG}.save_hltb_cache"), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), - ): - mock_fetch.return_value = [] - result = fetch_hltb_times_cached( - [(440, "TF2")], - progress_cb=cb, - ) - assert 440 not in result or result.get(440) == -1 - - def test_uncached_zero_elapsed(self) -> None: - """Covers the elapsed == 0 branch for rate calculation.""" - with ( - patch(f"{PKG}.load_hltb_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times") as mock_fetch, - patch(f"{PKG}.save_hltb_cache"), - patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]), - ): - mock_fetch.return_value = [] - fetch_hltb_times_cached([(440, "TF2")]) - - def test_found_count(self) -> None: - """Covers the found count in logging.""" - with ( - patch(f"{PKG}.load_hltb_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times") as mock_fetch, - patch(f"{PKG}.save_hltb_cache"), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 3.0]), - ): - - def add_found( - _games: object, - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, - extras: _HLTBExtras | None = None, - ) -> list[object]: - if cache is not None: - cache[440] = 50.0 - cache[730] = -1 - if polls is not None: - polls[440] = 5 - polls[730] = 0 - if extras is not None: - extras.count_comp[440] = 15 - extras.count_comp[730] = 0 - return [] - - mock_fetch.side_effect = add_found - result = fetch_hltb_times_cached( - [(440, "TF2"), (730, "CS")], - ) - assert result[440] == 50.0 - assert result[730] == -1 - - -class TestGetHltbSubmitUrl: - """Tests for get_hltb_submit_url.""" - - def test_found(self) -> None: - mock_result = HLTBResult( - app_id=0, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ) - with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): - url = get_hltb_submit_url("TF2") - assert url == f"{HLTB_BASE_URL}/submit/game/12345" - - def test_not_found_empty(self) -> None: - with patch(f"{PKG}.fetch_hltb_times", return_value=[]): - url = get_hltb_submit_url("Unknown Game") - assert url is None - - def test_not_found_no_id(self) -> None: - mock_result = HLTBResult( - app_id=0, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=0, - ) - with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]): - url = get_hltb_submit_url("TF2") - assert url is None - - -class _DummySession: - """Minimal async context manager used to mock aiohttp ClientSession.""" - - async def __aenter__(self) -> Self: - """Enter async context.""" - return self - - async def __aexit__(self, *_args: object) -> bool: - """Exit async context.""" - return False - - -class TestConfidenceHelpers: - """Coverage tests for confidence-fetch helpers.""" - - def test_fetch_batch_confidence_only_returns_empty_without_auth(self) -> None: - with ( - patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), - patch(f"{PKG}.aiohttp.TCPConnector"), - patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), - patch(f"{PKG}._get_auth_info", return_value=None), - ): - result = asyncio.run( - _fetch_batch_confidence_only([(1, "Game")], {}, {}, None), - ) - assert result == [] - - def test_fetch_batch_confidence_only_handles_empty_hp_and_default_counts( - self, - ) -> None: - auth_token = str(1) - with ( - patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()), - patch(f"{PKG}.aiohttp.TCPConnector"), - patch(f"{PKG}._get_hltb_search_url", return_value="https://example"), - patch( - f"{PKG}._get_auth_info", - return_value=_AuthInfo(token=auth_token, hp_key="", hp_val=""), - ), - patch(f"{PKG}._search_one", side_effect=[None]) as mock_search, - ): - result = asyncio.run( - _fetch_batch_confidence_only( - games=[(1, "Game")], - cache={}, - polls={}, - progress_cb=None, - count_comp=None, - ), - ) - assert result == [] - mock_search.assert_called_once() - - def test_fetch_hltb_confidence_initializes_optional_dicts(self) -> None: - with patch(f"{PKG}.asyncio.run", return_value=[]) as mock_run: - result = fetch_hltb_confidence([(1, "Game")]) - assert result == [] - mock_run.assert_called_once() - - def test_fetch_hltb_confidence_empty_games_returns_empty(self) -> None: - with patch(f"{PKG}.asyncio.run") as mock_run: - result = fetch_hltb_confidence([]) - assert result == [] - mock_run.assert_not_called() - - def test_fetch_hltb_confidence_cached_all_cached_skips_fetch(self) -> None: - with ( - patch(f"{PKG}.load_hltb_cache", return_value={1: 12.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={1: 30}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={1: 200}), - patch(f"{PKG}.fetch_hltb_confidence") as mock_fetch, - patch(f"{PKG}.save_hltb_cache") as mock_save, - ): - result = fetch_hltb_confidence_cached([(1, "Game")]) - assert result == {1: 12.0} - mock_fetch.assert_not_called() - mock_save.assert_not_called() - - -class TestFetchHltbDetailMissing: - """Tests for fetch_hltb_detail_missing.""" - - def test_no_missing_returns_zero(self) -> None: - """All games in rush cache → early return without fetching.""" - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), - patch(f"{PKG}.fetch_hltb_times") as mock_fetch, - ): - result = fetch_hltb_detail_missing([(440, "TF2")]) - assert result == 0 - mock_fetch.assert_not_called() - - def test_fetches_missing_and_returns_count(self) -> None: - """Games not in rush cache are fetched; returns count with rush data.""" - - def add_rush( - _games: object, - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, - extras: _HLTBExtras | None = None, - ) -> list[object]: - if extras is not None: - extras.rush[730] = 10.0 - if cache is not None: - cache[730] = 25.0 - return [] - - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}), - patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times", side_effect=add_rush), - patch(f"{PKG}.save_hltb_cache") as mock_save, - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]), - ): - result = fetch_hltb_detail_missing([(440, "TF2"), (730, "CS")]) - assert result == 1 - mock_save.assert_called_once() - - def test_restores_prior_hours_when_not_refound(self) -> None: - """Hours are restored when re-fetch finds nothing for the game.""" - saved: dict[int, float] = {} - - def capture_save( - cache: dict[int, float], - _polls: object, - _extras: object = None, - ) -> None: - saved.update(cache) - - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times"), # no-op, cache stays empty - patch(f"{PKG}.save_hltb_cache", side_effect=capture_save), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), - ): - fetch_hltb_detail_missing([(730, "CS")]) - assert saved[730] == 20.0 - - def test_does_not_restore_when_refound(self) -> None: - """Prior hours are NOT restored when re-fetch successfully finds game.""" - - def add_hours_and_rush( - _games: object, - cache: dict[int, float] | None = None, - polls: dict[int, int] | None = None, - progress_cb: object = None, - extras: _HLTBExtras | None = None, - ) -> list[object]: - if cache is not None: - cache[730] = 30.0 - if extras is not None: - extras.rush[730] = 12.0 - return [] - - saved: dict[int, float] = {} - - def capture_save( - cache: dict[int, float], - _polls: object, - _extras: object = None, - ) -> None: - saved.update(cache) - - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times", side_effect=add_hours_and_rush), - patch(f"{PKG}.save_hltb_cache", side_effect=capture_save), - patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]), - ): - result = fetch_hltb_detail_missing([(730, "CS")]) - assert result == 1 - assert saved[730] == 30.0 - - def test_zero_elapsed_rate(self) -> None: - """Covers the elapsed == 0 branch in the rate calculation.""" - with ( - patch(f"{PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{PKG}.load_hltb_cache", return_value={}), - patch(f"{PKG}.load_hltb_polls_cache", return_value={}), - patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}), - patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{PKG}.fetch_hltb_times"), - patch(f"{PKG}.save_hltb_cache"), - patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]), - ): - result = fetch_hltb_detail_missing([(730, "CS")]) - assert result == 0 diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_part3.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_part3.py deleted file mode 100644 index e17d2c2..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_part3.py +++ /dev/null @@ -1,45 +0,0 @@ -"""Tests for hltb module - part 3 (fetch_hltb_times).""" - -from __future__ import annotations - -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer.hltb import ( - HLTBResult, - fetch_hltb_times, -) - - -class TestFetchHltbTimes: - """Tests for fetch_hltb_times.""" - - def test_empty(self) -> None: - assert fetch_hltb_times([]) == [] - - def test_calls_batch(self) -> None: - mock_result = HLTBResult( - app_id=440, game_name="TF2", completionist_hours=50.0, similarity=1.0 - ) - with patch( - "python_pkg.steam_backlog_enforcer.hltb._fetch_batch", - return_value=[mock_result], - ): - results = fetch_hltb_times([(440, "TF2")]) - assert len(results) == 1 - - def test_none_cache(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.hltb._fetch_batch", - return_value=[], - ): - results = fetch_hltb_times([(440, "TF2")]) - assert results == [] - - def test_explicit_cache(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.hltb._fetch_batch", - return_value=[], - ): - cache: dict[int, float] = {440: 10.0} - results = fetch_hltb_times([(440, "TF2")], cache=cache) - assert results == [] diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py deleted file mode 100644 index 15c9872..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_search.py +++ /dev/null @@ -1,289 +0,0 @@ -"""Tests for HLTB search, batch-fetch, and page parsing — part 2.""" - -from __future__ import annotations - -import asyncio -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp -from typing_extensions import Self - -from python_pkg.steam_backlog_enforcer._hltb_search import ( - _fetch_batch, - _search_one, - _SearchCtx, -) -from python_pkg.steam_backlog_enforcer._hltb_types import ( - _SAVE_INTERVAL, -) - -if TYPE_CHECKING: - from collections.abc import Callable - - -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: object) -> None: - pass - - async def json(self) -> dict[str, Any]: - return self._json_data - - -def _make_session(resp: _FakeResponse) -> MagicMock: - session = MagicMock() - session.post.return_value = resp - return session - - -def _make_ctx( - session: MagicMock, - *, - cache: dict[int, float] | None = None, - progress_cb: Callable[..., object] | None = None, -) -> _SearchCtx: - return _SearchCtx( - session=session, - search_url="https://example.com/search", - headers={}, - cache=cache if cache is not None else {}, - counter={"done": 0, "found": 0}, - total=1, - progress_cb=progress_cb, - ) - - -class TestSearchOne: - """Tests for _search_one.""" - - def test_found(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 180000, - "game_id": 12345, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - assert result.app_id == 440 - - def test_found_without_game_id(self) -> None: - """Found result with hltb_game_id=0 does not populate ctx.hltb_game_id.""" - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 180000, - "game_id": 0, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - assert 440 not in ctx.hltb_game_id - - def test_not_found(self) -> None: - resp = _FakeResponse(200, {"data": []}) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is None - assert ctx.cache[440] == -1 - - def test_error(self) -> None: - session = MagicMock() - session.post.side_effect = aiohttp.ClientError("fail") - ctx = _make_ctx(session) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is None - - def test_non_200(self) -> None: - resp = _FakeResponse(500) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is None - - def test_fallback_name_without_year_suffix(self) -> None: - session = MagicMock() - session.post.side_effect = [ - _FakeResponse(200, {"data": []}), - _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Final Fantasy VII", - "game_alias": "", - "game_type": "game", - "comp_100": 141120, - "game_id": 435, - "comp_100_count": 746, - "count_comp": 10450, - } - ] - }, - ), - ] - ctx = _make_ctx(session) - result = asyncio.run( - _search_one(asyncio.Semaphore(1), ctx, 39140, "Final Fantasy VII (2013)") - ) - assert result is not None - assert result.app_id == 39140 - assert result.comp_100_count == 746 - assert result.count_comp == 10450 - assert session.post.call_count == 2 - - def test_with_progress_cb(self) -> None: - resp = _FakeResponse(200, {"data": []}) - cb = MagicMock() - ctx = _make_ctx(_make_session(resp), progress_cb=cb) - asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - cb.assert_called_once() - - def test_low_similarity_skipped(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Completely Different Name", - "game_alias": "", - "comp_100": 3600, - "game_id": 1, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is None - - def test_zero_comp_100_skipped(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2", - "game_alias": "", - "comp_100": 0, - "game_id": 1, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is None - - def test_alias_match(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "Team Fortress 2", - "game_alias": "TF2", - "comp_100": 180000, - "game_id": 12345, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - - def test_full_edition_colon(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2: Complete", - "game_alias": "", - "comp_100": 180000, - "game_id": 99, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - - def test_full_edition_dash(self) -> None: - resp = _FakeResponse( - 200, - { - "data": [ - { - "game_name": "TF2 - Complete", - "game_alias": "", - "comp_100": 180000, - "game_id": 99, - } - ], - }, - ) - ctx = _make_ctx(_make_session(resp)) - result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - assert result is not None - - def test_save_interval(self) -> None: - """Trigger the _SAVE_INTERVAL branch.""" - resp = _FakeResponse(200, {"data": []}) - ctx = _make_ctx(_make_session(resp)) - # Set done to one less than _SAVE_INTERVAL so it triggers save - - ctx.counter["done"] = _SAVE_INTERVAL - 1 - with patch( - "python_pkg.steam_backlog_enforcer._hltb_search.save_hltb_cache" - ) as mock_save: - asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2")) - mock_save.assert_called_once() - - -class TestFetchBatchHltb: - """Tests for _fetch_batch (the hltb version).""" - - def test_no_auth(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=None, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] - - -class TestPickBestEntry: - """Tests for exact-vs-extended entry choice logic.""" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_hltb_search_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_hltb_search_part2.py deleted file mode 100644 index 15e0d0e..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_hltb_search_part2.py +++ /dev/null @@ -1,325 +0,0 @@ -"""Tests for HLTB search entry picking, page parsing, and leisure extraction.""" - -from __future__ import annotations - -import asyncio -import json -from typing import Any -from unittest.mock import AsyncMock, MagicMock, patch - -from typing_extensions import Self - -from python_pkg.steam_backlog_enforcer._hltb_detail import ( - _extract_leisure_hours, - _parse_game_page, -) -from python_pkg.steam_backlog_enforcer._hltb_search import ( - _build_search_variants, - _fetch_batch, - _pick_best_hltb_entry, -) -from python_pkg.steam_backlog_enforcer._hltb_types import ( - HLTBResult, - _AuthInfo, -) - - -class _FakeResponse: - """Async context manager mimicking aiohttp response.""" - - def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None: - self.status = status - self._json_data = json_data or {} - - async def __aenter__(self) -> Self: - return self - - async def __aexit__(self, *args: object) -> None: - pass - - async def json(self) -> dict[str, Any]: - return self._json_data - - -def _make_session(resp: _FakeResponse) -> MagicMock: - session = MagicMock() - session.post.return_value = resp - return session - - -class TestPickBestEntry: - """Tests for exact-vs-extended entry choice logic.""" - - def test_prefers_exact_over_low_confidence_modded_extended(self) -> None: - exact = ( - { - "game_name": "Celeste", - "game_alias": "", - "game_type": "game", - "comp_100": 141105, - "comp_100_count": 899, - "count_comp": 14055, - }, - 1.0, - ) - mod_extended = ( - { - "game_name": "Celeste - Strawberry Jam", - "game_alias": "", - "game_type": "mod", - "comp_100": 952080, - "comp_100_count": 1, - "count_comp": 6, - }, - 0.9, - ) - - best = _pick_best_hltb_entry("Celeste", [exact, mod_extended]) - assert best is not None - assert best[0]["game_name"] == "Celeste" - - def test_prefers_extended_when_confident_and_longer(self) -> None: - exact_demo = ( - { - "game_name": "FAITH", - "game_alias": "", - "game_type": "game", - "comp_100": 1800, - "comp_100_count": 1, - "count_comp": 1, - }, - 1.0, - ) - full_extended = ( - { - "game_name": "FAITH: The Unholy Trinity", - "game_alias": "", - "game_type": "game", - "comp_100": 25200, - "comp_100_count": 50, - "count_comp": 500, - }, - 0.9, - ) - - best = _pick_best_hltb_entry("FAITH", [exact_demo, full_extended]) - assert best is not None - assert best[0]["game_name"] == "FAITH: The Unholy Trinity" - - def test_with_auth(self) -> None: - auth = _AuthInfo("token123", "ign_x", "ff") - with ( - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=auth, - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._search_one", - new_callable=AsyncMock, - return_value=HLTBResult( - app_id=440, - game_name="TF2", - completionist_hours=50.0, - similarity=1.0, - hltb_game_id=12345, - ), - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._fetch_leisure_times", - new_callable=AsyncMock, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert len(results) == 1 - - def test_with_auth_no_hp(self) -> None: - auth = _AuthInfo("tok123") - with ( - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=auth, - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._search_one", - new_callable=AsyncMock, - return_value=None, - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._fetch_leisure_times", - new_callable=AsyncMock, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] - - def test_filters_none_results(self) -> None: - auth = _AuthInfo("tok123") - with ( - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url", - return_value="https://example.com", - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info", - new_callable=AsyncMock, - return_value=auth, - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._search_one", - new_callable=AsyncMock, - return_value=None, - ), - patch( - "python_pkg.steam_backlog_enforcer._hltb_search._fetch_leisure_times", - new_callable=AsyncMock, - ), - ): - results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None)) - assert results == [] - - -class TestParseGamePage: - """Tests for _parse_game_page.""" - - def test_valid_html(self) -> None: - game_data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6800}], - "relationships": [], - } - next_data = { - "props": {"pageProps": {"game": {"data": game_data}}}, - } - html = ( - '" - ) - assert _parse_game_page(html) == game_data - - def test_no_script_tag(self) -> None: - assert _parse_game_page("") is None - - def test_bad_json(self) -> None: - html = '' - assert _parse_game_page(html) is None - - def test_missing_keys(self) -> None: - html = ( - '' - ) - assert _parse_game_page(html) is None - - -class TestExtractLeisureHours: - """Tests for _extract_leisure_hours.""" - - def test_leisure_time_only(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6800}], - "relationships": [], - } - assert _extract_leisure_hours(data) == round(21243 / 3600, 2) - - def test_leisure_with_dlc(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 21243, "comp_100": 6800}], - "relationships": [ - {"game_type": "dlc", "comp_100": 12298}, - {"game_type": "dlc", "comp_100": 3600}, - ], - } - assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2) - - def test_fallback_to_comp_100(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100": 7200}], - "relationships": [], - } - assert _extract_leisure_hours(data) == round(7200 / 3600, 2) - - def test_no_game_data(self) -> None: - assert _extract_leisure_hours({"game": [], "relationships": []}) == -1 - - def test_zero_leisure(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 0, "comp_100": 0}], - "relationships": [], - } - assert _extract_leisure_hours(data) == -1 - - def test_no_game_key(self) -> None: - assert _extract_leisure_hours({"relationships": []}) == -1 - - def test_non_dlc_relationship_ignored(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [ - {"game_type": "game", "comp_100": 9999}, - {"game_type": "dlc", "comp_100": 1800}, - ], - } - assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2) - - def test_dlc_zero_comp_100_skipped(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": [ - {"game_type": "dlc", "comp_100": 0}, - ], - } - assert _extract_leisure_hours(data) == round(3600 / 3600, 2) - - def test_negative_leisure(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": -1, "comp_100": -1}], - "relationships": [], - } - assert _extract_leisure_hours(data) == -1 - - def test_string_numeric_fields(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": "7200", "comp_100": "3600"}], - "relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}], - } - assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2) - - def test_bad_string_falls_back_to_comp_100(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": "bad", "comp_100": "3600"}], - "relationships": [], - } - assert _extract_leisure_hours(data) == 1.0 - - def test_relationships_not_list(self) -> None: - data: dict[str, Any] = { - "game": [{"comp_100_h": 3600}], - "relationships": "not-a-list", - } - assert _extract_leisure_hours(data) == 1.0 - - -class TestBuildSearchVariants: - """Tests for _build_search_variants.""" - - def test_subtitle_with_edition_strips_edition_from_subtitle_part(self) -> None: - # "Rocksmith 2014 Edition - Remastered" → no_subtitle = "Rocksmith 2014 Edition" - # (which != base), so lines 201-202 also add "Rocksmith" and "Rocksmith 2014" - variants = _build_search_variants("Rocksmith 2014 Edition - Remastered") - assert "Rocksmith 2014 Edition" in variants - assert "Rocksmith 2014" in variants - assert "Rocksmith" in variants - - def test_no_subtitle_skips_edition_strip(self) -> None: - # No " - " → no_subtitle == base → lines 201-202 are not executed - variants = _build_search_variants("Portal 2") - assert "Portal 2" in variants diff --git a/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py b/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py deleted file mode 100644 index 1d9f074..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_library_hider.py +++ /dev/null @@ -1,425 +0,0 @@ -"""Tests for library_hider module.""" - -from __future__ import annotations - -import asyncio -import json -from unittest.mock import AsyncMock, MagicMock, patch - -import pytest - -from python_pkg.steam_backlog_enforcer.library_hider import ( - _cdp_result_value, - _evaluate_js, - _evaluate_js_async, - _get_shared_js_ws_url, - _is_steam_running, - _launch_steam_with_debug, - _shutdown_steam, - _steam_has_debug_port, - _wait_for_cdp_ready, - _wait_for_collections_ready, - ensure_steam_debug_port, -) - - -class TestGetSharedJsWsUrl: - """Tests for _get_shared_js_ws_url.""" - - def test_finds_url(self) -> None: - targets = [ - { - "title": "SharedJSContext", - "webSocketDebuggerUrl": "ws://127.0.0.1:8080/x", - }, - {"title": "Other", "webSocketDebuggerUrl": "ws://other"}, - ] - mock_resp = MagicMock() - mock_resp.json.return_value = targets - with patch( - "python_pkg.steam_backlog_enforcer.library_hider.requests.get", - return_value=mock_resp, - ): - result = _get_shared_js_ws_url() - assert result == "ws://127.0.0.1:8080/x" - - def test_no_shared_context(self) -> None: - targets = [{"title": "Other", "webSocketDebuggerUrl": "ws://other"}] - mock_resp = MagicMock() - mock_resp.json.return_value = targets - with patch( - "python_pkg.steam_backlog_enforcer.library_hider.requests.get", - return_value=mock_resp, - ): - assert _get_shared_js_ws_url() is None - - def test_connection_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider.requests.get", - side_effect=OSError, - ): - assert _get_shared_js_ws_url() is None - - -class TestEvaluateJsAsync: - """Tests for _evaluate_js_async.""" - - def test_success(self) -> None: - mock_ws = AsyncMock() - mock_ws.send = AsyncMock() - mock_ws.recv = AsyncMock( - return_value=json.dumps({"result": {"result": {"value": "ok"}}}) - ) - mock_ws.__aenter__ = AsyncMock(return_value=mock_ws) - mock_ws.__aexit__ = AsyncMock(return_value=False) - - with patch( - "python_pkg.steam_backlog_enforcer.library_hider.websockets.connect", - return_value=mock_ws, - ): - result = asyncio.run(_evaluate_js_async("ws://test", "1+1")) - assert result["result"]["result"]["value"] == "ok" - - -class TestEvaluateJs: - """Tests for _evaluate_js.""" - - def test_success(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value="ws://test", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.asyncio.run", - return_value={"result": {"result": {"value": "ok"}}}, - ), - ): - result = _evaluate_js("1+1") - assert result["result"]["result"]["value"] == "ok" - - def test_no_ws_url(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value=None, - ), - pytest.raises(RuntimeError, match="SharedJSContext not found"), - ): - _evaluate_js("1+1") - - -class TestCdpResultValue: - """Tests for _cdp_result_value.""" - - def test_extracts_value(self) -> None: - result = {"result": {"result": {"value": "hello"}}} - assert _cdp_result_value(result) == "hello" - - def test_exception(self) -> None: - result = { - "result": { - "result": {"description": "Error!"}, - "exceptionDetails": {}, - } - } - with pytest.raises(RuntimeError, match="JS evaluation error"): - _cdp_result_value(result) - - def test_empty(self) -> None: - assert _cdp_result_value({}) == "" - - -class TestIsSteamRunning: - """Tests for _is_steam_running.""" - - def test_running(self) -> None: - mock_result = MagicMock(returncode=0) - with patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ): - assert _is_steam_running() is True - - def test_not_running(self) -> None: - mock_result = MagicMock(returncode=1) - with patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ): - assert _is_steam_running() is False - - -class TestSteamHasDebugPort: - """Tests for _steam_has_debug_port.""" - - def test_has_port(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value="ws://test", - ): - assert _steam_has_debug_port() is True - - def test_no_port(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value=None, - ): - assert _steam_has_debug_port() is False - - -class TestWaitForCdpReady: - """Tests for _wait_for_cdp_ready.""" - - def test_ready_immediately(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value="ws://test", - ): - assert _wait_for_cdp_ready() is True - - def test_timeout(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url", - return_value=None, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", - 2, - ), - ): - assert _wait_for_cdp_ready() is False - - -class TestWaitForCollectionsReady: - """Tests for _wait_for_collections_ready.""" - - def test_ready(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={"result": {"result": {"value": "ok"}}}, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - return_value="ok", - ), - ): - assert _wait_for_collections_ready() is True - - def test_not_ready_then_ready(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - return_value={"result": {"result": {"value": "not_ready"}}}, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value", - side_effect=["not_ready", "ok"], - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", - 2, - ), - ): - assert _wait_for_collections_ready() is True - - def test_timeout(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._evaluate_js", - side_effect=RuntimeError, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT", - 2, - ), - ): - assert _wait_for_collections_ready() is False - - -class TestShutdownSteam: - """Tests for _shutdown_steam.""" - - def test_exits_immediately(self) -> None: - mock_result = MagicMock(returncode=1) # Not running - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ), - ): - _shutdown_steam() - - def test_waits_for_exit(self) -> None: - results = [MagicMock(returncode=0), MagicMock(returncode=1)] - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", - side_effect=results, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", - ), - ): - _shutdown_steam() - - def test_file_not_found(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", - side_effect=FileNotFoundError, - ): - _shutdown_steam() # Should not raise - - def test_timeout(self) -> None: - mock_result = MagicMock(returncode=0) # Still running - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.subprocess.run", - return_value=mock_result, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider.time.sleep", - ), - ): - _shutdown_steam() # Should complete loop without raising - - -class TestLaunchSteamWithDebug: - """Tests for _launch_steam_with_debug.""" - - def test_launches(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider._run_as_user", - ) as mock_run: - _launch_steam_with_debug() - mock_run.assert_called_once() - - -class TestEnsureSteamDebugPort: - """Tests for ensure_steam_debug_port.""" - - def test_already_available(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=True, - ): - ensure_steam_debug_port() - - def test_starts_fresh(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready", - return_value=True, - ), - ): - ensure_steam_debug_port() - - def test_restarts_running_steam(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._shutdown_steam", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready", - return_value=True, - ), - ): - ensure_steam_debug_port() - - def test_cdp_timeout(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=False, - ), - pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"), - ): - ensure_steam_debug_port() - - def test_collections_timeout(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._is_steam_running", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug", - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready", - return_value=False, - ), - pytest.raises( - RuntimeError, match="Timed out waiting for Steam collections" - ), - ): - ensure_steam_debug_port() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py deleted file mode 100644 index affa1d4..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_library_hider_part2.py +++ /dev/null @@ -1,194 +0,0 @@ -"""Tests for library_hider module — part 2 (missing coverage).""" - -from __future__ import annotations - -import os -import tempfile -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.library_hider import ( - _run_as_user, - hide_other_games, - restart_steam, - unhide_all_games, -) - -PKG = "python_pkg.steam_backlog_enforcer.library_hider" - - -class TestRunAsUser: - """Tests for _run_as_user.""" - - def test_non_root_runs_directly(self) -> None: - with ( - patch(f"{PKG}.os.geteuid", return_value=1000), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - ): - _run_as_user(["steam", "-shutdown"], "alice") - mock_popen.assert_called_once() - cmd = mock_popen.call_args[0][0] - assert cmd == ["steam", "-shutdown"] - - def test_root_drops_to_user(self) -> None: - mock_pw = MagicMock() - mock_pw.pw_uid = 1001 - with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), - patch.dict( - os.environ, - {"DISPLAY": ":1", "XAUTHORITY": tempfile.gettempdir() + "/.X"}, - ), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - ): - _run_as_user(["steam", "-shutdown"], "alice") - mock_popen.assert_called_once() - cmd = mock_popen.call_args[0][0] - assert cmd[0] == "sudo" - assert "-u" in cmd - assert "alice" in cmd - - def test_root_user_key_error(self) -> None: - with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("no user")), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - ): - _run_as_user(["steam"], "unknownuser") - mock_popen.assert_called_once() - cmd = mock_popen.call_args[0][0] - # Falls back to uid 1000 - assert "sudo" in cmd[0] - - def test_root_user_none(self) -> None: - """When user is None and euid is 0, runs directly.""" - with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - ): - _run_as_user(["steam"], None) - cmd = mock_popen.call_args[0][0] - assert cmd == ["steam"] - - def test_root_user_is_root(self) -> None: - """When user is 'root', runs directly.""" - with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - ): - _run_as_user(["steam"], "root") - cmd = mock_popen.call_args[0][0] - assert cmd == ["steam"] - - def test_root_uses_env_defaults(self) -> None: - """When DBUS/XAUTHORITY/DISPLAY not in env, uses defaults.""" - mock_pw = MagicMock() - mock_pw.pw_uid = 1000 - env_copy = os.environ.copy() - env_copy.pop("DBUS_SESSION_BUS_ADDRESS", None) - env_copy.pop("XAUTHORITY", None) - env_copy.pop("DISPLAY", None) - with ( - patch(f"{PKG}.os.geteuid", return_value=0), - patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw), - patch.dict(os.environ, env_copy, clear=True), - patch(f"{PKG}.subprocess.Popen") as mock_popen, - ): - _run_as_user(["steam"], "bob") - cmd = mock_popen.call_args[0][0] - assert any("DISPLAY=:0" in arg for arg in cmd) - assert any("/home/bob/.Xauthority" in arg for arg in cmd) - - -class TestRestartSteam: - """Tests for restart_steam.""" - - def test_cdp_ready(self) -> None: - with ( - patch(f"{PKG}._shutdown_steam"), - patch(f"{PKG}._launch_steam_with_debug"), - patch(f"{PKG}._wait_for_cdp_ready", return_value=True), - ): - restart_steam() - - def test_cdp_not_ready(self) -> None: - with ( - patch(f"{PKG}._shutdown_steam"), - patch(f"{PKG}._launch_steam_with_debug"), - patch(f"{PKG}._wait_for_cdp_ready", return_value=False), - ): - restart_steam() - - -class TestHideOtherGames: - """Tests for hide_other_games.""" - - def test_hides(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 5}'}}, - }, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"totalHidden": 5}', - ), - ): - count = hide_other_games([1, 2, 3], 1) - assert count == 5 - - def test_empty_list(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 0}'}}, - }, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"totalHidden": 0}', - ), - ): - count = hide_other_games([1], 1) - assert count == 0 - - def test_no_allowed(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={ - "result": {"result": {"value": '{"totalHidden": 2}'}}, - }, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"totalHidden": 2}', - ), - ): - count = hide_other_games([1, 2], None) - assert count == 2 - - -class TestUnhideAllGames: - """Tests for unhide_all_games.""" - - def test_unhides(self) -> None: - with ( - patch(f"{PKG}.ensure_steam_debug_port"), - patch( - f"{PKG}._evaluate_js", - return_value={"result": {"result": {"value": '{"count": 10}'}}}, - ), - patch( - f"{PKG}._cdp_result_value", - return_value='{"count": 10}', - ), - ): - count = unhide_all_games([1, 2, 3]) - assert count == 10 diff --git a/python_pkg/steam_backlog_enforcer/tests/test_main.py b/python_pkg/steam_backlog_enforcer/tests/test_main.py deleted file mode 100644 index 837d671..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_main.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Tests for main CLI module.""" - -from __future__ import annotations - -import sys -import time -from typing import Any -from unittest.mock import patch - -import pytest - -from python_pkg.steam_backlog_enforcer._whitelist import WHITELIST_COOLDOWN_SECONDS -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.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 = "python_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}.hide_other_games", return_value=1), - 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}.hide_other_games", return_value=0), - patch(f"{PKG}._echo"), - ): - cmd_hide(Config(), state) - - -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_no_pending(self) -> None: - with ( - patch(f"{PKG}._echo") as mock_echo, - patch( - f"{PKG}.add_pending_exception", - return_value="Exception requested for AppID 440.", - ), - patch(f"{PKG}.list_pending_exceptions", return_value=[]), - ): - cmd_add_exception(["440", "--reason", _VALID_REASON]) - mock_echo.assert_called() - - def test_happy_path_with_pending_list(self) -> None: - now = time.time() - pending = [ - {"app_id": 440, "requested_at": now - WHITELIST_COOLDOWN_SECONDS - 1}, - {"app_id": 730, "requested_at": now}, - ] - with ( - patch(f"{PKG}._echo") as mock_echo, - patch( - f"{PKG}.add_pending_exception", - return_value="Exception requested for AppID 440.", - ), - patch(f"{PKG}.list_pending_exceptions", return_value=pending), - ): - cmd_add_exception(["440", "--reason", _VALID_REASON]) - # At least the "Pending exceptions" line should be echoed - calls = [str(c) for c in mock_echo.call_args_list] - assert any("Pending" in s for s in calls) - - -# ────────────────────────────────────────────────────────────── -# 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/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py deleted file mode 100644 index a36a64d..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_main_part2.py +++ /dev/null @@ -1,424 +0,0 @@ -"""Tests for main CLI module — part 2 (missing coverage).""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer._cmd_done import ( - _enforce_on_done, - _finalize_completion, - cmd_done, -) -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done" -PKG = "python_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 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") - snap = [_snap(2, "NewGame", 10, 0, 5.0)] - 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") as mock_pick, - patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]), - patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=2), - patch(f"{CMD_DONE_PKG}.send_notification"), - patch.object(State, "save"), - ): - - def set_next( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "NewGame" - - mock_pick.side_effect = set_next - _finalize_completion(config, state, "G", 1) - assert 1 in state.finished_app_ids - - def test_no_snapshot(self) -> None: - config = Config() - state = State(current_app_id=1, current_game_name="G") - with ( - patch(f"{CMD_DONE_PKG}._echo"), - patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=None), - patch.object(State, "save"), - ): - _finalize_completion(config, state, "G", 1) - assert state.current_app_id is None - - def test_no_next_game(self) -> None: - config = Config() - state = State(current_app_id=1, current_game_name="G") - snap = [_snap(1, "G", 10, 10)] - 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") as mock_pick, - patch.object(State, "save"), - ): - - def set_none( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = None - - mock_pick.side_effect = set_none - _finalize_completion(config, state, "G", 1) - - def test_no_owned_ids(self) -> None: - config = Config() - state = State(current_app_id=1, current_game_name="G") - snap = [_snap(2, "Next", 10, 0)] - 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") as mock_pick, - patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]), - patch(f"{CMD_DONE_PKG}.send_notification"), - patch.object(State, "save"), - ): - - def set_2( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "Next" - - mock_pick.side_effect = set_2 - _finalize_completion(config, state, "G", 1) - - def test_hide_returns_zero(self) -> None: - config = Config() - state = State(current_app_id=1, current_game_name="G") - snap = [_snap(2, "Next", 10, 0)] - 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") as mock_pick, - patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]), - patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0), - patch(f"{CMD_DONE_PKG}.send_notification"), - patch.object(State, "save"), - ): - - def set_2( - _games: object, - s: State, - _c: object, - **_kwargs: object, - ) -> None: - s.current_app_id = 2 - s.current_game_name = "Next" - - 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}.hide_other_games", return_value=1), - 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}.hide_other_games", return_value=1), - 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}.hide_other_games", return_value=1), - ): - _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}.hide_other_games", return_value=0), - ): - _enforce_on_done(config, state) - - 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}.hide_other_games", return_value=0), - ): - _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/python_pkg/steam_backlog_enforcer/tests/test_main_part3.py b/python_pkg/steam_backlog_enforcer/tests/test_main_part3.py deleted file mode 100644 index 60276ff..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_main_part3.py +++ /dev/null @@ -1,305 +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 python_pkg.steam_backlog_enforcer._cmd_done import ( - cmd_done, -) -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.main import cmd_pick, main -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done" -PKG = "python_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() - - -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}.hide_other_games", return_value=2) 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}.hide_other_games", return_value=0), - 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_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}.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/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py b/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py deleted file mode 100644 index 3014b51..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking.py +++ /dev/null @@ -1,382 +0,0 @@ -"""Tests for HLTB poll-count tracking, schema migration, and confidence display.""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer import _cmd_done -from python_pkg.steam_backlog_enforcer._hltb_types import ( - HLTBResult, - _HLTBExtras, - load_hltb_cache, - load_hltb_count_comp_cache, - load_hltb_game_id_cache, - load_hltb_polls_cache, - save_hltb_cache, -) -from python_pkg.steam_backlog_enforcer.config import State -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -if TYPE_CHECKING: - from pathlib import Path - -_TYPES = "python_pkg.steam_backlog_enforcer._hltb_types" -_CMD = "python_pkg.steam_backlog_enforcer._cmd_done" -_SCAN = "python_pkg.steam_backlog_enforcer.scanning" - - -class TestCacheSchema: - """Tests for the new cache schema and back-compat migration.""" - - def test_legacy_float_migrates(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8") - with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): - assert load_hltb_cache() == {440: 10.5} - assert load_hltb_polls_cache() == {440: 0} - assert load_hltb_count_comp_cache() == {440: 0} - - def test_new_dict_schema(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"440": {"hours": 10.5, "polls": 7, "count_comp": 20}}), - encoding="utf-8", - ) - with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): - assert load_hltb_cache() == {440: 10.5} - assert load_hltb_polls_cache() == {440: 7} - assert load_hltb_count_comp_cache() == {440: 20} - - def test_invalid_app_id_skipped(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"notanint": 1.0, "440": 5.0}), encoding="utf-8" - ) - with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): - assert load_hltb_cache() == {440: 5.0} - - def test_unparseable_value_skipped(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text(json.dumps({"440": "notafloat"}), encoding="utf-8") - with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): - assert load_hltb_cache() == {} - - def test_save_with_polls_roundtrip(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - ): - save_hltb_cache({440: 10.5}, {440: 7}, _HLTBExtras(count_comp={440: 20})) - data = json.loads(cache_file.read_text(encoding="utf-8")) - assert data == { - "440": { - "hours": 10.5, - "polls": 7, - "count_comp": 20, - "rush_hours": -1, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } - - def test_save_without_polls_defaults_zero(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - ): - save_hltb_cache({440: 10.5}) - data = json.loads(cache_file.read_text(encoding="utf-8")) - assert data == { - "440": { - "hours": 10.5, - "polls": 0, - "count_comp": 0, - "rush_hours": -1, - "leisure_100h": -1, - "hltb_game_id": 0, - } - } - - def test_load_game_id_cache(self, tmp_path: Path) -> None: - """load_hltb_game_id_cache returns the hltb_game_id portion of the cache.""" - cache_file = tmp_path / "hltb_cache.json" - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - ): - save_hltb_cache({440: 10.5}, extras=_HLTBExtras(hltb_game_id={440: 99})) - assert load_hltb_game_id_cache() == {440: 99} - - -class TestHltbResultPolls: - def test_default_zero(self) -> None: - r = HLTBResult(app_id=1, game_name="x", completionist_hours=1.0, similarity=1) - assert r.comp_100_count == 0 - assert r.count_comp == 0 - - def test_explicit(self) -> None: - r = HLTBResult( - app_id=1, - game_name="x", - completionist_hours=1.0, - similarity=1, - comp_100_count=42, - count_comp=100, - ) - assert r.comp_100_count == 42 - assert r.count_comp == 100 - - -class TestGameInfoPolls: - def test_snapshot_roundtrip(self) -> None: - g = GameInfo( - app_id=1, - name="X", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=30, - comp_100_count=8, - count_comp=20, - ) - snap = g.to_snapshot() - assert snap["comp_100_count"] == 8 - assert snap["count_comp"] == 20 - restored = GameInfo.from_snapshot(snap) - assert restored.comp_100_count == 8 - assert restored.count_comp == 20 - - def test_snapshot_missing_field_defaults(self) -> None: - snap = { - "app_id": 1, - "name": "X", - "total_achievements": 0, - "unlocked_achievements": 0, - } - restored = GameInfo.from_snapshot(snap) - assert restored.comp_100_count == 0 - assert restored.count_comp == 0 - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s - - -class TestBackfillPollsForFinished: - def test_no_missing_returns_existing(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 1.0, "polls": 5}}), encoding="utf-8" - ) - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), - ): - result = _cmd_done._backfill_polls_for_finished(_state([1])) - assert result == {1: 5} - - def test_no_snapshot_no_missing(self) -> None: - with ( - patch(f"{_CMD}.load_hltb_polls_cache", return_value={}), - patch(f"{_CMD}.load_snapshot", return_value=None), - ): - assert _cmd_done._backfill_polls_for_finished(_state([1])) == {} - - def test_missing_triggers_fetch(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": 2.0, "polls": 9} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 2.0 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]), - patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_CMD}._echo"), - ): - result = _cmd_done._backfill_polls_for_finished(_state([1])) - assert result == {1: 9} - - def test_extra_app_id_with_zero_polls_added(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": 1.0, "polls": 4} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 1.0 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]), - patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_CMD}._echo"), - ): - result = _cmd_done._backfill_polls_for_finished( - _state([], current=7), extra_app_id=7 - ) - assert result == {7: 4} - - def test_preserves_prior_hours_on_miss(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - # Simulate a refetch returning a miss (hours -1, polls 0). - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": -1, "polls": 0} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: -1 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]), - patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_CMD}._echo"), - ): - _cmd_done._backfill_polls_for_finished(_state([3])) - # Prior hours should be preserved on miss. - final = json.loads(cache_file.read_text(encoding="utf-8")) - assert final["3"]["hours"] == 4.0 - - -class TestReportAssignedConfidence: - def test_new_low_warning(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 1, 2: 5, 3: 10}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "OldShortest"}, - {"app_id": 3, "name": "Other"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2, 3], current=1)) - assert any("NEW LOW" in s for s in echoed) - assert any("Historical min" in s and "OldShortest" in s for s in echoed) - - def test_zero_polls_warning_with_history(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 0, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) - assert any("no polls recorded" in s for s in echoed) - - def test_zero_polls_warning_no_history(self) -> None: - echoed: list[str] = [] - with ( - patch(f"{_CMD}._backfill_polls_for_finished", return_value={1: 0}), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([], current=1)) - assert any("no polls recorded" in s for s in echoed) - assert not any("Historical min" in s for s in echoed) - - def test_healthy_no_warning(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 50, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) - assert any("HLTB confidence: 50" in s for s in echoed) - - def test_unknown_finished_uses_appid_label(self) -> None: - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 50, 99: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([99], current=1)) - assert any("AppID=99" in s for s in echoed) - - def test_chosen_equals_min_no_warning(self) -> None: - # Edge case: chosen_polls == min_polls (not a new low). - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 5, 2: 5}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - {"app_id": 2, "name": "Old"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([2], current=1)) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking_part2.py deleted file mode 100644 index 34fd530..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_polls_tracking_part2.py +++ /dev/null @@ -1,417 +0,0 @@ -"""Tests for HLTB poll-count tracking — scanning integration (part 2).""" - -from __future__ import annotations - -import json -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer import _cmd_done, _scanning_confidence, scanning -from python_pkg.steam_backlog_enforcer.config import State -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -if TYPE_CHECKING: - from pathlib import Path - -_TYPES = "python_pkg.steam_backlog_enforcer._hltb_types" -_CMD = "python_pkg.steam_backlog_enforcer._cmd_done" -_SCAN = "python_pkg.steam_backlog_enforcer.scanning" -_SCANCONF = "python_pkg.steam_backlog_enforcer._scanning_confidence" - - -def _state(finished: list[int], current: int | None = None) -> State: - s = State() - s.finished_app_ids = list(finished) - s.current_app_id = current - s.current_game_name = "" - return s - - -class TestScanningPollsIntegration: - def test_do_scan_kept_assignment_reports(self) -> None: - # Targeted test for scanning's `else` branch that prints CURRENT. - echoed: list[str] = [] - games = [ - GameInfo( - app_id=1, - name="X", - total_achievements=10, - unlocked_achievements=2, - playtime_minutes=0, - completionist_hours=5.0, - comp_100_count=20, - ) - ] - state = _state([], current=1) - with ( - patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_SCAN}._report_poll_confidence") as mock_report, - ): - # Directly invoke just the kept-assignment branch. - current = next((g for g in games if g.app_id == state.current_app_id), None) - assert current is not None - scanning._echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})") - scanning._report_poll_confidence(current, games, state) - assert any("CURRENT" in s for s in echoed) - mock_report.assert_called_once() - - def test_report_poll_confidence_new_low(self) -> None: - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=0, - ) - games = [ - chosen, - GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ), - ] - with ( - patch( - f"{_SCANCONF}._backfill_polls_for_finished", - return_value={1: 1, 2: 5}, - ), - patch( - f"{_SCANCONF}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence(chosen, games, _state([2], current=1)) - assert any("NEW LOW" in s for s in echoed) - assert chosen.comp_100_count == 1 - - def test_report_poll_confidence_no_history(self) -> None: - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=4, - ) - with ( - patch(f"{_SCANCONF}._backfill_polls_for_finished", return_value={1: 4}), - patch( - f"{_SCANCONF}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence(chosen, [chosen], _state([], current=1)) - # No "Historical min" line when no finished games have polls. - assert not any("Historical min" in s for s in echoed) - assert any("HLTB confidence: 4" in s for s in echoed) - - def test_scanning_backfill_no_missing(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"2": {"hours": 1.0, "polls": 5}}), encoding="utf-8" - ) - with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file): - result = _scanning_confidence._backfill_polls_for_finished( - _state([2]), - [ - GameInfo( - app_id=2, - name="X", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - ], - ) - assert result == {2: 5} - - def test_scanning_backfill_with_missing(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"2": {"hours": 3.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": 3.0, "polls": 8} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: 3.0 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - ): - result = _scanning_confidence._backfill_polls_for_finished( - _state([2]), - [ - GameInfo( - app_id=2, - name="X", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - ], - ) - assert result == {2: 8} - - def test_scanning_backfill_preserves_hours_on_miss(self, tmp_path: Path) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"2": {"hours": 9.0, "polls": 0}}), encoding="utf-8" - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - for aid, _name in games: - data[str(aid)] = {"hours": -1, "polls": 0} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {aid: -1 for aid, _ in games} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - ): - _scanning_confidence._backfill_polls_for_finished( - _state([2]), - [ - GameInfo( - app_id=2, - name="X", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - ], - ) - final = json.loads(cache_file.read_text(encoding="utf-8")) - assert final["2"]["hours"] == 9.0 - - def test_report_poll_confidence_chosen_zero_polls(self) -> None: - """Covers scanning.py 301-302: 0-poll chosen with history yields warning.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=0, - ) - old = GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ) - with ( - patch( - f"{_SCANCONF}._backfill_polls_for_finished", - return_value={1: 0, 2: 5}, - ), - patch( - f"{_SCANCONF}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence( - chosen, [chosen, old], _state([2], current=1) - ) - assert any("no polls recorded" in s for s in echoed) - - def test_do_scan_kept_assignment_missing_game(self) -> None: - """Covers scanning.py 110->116: current_app_id set but game absent.""" - from python_pkg.steam_backlog_enforcer.config import Config - from python_pkg.steam_backlog_enforcer.scanning import do_scan - - other = GameInfo( - app_id=999, - name="Other", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=0, - ) - - mock_client = MagicMock() - mock_client.build_game_list.return_value = [other] - with ( - patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client), - patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}), - patch(f"{_SCAN}.save_snapshot"), - patch(f"{_SCAN}.pick_next_game") as mock_pick, - patch(f"{_SCAN}._echo"), - patch(f"{_SCAN}._report_poll_confidence") as mock_report, - ): - config = Config(steam_api_key="k", steam_id="i") - state = State(current_app_id=440) # not in games - do_scan(config, state) - mock_pick.assert_not_called() - mock_report.assert_not_called() - - def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None: - """Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls.""" - echoed: list[str] = [] - with ( - patch( - f"{_CMD}._backfill_polls_for_finished", - return_value={1: 7}, - ), - patch( - f"{_CMD}.load_snapshot", - return_value=[ - {"app_id": 1, "name": "Chosen"}, - ], - ), - patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - _cmd_done._report_assigned_confidence(1, _state([], current=1)) - assert any("HLTB confidence: 7" in s for s in echoed) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) - - def test_report_poll_confidence_chosen_equals_min(self) -> None: - """Covers scanning.py 301->304: chosen_polls >= min_polls, no warning.""" - echoed: list[str] = [] - chosen = GameInfo( - app_id=1, - name="Chosen", - total_achievements=10, - unlocked_achievements=0, - playtime_minutes=0, - comp_100_count=5, - ) - old = GameInfo( - app_id=2, - name="Old", - total_achievements=10, - unlocked_achievements=10, - playtime_minutes=0, - ) - with ( - patch( - f"{_SCANCONF}._backfill_polls_for_finished", - return_value={1: 5, 2: 5}, - ), - patch( - f"{_SCANCONF}._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - ): - scanning._report_poll_confidence( - chosen, [chosen, old], _state([2], current=1) - ) - assert not any("NEW LOW" in s for s in echoed) - assert not any("no polls recorded" in s for s in echoed) - - def test_refresh_candidate_confidence_noop_when_present(self) -> None: - game = GameInfo( - app_id=1, - name="Known", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=3, - count_comp=15, - ) - with patch(f"{_SCANCONF}.fetch_hltb_confidence_cached") as mock_fetch: - _scanning_confidence._refresh_candidate_confidence(game) - mock_fetch.assert_not_called() - - def test_refresh_candidate_confidence_backfills_zeroes( - self, tmp_path: Path - ) -> None: - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}), - encoding="utf-8", - ) - game = GameInfo( - app_id=1, - name="NeedsRefresh", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - - def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]: - data = json.loads(cache_file.read_text(encoding="utf-8")) - data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {1: 4.0} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch), - patch(f"{_SCANCONF}._echo"), - ): - _scanning_confidence._refresh_candidate_confidence(game) - - assert game.comp_100_count == 3 - assert game.count_comp == 15 - - def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None: - """Filtering refreshes missing confidence in one batched cache lookup.""" - cache_file = tmp_path / "hltb_cache.json" - cache_file.write_text( - json.dumps( - { - "1": {"hours": 4.0, "polls": 0, "count_comp": 0}, - "2": {"hours": 5.0, "polls": 0, "count_comp": 0}, - } - ), - encoding="utf-8", - ) - game_a = GameInfo( - app_id=1, - name="A", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - game_b = GameInfo( - app_id=2, - name="B", - total_achievements=10, - unlocked_achievements=1, - playtime_minutes=0, - comp_100_count=0, - count_comp=0, - ) - - def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]: - assert sorted(games) == [(1, "A"), (2, "B")] - data = json.loads(cache_file.read_text(encoding="utf-8")) - data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15} - data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15} - cache_file.write_text(json.dumps(data), encoding="utf-8") - return {1: 4.0, 2: 5.0} - - with ( - patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file), - patch(f"{_TYPES}.CONFIG_DIR", tmp_path), - patch( - f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch - ) as mock_fetch, - patch(f"{_SCANCONF}._echo"), - ): - kept = _scanning_confidence._filter_hltb_confident_candidates( - [game_a, game_b] - ) - - assert [game.app_id for game in kept] == [1, 2] - mock_fetch.assert_called_once() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_protondb.py b/python_pkg/steam_backlog_enforcer/tests/test_protondb.py deleted file mode 100644 index 7e46438..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_protondb.py +++ /dev/null @@ -1,291 +0,0 @@ -"""Tests for protondb module.""" - -from __future__ import annotations - -import asyncio -import json -from typing import TYPE_CHECKING, Any -from unittest.mock import AsyncMock, MagicMock, patch - -import aiohttp - -from python_pkg.steam_backlog_enforcer.protondb import ( - HTTP_NOT_FOUND, - ProtonDBRating, - _fetch_batch, - _fetch_one, - _load_cache, - _rating_from_cache, - _rating_to_dict, - _save_cache, - fetch_protondb_ratings, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestProtonDBRating: - """Tests for ProtonDBRating.""" - - def test_playable_native(self) -> None: - r = ProtonDBRating(app_id=1, tier="native") - assert r.is_playable is True - - def test_playable_platinum(self) -> None: - r = ProtonDBRating(app_id=1, tier="platinum") - assert r.is_playable is True - - def test_playable_gold(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold") - assert r.is_playable is True - - def test_not_playable_silver(self) -> None: - r = ProtonDBRating(app_id=1, tier="silver") - assert r.is_playable is False - - def test_not_playable_bronze(self) -> None: - r = ProtonDBRating(app_id=1, tier="bronze") - assert r.is_playable is False - - def test_not_playable_borked(self) -> None: - r = ProtonDBRating(app_id=1, tier="borked") - assert r.is_playable is False - - def test_playable_no_data(self) -> None: - r = ProtonDBRating(app_id=1, tier="") - assert r.is_playable is True - - def test_playable_pending(self) -> None: - r = ProtonDBRating(app_id=1, tier="pending") - assert r.is_playable is True - - def test_gold_trending_silver(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver") - assert r.is_playable is True - - def test_gold_trending_gold(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold") - assert r.is_playable is True - - def test_silver_trending_gold(self) -> None: - r = ProtonDBRating(app_id=1, tier="silver", trending_tier="gold") - assert r.is_playable is True - - def test_gold_no_trending(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="") - assert r.is_playable is True - - def test_gold_trending_platinum(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="platinum") - assert r.is_playable is True - - def test_gold_trending_unknown(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown") - assert r.is_playable is False - - def test_gold_trending_bronze(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze") - assert r.is_playable is False - - def test_unknown_tier(self) -> None: - r = ProtonDBRating(app_id=1, tier="unknown_tier") - assert r.is_playable is False - - def test_unplayable_reason_no_trending_tier(self) -> None: - r = ProtonDBRating(app_id=1, tier="borked") - assert "tier<" in r.unplayable_reason - - def test_unplayable_reason_for_silver_silver(self) -> None: - r = ProtonDBRating(app_id=1, tier="silver", trending_tier="silver") - assert "no gold tier" in r.unplayable_reason - - def test_unplayable_reason_for_gold_bronze(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze") - assert "below silver" in r.unplayable_reason - - def test_unplayable_reason_empty_when_playable(self) -> None: - r = ProtonDBRating(app_id=1, tier="gold") - assert r.unplayable_reason == "" - - -class TestProtonDBCache: - """Tests for cache I/O.""" - - def test_load_cache_exists(self, tmp_path: Path) -> None: - cache_file = tmp_path / "protondb_cache.json" - cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): - result = _load_cache() - assert result == {"440": {"tier": "gold"}} - - def test_load_cache_missing(self, tmp_path: Path) -> None: - cache_file = tmp_path / "nonexistent.json" - with patch( - "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): - assert _load_cache() == {} - - def test_save_cache(self, tmp_path: Path) -> None: - cache_file = tmp_path / "protondb_cache.json" - config_dir = tmp_path - with ( - patch( - "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ), - patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), - ): - _save_cache({"440": {"tier": "gold"}}) - assert cache_file.exists() - - -class TestRatingConversion: - """Tests for rating serialization.""" - - def test_to_dict(self) -> None: - r = ProtonDBRating( - app_id=1, - tier="gold", - trending_tier="platinum", - score=0.9, - confidence="high", - total_reports=100, - ) - d = _rating_to_dict(r) - assert d["tier"] == "gold" - assert d["total_reports"] == 100 - - def test_from_cache(self) -> None: - data: dict[str, Any] = { - "tier": "silver", - "trending_tier": "bronze", - "score": 0.5, - } - r = _rating_from_cache(440, data) - assert r.app_id == 440 - assert r.tier == "silver" - assert r.trending_tier == "bronze" - - def test_from_cache_defaults(self) -> None: - r = _rating_from_cache(440, {}) - assert r.tier == "" - assert r.total_reports == 0 - - -class TestFetchOne: - """Tests for _fetch_one.""" - - def test_success(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.raise_for_status = MagicMock() - mock_resp.json = AsyncMock( - return_value={"tier": "gold", "trendingTier": "platinum"} - ) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - sem = asyncio.Semaphore(1) - result = asyncio.run(_fetch_one(mock_session, sem, 440)) - assert result.tier == "gold" - - def test_not_found(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = HTTP_NOT_FOUND - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - sem = asyncio.Semaphore(1) - result = asyncio.run(_fetch_one(mock_session, sem, 440)) - assert result.tier == "" - - def test_client_error(self) -> None: - mock_resp = AsyncMock() - mock_resp.status = 200 - mock_resp.raise_for_status = MagicMock(side_effect=aiohttp.ClientError) - mock_resp.__aenter__ = AsyncMock(return_value=mock_resp) - mock_resp.__aexit__ = AsyncMock(return_value=False) - - mock_session = MagicMock() - mock_session.get = MagicMock(return_value=mock_resp) - - sem = asyncio.Semaphore(1) - result = asyncio.run(_fetch_one(mock_session, sem, 440)) - assert result is None - - -class TestFetchBatch: - """Tests for _fetch_batch.""" - - def test_returns_ratings(self) -> None: - rating = ProtonDBRating(app_id=440, tier="gold") - with patch( - "python_pkg.steam_backlog_enforcer.protondb._fetch_one", - new_callable=AsyncMock, - return_value=rating, - ): - result = asyncio.run(_fetch_batch([440])) - assert len(result) == 1 - assert result[0].tier == "gold" - - def test_filters_none_results(self) -> None: - """Network failures (None) are filtered out of the batch result.""" - rating = ProtonDBRating(app_id=440, tier="gold") - - async def mock_fetch_one( - _session: aiohttp.ClientSession, - _sem: asyncio.Semaphore, - app_id: int, - ) -> ProtonDBRating | None: - return rating if app_id == 440 else None - - with patch( - "python_pkg.steam_backlog_enforcer.protondb._fetch_one", - side_effect=mock_fetch_one, - ): - result = asyncio.run(_fetch_batch([440, 999])) - assert len(result) == 1 - assert result[0].app_id == 440 - - -class TestFetchProtondbRatings: - """Tests for fetch_protondb_ratings.""" - - def test_all_cached(self, tmp_path: Path) -> None: - cache_file = tmp_path / "protondb_cache.json" - cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ): - result = fetch_protondb_ratings([440]) - assert 440 in result - assert result[440].tier == "gold" - - def test_fetch_uncached(self, tmp_path: Path) -> None: - cache_file = tmp_path / "protondb_cache.json" - config_dir = tmp_path - with ( - patch( - "python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE", - cache_file, - ), - patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir), - patch( - "python_pkg.steam_backlog_enforcer.protondb._fetch_batch", - return_value=[ProtonDBRating(app_id=440, tier="platinum")], - ), - ): - result = fetch_protondb_ratings([440]) - assert result[440].tier == "platinum" diff --git a/python_pkg/steam_backlog_enforcer/tests/test_scanning.py b/python_pkg/steam_backlog_enforcer/tests/test_scanning.py deleted file mode 100644 index 311e09b..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_scanning.py +++ /dev/null @@ -1,446 +0,0 @@ -"""Tests for scanning module.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating -from python_pkg.steam_backlog_enforcer.scanning import ( - _pick_playable_candidate, - do_scan, - pick_next_game, -) -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -if TYPE_CHECKING: - from collections.abc import Callable - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) - - -class TestDoScan: - """Tests for do_scan.""" - - def test_scans_and_picks(self) -> None: - game = _game(app_id=440, name="TF2", total=10, unlocked=5) - mock_client = MagicMock() - - def build_game_list( - progress_callback: Callable[..., object] | None = None, - ) -> list[GameInfo]: - # Trigger progress callback to cover those lines. - if progress_callback: - progress_callback(50, 100) - progress_callback(100, 100) - return [game] - - mock_client.build_game_list.side_effect = build_game_list - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached", - side_effect=lambda _games, progress_cb=None: ( - progress_cb(1, 1, 1, "TF2") if progress_cb else None, - {440: 20.0}, - )[1], - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.save_snapshot", - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", - ) as mock_pick, - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - ), - ): - config = Config(steam_api_key="k", steam_id="i") - state = State() - result = do_scan(config, state) - assert len(result) == 1 - mock_pick.assert_called_once() - - def test_scan_all_complete(self) -> None: - game = _game(app_id=440, name="TF2", total=10, unlocked=10) - mock_client = MagicMock() - - def build_game_list( - progress_callback: Callable[..., object] | None = None, - ) -> list[GameInfo]: - if progress_callback: - # current=1, total=2 → not %50 and not ==total → covers False branch - progress_callback(1, 2) - return [game] - - mock_client.build_game_list.side_effect = build_game_list - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.save_snapshot", - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", - ) as mock_pick, - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - config = Config(steam_api_key="k", steam_id="i") - state = State() - result = do_scan(config, state) - assert len(result) == 1 - mock_pick.assert_called_once() - - def test_scan_already_assigned(self) -> None: - game = _game(app_id=440, total=10, unlocked=5) - mock_client = MagicMock() - mock_client.build_game_list.return_value = [game] - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached", - return_value={440: 20.0}, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.save_snapshot", - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", - ) as mock_pick, - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - config = Config(steam_api_key="k", steam_id="i") - state = State(current_app_id=440) - result = do_scan(config, state) - assert len(result) == 1 - mock_pick.assert_not_called() - - -class TestPickPlayableCandidate: - """Tests for _pick_playable_candidate.""" - - def test_finds_playable(self) -> None: - game = _game(app_id=440, name="TF2") - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", - return_value={ - 440: ProtonDBRating(app_id=440, tier="gold"), - }, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - result = _pick_playable_candidate([game]) - assert result is not None - assert result.app_id == 440 - - def test_skips_bad_rating(self) -> None: - bad = _game(app_id=1, name="Bad") - good = _game(app_id=2, name="Good") - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", - return_value={ - 1: ProtonDBRating(app_id=1, tier="borked"), - 2: ProtonDBRating(app_id=2, tier="platinum"), - }, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - result = _pick_playable_candidate([bad, good]) - assert result is not None - assert result.app_id == 2 - - def test_all_unplayable(self) -> None: - game = _game(app_id=1, name="Bad") - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", - return_value={ - 1: ProtonDBRating(app_id=1, tier="borked"), - }, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - assert _pick_playable_candidate([game]) is None - - def test_empty_list(self) -> None: - assert _pick_playable_candidate([]) is None - - def test_first_in_batch_playable(self) -> None: - """First game in first batch is playable — no skip message.""" - game = _game(app_id=440, name="TF2") - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings", - return_value={ - 440: ProtonDBRating(app_id=440, tier="platinum"), - }, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - result = _pick_playable_candidate([game]) - assert result is not None - - -class TestPickNextGame: - """Tests for pick_next_game.""" - - def test_picks_shortest(self) -> None: - g1 = _game(app_id=1, name="Long", hours=100.0) - g2 = _game(app_id=2, name="Short", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch("python_pkg.steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_no_candidates(self) -> None: - g1 = _game(app_id=1, total=5, unlocked=5) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with patch("python_pkg.steam_backlog_enforcer.scanning._echo"): - pick_next_game([g1], state, config) - assert state.current_app_id is None - - def test_skips_finished(self) -> None: - g1 = _game(app_id=1, name="G1", hours=10.0) - g2 = _game(app_id=2, name="G2", hours=20.0) - config = Config(steam_api_key="k", steam_id="i") - state = State(finished_app_ids=[1]) - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_no_playable(self) -> None: - g1 = _game(app_id=1, name="G1") - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - pick_next_game([g1], state, config) - assert state.current_app_id is None - - def test_uninstalls_others(self) -> None: - g1 = _game(app_id=1, name="G1", hours=10.0) - config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True) - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch("python_pkg.steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=2, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - - def test_auto_installs(self) -> None: - g1 = _game(app_id=1, name="G1", hours=10.0) - config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False) - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch("python_pkg.steam_backlog_enforcer._scanning_confidence._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.install_game" - ) as mock_install, - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) - mock_install.assert_called_once() - - def test_unknown_hours(self) -> None: - g1 = _game(app_id=1, name="G1", hours=-1) - g2 = _game(app_id=2, name="G2", hours=10.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_picks_game_no_hours(self) -> None: - """Chosen game has no HLTB hours — covers no-hours output branch.""" - g1 = _game(app_id=1, name="G1", hours=-1) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - - def test_skips_low_confidence_and_picks_next(self) -> None: - low = _game(app_id=1, name="LowConfidence", hours=1.0) - low.comp_100_count = 1 - low.count_comp = 5 - valid = _game(app_id=2, name="ValidConfidence", hours=2.0) - valid.comp_100_count = 3 - valid.count_comp = 15 - echoed: list[str] = [] - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, valid], state, config) - assert state.current_app_id == 2 - assert any("Skipping LowConfidence" in line for line in echoed) - assert any("comp_100 polls 1 < 3" in line for line in echoed) - - def test_all_candidates_filtered_by_confidence(self) -> None: - low_a = _game(app_id=1, name="LowA", hours=1.0) - low_a.comp_100_count = 2 - low_a.count_comp = 15 - low_b = _game(app_id=2, name="LowB", hours=2.0) - low_b.comp_100_count = 3 - low_b.count_comp = 14 - echoed: list[str] = [] - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ) as mock_pick, - ): - pick_next_game([low_a, low_b], state, config) - assert state.current_app_id is None - mock_pick.assert_not_called() - assert any("No assignable games found" in line for line in echoed) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_scanning_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_scanning_part2.py deleted file mode 100644 index ef74fe4..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_scanning_part2.py +++ /dev/null @@ -1,134 +0,0 @@ -"""Tests for scanning module — part 2 (missing coverage).""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.scanning import ( - _check_game_tampering, - detect_tampering, -) - -PKG = "python_pkg.steam_backlog_enforcer.scanning" - - -def _entry( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 5, - playtime: int = 60, -) -> dict[str, Any]: - return { - "app_id": app_id, - "name": name, - "total_achievements": total, - "unlocked_achievements": unlocked, - "playtime_minutes": playtime, - } - - -class TestCheckGameTampering: - """Tests for _check_game_tampering.""" - - def test_current_game_skipped(self) -> None: - state = State(current_app_id=1) - result = _check_game_tampering(MagicMock(), _entry(app_id=1), state) - assert result is None - - def test_already_complete_skipped(self) -> None: - state = State() - result = _check_game_tampering( - MagicMock(), - _entry(unlocked=10, total=10), - state, - ) - assert result is None - - def test_zero_playtime_skipped(self) -> None: - state = State() - result = _check_game_tampering( - MagicMock(), - _entry(playtime=0), - state, - ) - assert result is None - - def test_no_new_achievements(self) -> None: - client = MagicMock() - game = MagicMock() - game.unlocked_achievements = 5 - client.refresh_single_game.return_value = game - state = State() - result = _check_game_tampering(client, _entry(unlocked=5), state) - assert result is None - - def test_tampering_detected(self) -> None: - client = MagicMock() - game = MagicMock() - game.unlocked_achievements = 8 - client.refresh_single_game.return_value = game - state = State() - entry = _entry(app_id=99, name="Cheated", unlocked=5) - result = _check_game_tampering(client, entry, state) - assert result is not None - assert result == ("Cheated", 99, 3) - - def test_refresh_returns_none(self) -> None: - client = MagicMock() - client.refresh_single_game.return_value = None - state = State() - result = _check_game_tampering(client, _entry(), state) - assert result is None - - -class TestDetectTampering: - """Tests for detect_tampering.""" - - def test_no_snapshot(self) -> None: - with patch(f"{PKG}.load_snapshot", return_value=None): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) - - def test_no_tampering(self) -> None: - entries = [_entry(app_id=1)] - with ( - patch(f"{PKG}.load_snapshot", return_value=entries), - patch(f"{PKG}.SteamAPIClient"), - patch(f"{PKG}._check_game_tampering", return_value=None), - patch(f"{PKG}._echo"), - ): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) - - def test_tampering_found(self) -> None: - entries = [_entry(app_id=1, name="BadGame")] - with ( - patch(f"{PKG}.load_snapshot", return_value=entries), - patch(f"{PKG}.SteamAPIClient"), - patch( - f"{PKG}._check_game_tampering", - return_value=("BadGame", 1, 3), - ), - patch(f"{PKG}._echo") as mock_echo, - patch(f"{PKG}.send_notification"), - ): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) - assert any("TAMPERING" in str(c) for c in mock_echo.call_args_list) - - def test_stops_at_limit(self) -> None: - """Stops after _TAMPER_CHECK_LIMIT suspicious games.""" - entries = [_entry(app_id=i, name=f"G{i}") for i in range(10)] - with ( - patch(f"{PKG}.load_snapshot", return_value=entries), - patch(f"{PKG}.SteamAPIClient"), - patch( - f"{PKG}._check_game_tampering", - return_value=("Game", 1, 1), - ) as mock_check, - patch(f"{PKG}._echo"), - patch(f"{PKG}.send_notification"), - ): - detect_tampering(Config(steam_api_key="k", steam_id="i"), State()) - # Should stop after 3 (_TAMPER_CHECK_LIMIT) - assert mock_check.call_count == 3 diff --git a/python_pkg/steam_backlog_enforcer/tests/test_scanning_part3.py b/python_pkg/steam_backlog_enforcer/tests/test_scanning_part3.py deleted file mode 100644 index c07dc89..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_scanning_part3.py +++ /dev/null @@ -1,414 +0,0 @@ -"""Tests for scanning module (part 3): TestPickNextGame continued.""" - -from __future__ import annotations - -import contextlib -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.scanning import pick_next_game -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) - - -class TestPickNextGame: - """Tests for pick_next_game (continued from test_scanning.py).""" - - def test_zero_confidence_is_refreshed_before_skipping(self) -> None: - """Missing confidence fields are refreshed once before final skip decision.""" - stale = _game(app_id=1, name="Celeste", hours=1.0) - stale.comp_100_count = 0 - stale.count_comp = 0 - fallback = _game(app_id=2, name="Fallback", hours=2.0) - - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - - def refresh_side_effect(game: GameInfo) -> None: - if game.app_id == 1: - game.comp_100_count = 899 - game.count_comp = 14055 - - with ( - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence", - side_effect=refresh_side_effect, - ) as mock_refresh, - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([stale, fallback], state, config) - - assert state.current_app_id == 1 - mock_refresh.assert_called_once_with(stale) - assert not any("Skipping Celeste" in line for line in echoed) - - def test_nonzero_low_confidence_does_not_force_refetch(self) -> None: - """Non-zero low-confidence entries are skipped using cached values.""" - low = _game(app_id=1, name="Low", hours=1.0) - low.comp_100_count = 1 - low.count_comp = 8 - fallback = _game(app_id=2, name="Fallback", hours=2.0) - - config = Config(steam_api_key="k", steam_id="i") - state = State() - - with ( - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch" - ) as mock_refresh_batch, - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, fallback], state, config) - - assert state.current_app_id == 2 - mock_refresh_batch.assert_not_called() - - def test_cached_confidence_overlay_avoids_refetch_for_zero_snapshot_fields( - self, - ) -> None: - """Use cached confidence before deciding whether refresh is needed.""" - low = _game(app_id=1, name="Low", hours=1.0) - low.comp_100_count = 0 - low.count_comp = 0 - fallback = _game(app_id=2, name="Fallback", hours=2.0) - fallback.comp_100_count = 3 - fallback.count_comp = 20 - - config = Config(steam_api_key="k", steam_id="i") - state = State() - - with ( - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", - return_value={1: 1, 2: 3}, - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", - return_value={1: 8, 2: 20}, - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch" - ) as mock_refresh_batch, - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game([low, fallback], state, config) - - assert state.current_app_id == 2 - mock_refresh_batch.assert_not_called() - - def test_stops_collecting_after_n_qualified(self) -> None: - """Collection stops once _PICK_LIST_SIZE candidates are qualified.""" - # Create 11 games that all pass filters; only the first 10 should be - # presented and the 11th should never trigger a ProtonDB call. - games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 12)] - protondb_call_count = 0 - - def playable_side_effect(c: list[GameInfo]) -> GameInfo | None: - nonlocal protondb_call_count - protondb_call_count += 1 - return c[0] if c else None - - config = Config(steam_api_key="k", steam_id="i") - state = State() - - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=playable_side_effect, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="1"), - ): - pick_next_game(games, state, config) - - assert state.current_app_id == 1 - assert protondb_call_count == 10 - - def test_user_picks_second_candidate(self) -> None: - """User can select a game other than the shortest one.""" - g1 = _game(app_id=1, name="Short", hours=5.0) - g2 = _game(app_id=2, name="Medium", hours=15.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", return_value="2"), - ): - pick_next_game([g1, g2], state, config) - assert state.current_app_id == 2 - - def test_invalid_input_then_valid(self) -> None: - """Non-numeric input prints error and loops until valid input.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", side_effect=["abc", "1"]), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - assert any("Invalid input" in line for line in echoed) - - def test_out_of_range_then_valid(self) -> None: - """Out-of-range number prints error and loops until valid input.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ), - patch("builtins.input", side_effect=["99", "1"]), - ): - pick_next_game([g1], state, config) - assert state.current_app_id == 1 - assert any("Out of range" in line for line in echoed) - - -class TestPickNextGameSequential: - """Tests for the on_select sequential branch of pick_next_game.""" - - @staticmethod - def _common_patches(echoed: list[str]) -> contextlib.ExitStack: - stack = contextlib.ExitStack() - stack.enter_context( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ) - ) - stack.enter_context( - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ) - ) - stack.enter_context( - patch( - "python_pkg.steam_backlog_enforcer.scanning.is_game_installed", - return_value=True, - ) - ) - stack.enter_context( - patch( - "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games", - return_value=0, - ) - ) - stack.enter_context( - patch("python_pkg.steam_backlog_enforcer.config._atomic_write") - ) - return stack - - def test_on_select_accepts_pick(self) -> None: - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with self._common_patches(echoed): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id == 1 - - def test_on_select_rejection_records_skip_and_picks_next(self) -> None: - g1 = _game(app_id=1, name="G1", hours=5.0) - g2 = _game(app_id=2, name="G2", hours=6.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - calls: list[int] = [] - - def on_select(game: GameInfo) -> bool: - calls.append(game.app_id) - return game.app_id == 2 # reject g1, accept g2 - - with self._common_patches(echoed): - pick_next_game([g1, g2], state, config, on_select=on_select) - - assert calls == [1, 2] - assert state.current_app_id == 2 - assert "1" in state.skipped_until - assert any("Skipped G1 for 7 days" in line for line in echoed) - - def test_on_select_no_candidates(self) -> None: - """Sequential branch with no candidates clears state.""" - complete = _game(app_id=1, hours=1.0, total=10, unlocked=10) - config = Config(steam_api_key="k", steam_id="i") - state = State(current_app_id=99, current_game_name="X") - echoed: list[str] = [] - with self._common_patches(echoed): - pick_next_game([complete], state, config, on_select=lambda _g: True) - assert state.current_app_id is None - - def test_on_select_no_playable_branch(self) -> None: - """Sequential branch when all candidates lack Linux compatibility.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch("python_pkg.steam_backlog_enforcer.config._atomic_write"), - ): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id is None - assert any("No playable games" in line for line in echoed) - - def test_on_select_no_confidence_branch(self) -> None: - """Sequential branch when all candidates fail HLTB confidence.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - g1.comp_100_count = 0 - g1.count_comp = 0 - config = Config(steam_api_key="k", steam_id="i") - state = State() - echoed: list[str] = [] - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning._echo", - side_effect=lambda *a, **_: echoed.append(a[0]), - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence", - ), - patch("python_pkg.steam_backlog_enforcer.config._atomic_write"), - ): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id is None - assert any("No assignable games" in line for line in echoed) - - def test_enforcement_started_at_not_overwritten_when_set(self) -> None: - """enforcement_started_at is preserved when already populated.""" - g1 = _game(app_id=1, name="G1", hours=5.0) - config = Config(steam_api_key="k", steam_id="i") - existing_ts = "2024-01-01T00:00:00+00:00" - state = State(enforcement_started_at=existing_ts) - with self._common_patches([]): - pick_next_game([g1], state, config, on_select=lambda _g: True) - assert state.current_app_id == 1 - assert state.enforcement_started_at == existing_ts diff --git a/python_pkg/steam_backlog_enforcer/tests/test_scanning_part4.py b/python_pkg/steam_backlog_enforcer/tests/test_scanning_part4.py deleted file mode 100644 index e505133..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_scanning_part4.py +++ /dev/null @@ -1,328 +0,0 @@ -"""Scanning tests (part 4): collect_top_candidates, do_check, confidence.""" - -from __future__ import annotations - -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer._scanning_confidence import ( - _filter_hltb_confident_candidates, - _force_refresh_candidate_confidence, - _refresh_candidate_confidence_batch, -) -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.scanning import ( - _collect_top_candidates, - _pick_next_shortest_candidate, - do_check, -) -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - - -def _game( - app_id: int = 1, - name: str = "G", - total: int = 10, - unlocked: int = 0, - hours: float = -1, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=3, - count_comp=15, - ) - - -class TestCollectTopCandidates: - """Tests for _collect_top_candidates.""" - - def test_collects_up_to_n(self) -> None: - """Returns at most n qualified candidates.""" - games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 6)] - with patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ): - qualified, conf_skip, linux_skip = _collect_top_candidates(games, n=3) - assert len(qualified) == 3 - assert [g.app_id for g in qualified] == [1, 2, 3] - assert conf_skip == 0 - assert linux_skip == 0 - - def test_skips_linux_incompatible(self) -> None: - """Games failing ProtonDB are counted in linux_skipped.""" - g1 = _game(app_id=1, name="Borked", hours=1.0) - g2 = _game(app_id=2, name="Good", hours=2.0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: None if c[0].app_id == 1 else c[0], - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - qualified, conf_skip, linux_skip = _collect_top_candidates([g1, g2], n=10) - assert [g.app_id for g in qualified] == [2] - assert linux_skip == 1 - assert conf_skip == 0 - - def test_empty_candidates(self) -> None: - qualified, conf_skip, linux_skip = _collect_top_candidates([]) - assert qualified == [] - assert conf_skip == 0 - assert linux_skip == 0 - - def test_no_linux_skip_message_when_zero(self) -> None: - """No skip message is printed when linux_skipped is 0.""" - g = _game(app_id=1, name="Good", hours=1.0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=lambda c: c[0] if c else None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - _collect_top_candidates([g], n=10) - mock_echo.assert_not_called() - - -class TestDoCheck: - """Tests for do_check.""" - - def test_no_assignment(self) -> None: - with patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo: - do_check(Config(), State()) - mock_echo.assert_called() - - def test_fetch_fails(self) -> None: - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = None - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) - - -class TestConfidenceHelpers: - """Coverage-focused tests for scanning confidence helper branches.""" - - def test_force_refresh_candidate_confidence_delegates(self) -> None: - game = _game(app_id=10, name="A") - with patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", - ) as mock_batch: - _force_refresh_candidate_confidence(game) - mock_batch.assert_called_once_with([game], force=True) - - def test_refresh_candidate_confidence_batch_no_missing_skips_fetch(self) -> None: - game = _game(app_id=20, name="B", hours=12.0) - game.comp_100_count = 3 - game.count_comp = 15 - with patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", - ) as mock_fetch: - _refresh_candidate_confidence_batch([game], force=False) - mock_fetch.assert_not_called() - - def test_refresh_candidate_confidence_batch_preserves_existing_hours(self) -> None: - game = _game(app_id=30, name="C", hours=9.5) - game.comp_100_count = 0 - game.count_comp = 0 - with ( - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_cache", - side_effect=[{30: 9.5}, {30: -1.0}], - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache", - return_value={30: 0}, - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache", - return_value={30: 0}, - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached", - return_value={30: -1.0}, - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence.save_hltb_cache", - ) as mock_save, - ): - _refresh_candidate_confidence_batch([game], force=True) - - assert game.completionist_hours == 9.5 - saved_cache = mock_save.call_args.args[0] - assert saved_cache[30] == 9.5 - - def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None: - low = _game(app_id=40, name="Low", hours=2.0) - low.comp_100_count = 1 - low.count_comp = 2 - with ( - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch", - ), - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._echo" - ) as mock_echo, - ): - result = _filter_hltb_confident_candidates([low]) - assert result == [] - assert mock_echo.called - - def test_pick_next_shortest_candidate_logs_skipped_unplayable_batches(self) -> None: - bad = _game(app_id=50, name="Bad", hours=1.0) - good = _game(app_id=51, name="Good", hours=2.0) - bad.comp_100_count = 3 - bad.count_comp = 15 - good.comp_100_count = 3 - good.count_comp = 15 - - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - side_effect=[None, good], - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [bad, good], - ) - - assert picked is good - assert skipped_low_conf == 0 - assert skipped_linux == 1 - assert any( - "Skipped 1 game(s) with poor Linux compatibility" in str(call) - for call in mock_echo.call_args_list - ) - - def test_pick_next_shortest_candidate_no_echo_when_linux_skipped_zero( - self, - ) -> None: - """Covers 419->423: no echo printed when linux_skipped == 0.""" - good = _game(app_id=51, name="Good", hours=2.0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=good, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [good], - ) - assert picked is good - assert skipped_linux == 0 - mock_echo.assert_not_called() - - def test_pick_next_shortest_candidate_skips_low_confidence(self) -> None: - """Covers lines 413-414: confidence_skipped += 1; continue.""" - low_conf = _game(app_id=10, name="Low", hours=1.0) - low_conf.comp_100_count = 0 - low_conf.count_comp = 0 - with ( - patch( - "python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence" - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - ): - picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [low_conf], - ) - assert picked is None - assert skipped_low_conf == 1 - assert skipped_linux == 0 - - def test_pick_next_shortest_candidate_all_protondb_fail(self) -> None: - """Covers lines 426-428: linux_skipped > 0 after loop, return None.""" - g1 = _game(app_id=10, name="Borked", hours=1.0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate", - return_value=None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo, - ): - picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate( - [g1], - ) - assert picked is None - assert skipped_linux == 1 - assert any( - "Skipped 1 game(s) with poor Linux compatibility" in str(call) - for call in mock_echo.call_args_list - ) - - game = _game(app_id=440, name="TF2", total=5, unlocked=5) - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = game - snap = [game.to_snapshot()] - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.send_notification", - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.load_snapshot", - return_value=snap, - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.pick_next_game", - ), - patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) - assert 440 in state.finished_app_ids - - def test_complete_no_snapshot(self) -> None: - game = _game(app_id=440, name="TF2", total=5, unlocked=5) - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = game - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch( - "python_pkg.steam_backlog_enforcer.scanning.send_notification", - ), - patch( - "python_pkg.steam_backlog_enforcer.scanning.load_snapshot", - return_value=None, - ), - patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) - - def test_not_complete(self) -> None: - game = _game(app_id=440, name="TF2", total=10, unlocked=5) - mock_client = MagicMock() - mock_client.refresh_single_game.return_value = game - with ( - patch( - "python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient", - return_value=mock_client, - ), - patch("python_pkg.steam_backlog_enforcer.scanning._echo"), - patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"), - ): - state = State(current_app_id=440, current_game_name="TF2") - do_check(Config(steam_api_key="k", steam_id="i"), state) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_stats.py b/python_pkg/steam_backlog_enforcer/tests/test_stats.py deleted file mode 100644 index 8a09158..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_stats.py +++ /dev/null @@ -1,701 +0,0 @@ -"""Tests for _stats module — 100% branch coverage.""" - -from __future__ import annotations - -from datetime import datetime, timedelta, timezone -from unittest.mock import patch - -from python_pkg.steam_backlog_enforcer._stats import ( - _ensure_rush_data, - _filter_qualifying_games, - _format_completion_date, - _GameTimes, - _print_pace_scenario, - _print_scenario, - _print_worst_example, - _sum_hours, - cmd_stats, -) -from python_pkg.steam_backlog_enforcer.config import Config, State -from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating -from python_pkg.steam_backlog_enforcer.steam_api import GameInfo - -_PKG = "python_pkg.steam_backlog_enforcer._stats" - - -def _game( - app_id: int = 1, - name: str = "G", - hours: float = 10.0, - total: int = 10, - unlocked: int = 0, -) -> GameInfo: - return GameInfo( - app_id=app_id, - name=name, - total_achievements=total, - unlocked_achievements=unlocked, - playtime_minutes=60, - completionist_hours=hours, - comp_100_count=5, - count_comp=20, - ) - - -def _unplayable_rating(app_id: int) -> ProtonDBRating: - return ProtonDBRating(app_id=app_id, tier="borked") - - -class TestFilterQualifyingGames: - """Tests for _filter_qualifying_games.""" - - def _run( - self, - games: list[GameInfo], - state: State, - rush_cache: dict[int, float] | None = None, - leisure_cache: dict[int, float] | None = None, - game_id_cache: dict[int, int] | None = None, - ) -> tuple[list[_GameTimes], int, int, int]: - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}), - patch( - f"{_PKG}.load_hltb_leisure_100h_cache", - return_value=leisure_cache or {}, - ), - patch( - f"{_PKG}.load_hltb_game_id_cache", - return_value=game_id_cache or {}, - ), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value={}), - ): - return _filter_qualifying_games(games, state) - - def test_current_app_id_excluded(self) -> None: - state = State(current_app_id=1) - g1 = _game(app_id=1) - g2 = _game(app_id=2) - qualified, _, _, _ = self._run([g1, g2], state) - ids = [e.game.app_id for e in qualified] - assert 1 not in ids - assert 2 in ids - - def test_no_current_app_id_branch(self) -> None: - """current_app_id is None — the exclude.add branch is not taken.""" - state = State(current_app_id=None) - g = _game(app_id=3) - qualified, _, _, _ = self._run([g], state) - assert len(qualified) == 1 - - def test_finished_app_ids_excluded(self) -> None: - state = State() - state.finished_app_ids = [1] - g1 = _game(app_id=1) - g2 = _game(app_id=2) - qualified, _, _, _ = self._run([g1, g2], state) - assert all(e.game.app_id != 1 for e in qualified) - - def test_complete_games_excluded(self) -> None: - """Games where is_complete is True are excluded from candidates.""" - state = State() - complete = _game(app_id=1, total=5, unlocked=5) - incomplete = _game(app_id=2, total=5, unlocked=0) - qualified, _, _, _ = self._run([complete, incomplete], state) - assert len(qualified) == 1 - assert qualified[0].game.app_id == 2 - - def test_low_confidence_counts_hltb_skipped(self) -> None: - state = State() - g = _game(app_id=1) - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=["low"]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value={}), - ): - qualified, hltb_skip, _, _ = _filter_qualifying_games([g], state) - assert hltb_skip == 1 - assert len(qualified) == 0 - - def test_no_candidates_skips_protondb_call(self) -> None: - """When confidence filters all out, fetch_protondb_ratings is not called.""" - state = State() - g = _game(app_id=1) - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=["low"]), - patch(f"{_PKG}.fetch_protondb_ratings") as mock_proton, - ): - _filter_qualifying_games([g], state) - mock_proton.assert_not_called() - - def test_unplayable_rating_counts_linux_skipped(self) -> None: - state = State() - g = _game(app_id=1) - ratings = {1: _unplayable_rating(1)} - with ( - patch(f"{_PKG}.load_hltb_rush_cache", return_value={}), - patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}), - patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}), - patch(f"{_PKG}._apply_cached_confidence_to_candidates"), - patch(f"{_PKG}._refresh_candidate_confidence_batch"), - patch(f"{_PKG}._confidence_fail_reasons", return_value=[]), - patch(f"{_PKG}.fetch_protondb_ratings", return_value=ratings), - ): - qualified, _, linux_skip, _ = _filter_qualifying_games([g], state) - assert linux_skip == 1 - assert len(qualified) == 0 - - def test_no_data_counts_no_data_skipped(self) -> None: - """Game with all -1 hours is counted as no_data_skipped.""" - state = State() - g = _game(app_id=1, hours=-1.0) - qualified, _, _, no_data_skip = self._run([g], state) - assert no_data_skip == 1 - assert len(qualified) == 0 - - def test_worst_hours_positive_when_completionist_hours_positive(self) -> None: - state = State() - g = _game(app_id=1, hours=25.0) - qualified, _, _, _ = self._run([g], state, rush_cache={1: 10.0}) - assert qualified[0].worst_hours == 25.0 - - def test_worst_hours_from_leisure_when_completionist_zero(self) -> None: - """worst_hours falls back to leisure_100h when completionist_hours is zero.""" - state = State() - g = _game(app_id=1, hours=0.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 5.0}, leisure_cache={1: 6.0} - ) - assert qualified[0].worst_hours == 6.0 - - def test_worst_hours_is_max_when_leisure_exceeds_completionist(self) -> None: - """worst_hours is max(completionist, leisure_100h) when leisure is higher.""" - state = State() - g = _game(app_id=1, hours=25.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 10.0}, leisure_cache={1: 40.0} - ) - assert qualified[0].worst_hours == 40.0 - - def test_worst_hours_negative_when_all_zero(self) -> None: - """worst_hours = -1 when both completionist_hours and leisure_100h are zero.""" - state = State() - g = _game(app_id=1, hours=0.0) - qualified, _, _, _ = self._run([g], state, rush_cache={1: 5.0}) - assert qualified[0].worst_hours == -1 - - def test_rush_and_leisure_from_cache(self) -> None: - state = State() - g = _game(app_id=1, hours=30.0) - qualified, _, _, _ = self._run( - [g], state, rush_cache={1: 12.0}, leisure_cache={1: 40.0} - ) - assert qualified[0].rush_hours == 12.0 - assert qualified[0].leisure_100h == 40.0 - - def test_missing_cache_entry_defaults_to_minus_one(self) -> None: - state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state) - assert qualified[0].rush_hours == -1 - assert qualified[0].leisure_100h == -1 - - def test_only_rush_nonzero_qualifies(self) -> None: - """Game qualifies if only rush_hours is positive (worst <= 0, leisure <= 0).""" - state = State() - g = _game(app_id=1, hours=-1.0) - qualified, _, _, no_data_skip = self._run([g], state, rush_cache={1: 8.0}) - assert no_data_skip == 0 - assert len(qualified) == 1 - - def test_game_id_populated_from_cache(self) -> None: - """hltb_game_id is taken from game_id_cache.""" - state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state, game_id_cache={1: 57514}) - assert qualified[0].hltb_game_id == 57514 - - def test_game_id_defaults_to_zero_when_not_in_cache(self) -> None: - """hltb_game_id defaults to 0 when not in cache.""" - state = State() - g = _game(app_id=1, hours=20.0) - qualified, _, _, _ = self._run([g], state) - assert qualified[0].hltb_game_id == 0 - - -class TestSumHours: - """Tests for _sum_hours.""" - - def _make_entry(self, worst: float, rush: float, leisure: float) -> _GameTimes: - return _GameTimes( - game=_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure - ) - - def test_empty_list(self) -> None: - total, missing = _sum_hours([], "worst_hours") - assert total == 0.0 - assert missing == 0 - - def test_all_positive(self) -> None: - entries = [ - self._make_entry(10.0, 8.0, 12.0), - self._make_entry(20.0, 15.0, 25.0), - ] - total, missing = _sum_hours(entries, "worst_hours") - assert total == 30.0 - assert missing == 0 - - def test_some_negative(self) -> None: - entries = [ - self._make_entry(10.0, -1.0, 12.0), - self._make_entry(-1.0, 8.0, 25.0), - ] - total, missing = _sum_hours(entries, "worst_hours") - assert total == 10.0 - assert missing == 1 - - def test_all_negative(self) -> None: - entries = [self._make_entry(-1.0, -1.0, -1.0)] - total, missing = _sum_hours(entries, "rush_hours") - assert total == 0.0 - assert missing == 1 - - -class TestFormatCompletionDate: - """Tests for _format_completion_date.""" - - def test_zero_hours_returns_na(self) -> None: - assert _format_completion_date(0.0, 4.0) == "N/A" - - def test_negative_hours_returns_na(self) -> None: - assert _format_completion_date(-5.0, 4.0) == "N/A" - - def test_zero_daily_hours_returns_na(self) -> None: - assert _format_completion_date(100.0, 0.0) == "N/A" - - def test_negative_daily_hours_returns_na(self) -> None: - assert _format_completion_date(100.0, -1.0) == "N/A" - - def test_normal_returns_days_and_date(self) -> None: - result = _format_completion_date(40.0, 4.0) - # 40 / 4 = 10 days - assert result.startswith("10 days (") - assert ")" in result - - -class TestPrintScenario: - """Tests for _print_scenario.""" - - def test_no_data_prints_no_data_message(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_scenario("2. RUSH", 0.0, 0, 5) - assert any("No data available" in s for s in echoed) - - def test_with_data_no_missing(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_scenario("2. RUSH", 100.0, 0, 5) - assert any("Total:" in s for s in echoed) - assert not any("had no data" in s for s in echoed) - - def test_with_data_and_missing(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_scenario("2. RUSH", 100.0, 2, 5) - assert any("had no data" in s for s in echoed) - - -class TestPrintPaceScenario: - """Tests for _print_pace_scenario.""" - - def test_no_start_date(self) -> None: - state = State() - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 10, 0) - assert any("No start date recorded" in s for s in echoed) - - def test_invalid_start_date(self) -> None: - state = State(enforcement_started_at="not-a-date") - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 10, 0) - assert any("Invalid enforcement_started_at" in s for s in echoed) - - def test_no_games_finished(self) -> None: - started = datetime.now(timezone.utc) - timedelta(days=30) - state = State(enforcement_started_at=started.isoformat()) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 10, 0) - assert any("No games finished yet" in s for s in echoed) - - def test_normal_pace(self) -> None: - started = datetime.now(timezone.utc) - timedelta(days=60) - state = State(enforcement_started_at=started.isoformat()) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_pace_scenario(state, 5, 3) - assert any("Pace:" in s for s in echoed) - assert any("Est. complete:" in s for s in echoed) - - -class TestCmdStats: - """Tests for cmd_stats.""" - - def _config(self) -> Config: - return Config(steam_api_key="k", steam_id="i") - - def test_no_snapshot(self) -> None: - echoed: list[str] = [] - state = State() - with ( - patch(f"{_PKG}.load_snapshot", return_value=None), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - ): - cmd_stats(self._config(), state) - assert any("No snapshot found" in s for s in echoed) - - def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]: - return { - "app_id": app_id, - "name": f"Game{app_id}", - "total_achievements": 10, - "unlocked_achievements": 0, - "playtime_minutes": 60, - "completionist_hours": hours, - "comp_100_count": 5, - "count_comp": 20, - } - - def _run_cmd_stats( - self, - state: State, - hltb_skip: int = 0, - linux_skip: int = 0, - no_data_skip: int = 0, - ) -> list[str]: - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], hltb_skip, linux_skip, no_data_skip), - ), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) - return echoed - - def test_with_no_current_game(self) -> None: - state = State() - echoed = self._run_cmd_stats(state) - assert any("Qualifying games" in s for s in echoed) - assert not any("Current game:" in s for s in echoed) - - def test_with_current_game(self) -> None: - state = State(current_app_id=42, current_game_name="Hollow Knight") - echoed = self._run_cmd_stats(state) - assert any("Current game:" in s and "Hollow Knight" in s for s in echoed) - - def test_hltb_skipped_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state, hltb_skip=3) - assert any("HLTB-skipped" in s for s in echoed) - - def test_linux_skipped_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state, linux_skip=2) - assert any("Linux-skipped" in s for s in echoed) - - def test_no_data_skipped_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state, no_data_skip=1) - assert any("No-data-skipped" in s for s in echoed) - - def test_zero_skips_not_shown(self) -> None: - state = State() - echoed = self._run_cmd_stats(state) - assert not any("HLTB-skipped" in s for s in echoed) - assert not any("Linux-skipped" in s for s in echoed) - assert not any("No-data-skipped" in s for s in echoed) - - def test_finished_games_count_uses_snapshot_complete(self) -> None: - """'Finished games' count uses snapshot is_complete, not finished_app_ids.""" - state = State() - # finished_app_ids has 1 entry, but snapshot has 2 complete games — count = 2. - state.finished_app_ids = [99] - snapshot_complete = { - **self._snapshot_game(app_id=2), - "unlocked_achievements": 10, - } - snapshot = [self._snapshot_game(app_id=1), snapshot_complete] - game = GameInfo.from_snapshot(self._snapshot_game()) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - ): - cmd_stats(self._config(), state) - assert any("Finished games" in s and "1" in s for s in echoed) - - def test_detail_data_complete_message_shown(self) -> None: - """'Detail data: ...' shown when all qualifying games have rush hours.""" - state = State() - echoed = self._run_cmd_stats(state) - # entry has rush_hours=15.0 > 0, so missing_rush_final == 0 and total_q == 1 - assert any("Detail data" in s for s in echoed) - - def test_note_missing_rush_shown_when_rush_absent(self) -> None: - """'Note: X games still missing...' shown when rush_hours <= 0 after fetch.""" - state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=-1.0, leisure_100h=-1.0 - ) - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_rush_data", return_value=False), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert any("still missing" in s for s in echoed) - - def test_no_detail_message_when_no_qualifying_games(self) -> None: - """Neither 'Note' nor 'Detail data' shown when qualified list is empty.""" - state = State() - snapshot = [self._snapshot_game()] - echoed: list[str] = [] - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([], 0, 0, 0), - ), - patch(f"{_PKG}._ensure_rush_data", return_value=False), - patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert not any("Detail data" in s for s in echoed) - assert not any("still missing" in s for s in echoed) - - def test_refilter_called_when_ensure_rush_data_returns_true(self) -> None: - """_filter_qualifying_games called twice when _ensure_rush_data returns True.""" - state = State() - snapshot = [self._snapshot_game()] - game = GameInfo.from_snapshot(snapshot[0]) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - filter_calls: list[int] = [] - - def count_filter( - _games: object, _state: object - ) -> tuple[list[_GameTimes], int, int, int]: - filter_calls.append(1) - return [entry], 0, 0, 0 - - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch(f"{_PKG}._filter_qualifying_games", side_effect=count_filter), - patch(f"{_PKG}._ensure_rush_data", return_value=True), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario"), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert len(filter_calls) == 2 - - def test_games_done_passed_to_pace_from_snapshot_complete(self) -> None: - """_print_pace_scenario receives is_complete count from snapshot.""" - state = State() - # Snapshot: 1 complete game (unlocked=total=10), 1 incomplete. - snapshot_complete = { - **self._snapshot_game(app_id=2), - "unlocked_achievements": 10, - } - snapshot = [self._snapshot_game(app_id=1), snapshot_complete] - game = GameInfo.from_snapshot(self._snapshot_game()) - entry = _GameTimes( - game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0 - ) - captured: dict[str, int] = {} - - def capture_pace(_state: object, _remaining: object, games_done: int) -> None: - captured["games_done"] = games_done - - with ( - patch(f"{_PKG}.load_snapshot", return_value=snapshot), - patch( - f"{_PKG}._filter_qualifying_games", - return_value=([entry], 0, 0, 0), - ), - patch(f"{_PKG}._echo"), - patch(f"{_PKG}._print_pace_scenario", side_effect=capture_pace), - patch(f"{_PKG}._print_scenario"), - patch(f"{_PKG}._print_worst_example"), - ): - cmd_stats(self._config(), state) - assert captured["games_done"] == 1 - - -class TestEnsureRushData: - """Tests for _ensure_rush_data.""" - - def _entry(self, rush: float) -> _GameTimes: - return _GameTimes( - game=_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0 - ) - - def test_empty_qualified_returns_false(self) -> None: - with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: - result = _ensure_rush_data([]) - assert result is False - mock_fetch.assert_not_called() - - def test_all_have_rush_returns_false(self) -> None: - entries = [self._entry(10.0), self._entry(5.0)] - with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch: - result = _ensure_rush_data(entries) - assert result is False - mock_fetch.assert_not_called() - - def test_missing_rush_fetches_and_returns_true(self) -> None: - entries = [self._entry(-1.0)] - with ( - patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch, - patch(f"{_PKG}._echo"), - ): - result = _ensure_rush_data(entries) - assert result is True - mock_fetch.assert_called_once() - - -class TestPrintWorstExample: - """Tests for _print_worst_example.""" - - def test_empty_list_does_nothing(self) -> None: - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([]) - assert echoed == [] - - def test_example_with_rush_and_leisure(self) -> None: - entry = _GameTimes( - game=_game(name="Portal"), - worst_hours=15.0, - rush_hours=5.0, - leisure_100h=20.0, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("Portal" in s for s in echoed) - assert any("Rush" in s for s in echoed) - assert any("Leisure" in s for s in echoed) - - def test_example_without_rush(self) -> None: - entry = _GameTimes( - game=_game(name="X"), worst_hours=15.0, rush_hours=-1.0, leisure_100h=20.0 - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert not any("Rush" in s for s in echoed) - assert any("Leisure" in s for s in echoed) - - def test_example_without_leisure(self) -> None: - entry = _GameTimes( - game=_game(name="Y"), worst_hours=15.0, rush_hours=5.0, leisure_100h=-1.0 - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("Rush" in s for s in echoed) - assert not any("Leisure" in s for s in echoed) - - def test_hltb_search_url_shown_when_no_game_id(self) -> None: - """Falls back to search URL when hltb_game_id is 0.""" - entry = _GameTimes( - game=_game(name="Portal 2"), - worst_hours=15.0, - rush_hours=-1.0, - leisure_100h=-1.0, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("howlongtobeat.com" in s and "Portal+2" in s for s in echoed) - - def test_hltb_direct_link_shown_when_game_id_known(self) -> None: - """Direct HLTB game link shown when hltb_game_id is populated.""" - entry = _GameTimes( - game=_game(name="Devil May Cry 5"), - worst_hours=186.0, - rush_hours=50.0, - leisure_100h=186.0, - hltb_game_id=57514, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([entry]) - assert any("howlongtobeat.com/game/57514" in s for s in echoed) - assert not any("?q=" in s for s in echoed) - - def test_entries_with_zero_worst_hours_excluded_from_examples(self) -> None: - """Games with worst_hours <= 0 are not selected as the example.""" - bad = _GameTimes( - game=_game(name="Skip"), worst_hours=0.0, rush_hours=-1.0, leisure_100h=-1.0 - ) - good = _GameTimes( - game=_game(name="Pick"), - worst_hours=10.0, - rush_hours=-1.0, - leisure_100h=-1.0, - ) - echoed: list[str] = [] - with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])): - _print_worst_example([bad, good]) - assert any("Pick" in s for s in echoed) - assert not any("Skip" in s for s in echoed) diff --git a/python_pkg/steam_backlog_enforcer/tests/test_steam_api.py b/python_pkg/steam_backlog_enforcer/tests/test_steam_api.py deleted file mode 100644 index d3207dc..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_steam_api.py +++ /dev/null @@ -1,333 +0,0 @@ -"""Tests for steam_api module.""" - -from __future__ import annotations - -from typing import Any -from unittest.mock import MagicMock, patch - -import pytest -import requests - -from python_pkg.steam_backlog_enforcer.steam_api import ( - AchievementInfo, - GameInfo, - SteamAPIClient, - SteamAPIError, -) - - -class TestAchievementInfo: - """Tests for AchievementInfo.""" - - def test_create(self) -> None: - a = AchievementInfo( - api_name="ACH_1", display_name="First", achieved=True, unlock_time=1000 - ) - assert a.api_name == "ACH_1" - assert a.achieved is True - - -class TestGameInfo: - """Tests for GameInfo.""" - - def test_completion_pct_zero_achievements(self) -> None: - g = GameInfo( - app_id=1, - name="G", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - assert g.completion_pct == 100.0 - - def test_completion_pct_partial(self) -> None: - g = GameInfo( - app_id=1, - name="G", - total_achievements=10, - unlocked_achievements=5, - playtime_minutes=0, - ) - assert g.completion_pct == 50.0 - - def test_is_complete_true(self) -> None: - g = GameInfo( - app_id=1, - name="G", - total_achievements=5, - unlocked_achievements=5, - playtime_minutes=0, - ) - assert g.is_complete is True - - def test_is_complete_false(self) -> None: - g = GameInfo( - app_id=1, - name="G", - total_achievements=5, - unlocked_achievements=3, - playtime_minutes=0, - ) - assert g.is_complete is False - - def test_is_complete_zero(self) -> None: - g = GameInfo( - app_id=1, - name="G", - total_achievements=0, - unlocked_achievements=0, - playtime_minutes=0, - ) - assert g.is_complete is False - - def test_to_snapshot(self) -> None: - ach = AchievementInfo( - api_name="A1", display_name="Ach1", achieved=True, unlock_time=99 - ) - g = GameInfo( - app_id=1, - name="G", - total_achievements=1, - unlocked_achievements=1, - playtime_minutes=60, - achievements=[ach], - completionist_hours=5.0, - ) - snap = g.to_snapshot() - assert snap["app_id"] == 1 - assert snap["achievements"][0]["api_name"] == "A1" - assert snap["completionist_hours"] == 5.0 - - def test_from_snapshot(self) -> None: - data: dict[str, Any] = { - "app_id": 2, - "name": "G2", - "total_achievements": 3, - "unlocked_achievements": 1, - "playtime_minutes": 120, - "completionist_hours": 10.0, - "achievements": [ - { - "api_name": "A1", - "display_name": "First", - "achieved": False, - "unlock_time": 0, - }, - ], - } - g = GameInfo.from_snapshot(data) - assert g.app_id == 2 - assert g.completionist_hours == 10.0 - assert len(g.achievements) == 1 - - def test_from_snapshot_defaults(self) -> None: - data: dict[str, Any] = { - "app_id": 3, - "name": "G3", - "total_achievements": 0, - "unlocked_achievements": 0, - } - g = GameInfo.from_snapshot(data) - assert g.playtime_minutes == 0 - assert g.completionist_hours == -1 - assert g.achievements == [] - - def test_from_snapshot_achievement_defaults(self) -> None: - data: dict[str, Any] = { - "app_id": 4, - "name": "G4", - "total_achievements": 1, - "unlocked_achievements": 0, - "achievements": [{"api_name": "X", "achieved": False}], - } - g = GameInfo.from_snapshot(data) - assert g.achievements[0].display_name == "X" - assert g.achievements[0].unlock_time == 0 - - -class TestSteamAPIClient: - """Tests for SteamAPIClient.""" - - def test_init(self) -> None: - client = SteamAPIClient("key", "id") - assert client.api_key == "key" - assert client.steam_id == "id" - - def test_rate_limit(self) -> None: - client = SteamAPIClient("key", "id") - # Should not block on first call - client._rate_limit() - - def test_rate_limit_throttle(self) -> None: - client = SteamAPIClient("key", "id") - # Fill up the rate limit window - client._request_times = [__import__("time").time()] * client._max_rps - with patch( - "python_pkg.steam_backlog_enforcer.steam_api.time.sleep", - ) as mock_sleep: - # Next call should trigger sleep then succeed - client._rate_limit() - mock_sleep.assert_called() - - def test_get_success(self) -> None: - client = SteamAPIClient("key", "id") - mock_resp = MagicMock() - mock_resp.json.return_value = {"data": "value"} - client.session.get = MagicMock(return_value=mock_resp) - result = client._get("https://example.com/api") - assert result == {"data": "value"} - - def test_get_with_params(self) -> None: - client = SteamAPIClient("key", "id") - mock_resp = MagicMock() - mock_resp.json.return_value = {"data": "value"} - client.session.get = MagicMock(return_value=mock_resp) - result = client._get("https://example.com/api", params={"foo": "bar"}) - assert result == {"data": "value"} - # Verify key was added to existing params dict - call_kwargs = client.session.get.call_args - assert call_kwargs[1]["params"]["foo"] == "bar" - assert call_kwargs[1]["params"]["key"] == "key" - - def test_get_failure(self) -> None: - client = SteamAPIClient("key", "id") - client.session.get = MagicMock(side_effect=requests.RequestException("fail")) - with pytest.raises(SteamAPIError): - client._get("https://example.com/api") - - def test_get_owned_games(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object( - client, - "_get", - return_value={"response": {"games": [{"appid": 440}]}}, - ): - games = client.get_owned_games() - assert len(games) == 1 - assert games[0]["appid"] == 440 - - def test_get_owned_games_empty(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "_get", return_value={"response": {}}): - games = client.get_owned_games() - assert games == [] - - def test_get_achievement_details(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object( - client, - "_get", - return_value={ - "playerstats": { - "success": True, - "achievements": [ - { - "apiname": "ACH_1", - "name": "First", - "achieved": 1, - "unlocktime": 1000, - }, - ], - }, - }, - ): - result = client.get_achievement_details(440) - assert len(result) == 1 - assert result[0].achieved is True - - def test_get_achievement_details_failure(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "_get", side_effect=SteamAPIError("fail")): - result = client.get_achievement_details(440) - assert result == [] - - def test_get_achievement_details_not_success(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object( - client, - "_get", - return_value={"playerstats": {"success": False}}, - ): - result = client.get_achievement_details(440) - assert result == [] - - def test_fetch_one_game(self) -> None: - client = SteamAPIClient("key", "id") - ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with patch.object(client, "get_achievement_details", return_value=[ach]): - result = client._fetch_one_game( - {"appid": 440, "name": "TF2", "playtime_forever": 60}, - ) - assert result is not None - assert result.app_id == 440 - - def test_fetch_one_game_no_achievements(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "get_achievement_details", return_value=[]): - result = client._fetch_one_game({"appid": 440}) - assert result is None - - def test_build_game_list(self) -> None: - client = SteamAPIClient("key", "id") - ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with ( - patch.object( - client, - "get_owned_games", - return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}], - ), - patch.object(client, "get_achievement_details", return_value=[ach]), - ): - progress_calls: list[tuple[int, int]] = [] - - def progress(c: int, t: int) -> None: - progress_calls.append((c, t)) - - games = client.build_game_list(progress_callback=progress) - assert len(games) == 1 - assert len(progress_calls) > 0 - - def test_build_game_list_no_achievements_excluded(self) -> None: - """Games without achievements are excluded from results.""" - client = SteamAPIClient("key", "id") - with ( - patch.object( - client, - "get_owned_games", - return_value=[{"appid": 440, "name": "TF2"}], - ), - patch.object(client, "get_achievement_details", return_value=[]), - ): - games = client.build_game_list() - assert games == [] - - def test_build_game_list_exception_in_future(self) -> None: - client = SteamAPIClient("key", "id") - with ( - patch.object( - client, - "get_owned_games", - return_value=[{"appid": 440, "name": "TF2"}], - ), - patch.object( - client, - "get_achievement_details", - side_effect=SteamAPIError("err"), - ), - ): - games = client.build_game_list() - assert games == [] - - def test_refresh_single_game(self) -> None: - client = SteamAPIClient("key", "id") - ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100) - with patch.object(client, "get_achievement_details", return_value=[ach]): - result = client.refresh_single_game(440, "TF2", 60) - assert result is not None - assert result.unlocked_achievements == 1 - - def test_refresh_single_game_no_achievements(self) -> None: - client = SteamAPIClient("key", "id") - with patch.object(client, "get_achievement_details", return_value=[]): - result = client.refresh_single_game(440, "TF2") - assert result is None diff --git a/python_pkg/steam_backlog_enforcer/tests/test_store_blocker.py b/python_pkg/steam_backlog_enforcer/tests/test_store_blocker.py deleted file mode 100644 index f87b3a4..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_store_blocker.py +++ /dev/null @@ -1,470 +0,0 @@ -"""Tests for store_blocker module.""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.store_blocker import ( - _block_store_iptables, - _block_via_hosts_install, - _is_iptables_blocked, - _unblock_store_iptables, - block_store, - is_store_blocked, - unblock_store, -) - -if TYPE_CHECKING: - from pathlib import Path - - -class TestIsStoreBlocked: - """Tests for is_store_blocked.""" - - def test_blocked_in_hosts(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text("0.0.0.0 store.steampowered.com\n", encoding="utf-8") - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", - hosts_file, - ), - ): - assert is_store_blocked() is True - - def test_commented_in_hosts(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text("# 0.0.0.0 store.steampowered.com\n", encoding="utf-8") - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", - hosts_file, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", - return_value=False, - ), - ): - assert is_store_blocked() is False - - def test_not_in_hosts_iptables_blocked(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8") - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", - hosts_file, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", - return_value=True, - ), - ): - assert is_store_blocked() is True - - def test_hosts_read_error(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "nonexistent" - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", - hosts_file, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", - return_value=False, - ), - ): - assert is_store_blocked() is False - - def test_wrong_redirect_ip(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text("127.0.0.1 store.steampowered.com\n", encoding="utf-8") - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE", - hosts_file, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked", - return_value=False, - ), - ): - assert is_store_blocked() is False - - -class TestBlockStore: - """Tests for block_store.""" - - def test_already_blocked(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=True, - ): - assert block_store() is True - - def test_reblock_succeeds(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - side_effect=[False, True], - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert block_store() is True - - def test_fallback_to_install_script(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - side_effect=[False, False], - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert block_store() is True - - def test_all_fail(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - side_effect=[False, False], - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", - return_value=False, - ), - ): - assert block_store() is False - - def test_iptables_only_succeeds(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - side_effect=[False, False], - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert block_store() is True - - -class TestBlockViaHostsInstall: - """Tests for _block_via_hosts_install.""" - - def test_already_blocked(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=True, - ): - assert _block_via_hosts_install() is True - - def test_script_missing(self, tmp_path: Path) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - tmp_path / "nonexistent.sh", - ), - ): - assert _block_via_hosts_install() is False - - def test_script_succeeds(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - mock_result = MagicMock(returncode=0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - ): - assert _block_via_hosts_install() is True - - def test_script_fails(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - mock_result = MagicMock(returncode=1, stderr="error", stdout="") - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - ): - assert _block_via_hosts_install() is False - - def test_script_fails_no_stderr(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - mock_result = MagicMock(returncode=1, stderr="", stdout="out") - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - ): - assert _block_via_hosts_install() is False - - def test_script_os_error(self, tmp_path: Path) -> None: - script = tmp_path / "install.sh" - script.touch() - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT", - script, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ), - ): - assert _block_via_hosts_install() is False - - -class TestIsIptablesBlocked: - """Tests for _is_iptables_blocked.""" - - def test_blocked(self) -> None: - mock_result = MagicMock(returncode=0, stdout="DROP blah") - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ): - assert _is_iptables_blocked() is True - - def test_not_blocked_no_drop(self) -> None: - mock_result = MagicMock(returncode=0, stdout="ACCEPT") - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ): - assert _is_iptables_blocked() is False - - def test_not_blocked_error(self) -> None: - mock_result = MagicMock(returncode=1, stdout="") - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ): - assert _is_iptables_blocked() is False - - def test_os_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ): - assert _is_iptables_blocked() is False - - -class TestBlockStoreIptables: - """Tests for _block_store_iptables.""" - - def test_success(self) -> None: - mock_result = MagicMock(returncode=0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo", - return_value=[ - (None, None, None, None, ("1.2.3.4", 443)), - ], - ), - ): - assert _block_store_iptables() is True - - def test_os_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ): - assert _block_store_iptables() is False - - def test_dns_resolution_fails(self) -> None: - import socket - - mock_result = MagicMock(returncode=0) - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - return_value=mock_result, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo", - side_effect=socket.gaierror, - ), - ): - # Should succeed even if DNS fails (just no IPs to block) - assert _block_store_iptables() is True - - def test_chain_hook_needed(self) -> None: - results = [ - MagicMock(returncode=0), # -N - MagicMock(returncode=0), # -F - MagicMock(returncode=1), # -C OUTPUT (not hooked) - MagicMock(returncode=0), # -I OUTPUT - ] - call_count = 0 - - def side_effect(*_args: object, **_kwargs: object) -> MagicMock: - nonlocal call_count - idx = min(call_count, len(results) - 1) - call_count += 1 - return results[idx] - - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=side_effect, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo", - side_effect=__import__("socket").gaierror, - ), - ): - assert _block_store_iptables() is True - - -class TestUnblockStore: - """Tests for unblock_store.""" - - def test_both_succeed(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert unblock_store() is True - - def test_iptables_fails(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts", - return_value=True, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert unblock_store() is True - - def test_both_fail(self) -> None: - with ( - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts", - return_value=False, - ), - patch( - "python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache", - ), - ): - assert unblock_store() is False - - -class TestUnblockStoreIptables: - """Tests for _unblock_store_iptables.""" - - def test_success(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - ): - assert _unblock_store_iptables() is True - - def test_os_error(self) -> None: - with patch( - "python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run", - side_effect=OSError, - ): - assert _unblock_store_iptables() is False diff --git a/python_pkg/steam_backlog_enforcer/tests/test_store_blocker_part2.py b/python_pkg/steam_backlog_enforcer/tests/test_store_blocker_part2.py deleted file mode 100644 index 67a5021..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_store_blocker_part2.py +++ /dev/null @@ -1,200 +0,0 @@ -"""Tests for store_blocker module — part 2 (missing coverage).""" - -from __future__ import annotations - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -from python_pkg.steam_backlog_enforcer.store_blocker import ( - _disable_hosts_protection, - _enable_hosts_protection, - _reblock_hosts, - _sudo_write_hosts, - _unblock_hosts, - flush_dns_cache, -) - -if TYPE_CHECKING: - from pathlib import Path - -PKG = "python_pkg.steam_backlog_enforcer.store_blocker" - - -class TestSudoWriteHosts: - """Tests for _sudo_write_hosts.""" - - def test_writes_content(self) -> None: - with patch(f"{PKG}.subprocess.run") as mock_run: - _sudo_write_hosts("127.0.0.1 localhost\n") - mock_run.assert_called_once() - assert mock_run.call_args.kwargs["input"] == b"127.0.0.1 localhost\n" - - -class TestDisableHostsProtection: - """Tests for _disable_hosts_protection.""" - - def test_stops_services_unmounts_chattr(self) -> None: - findmnt_found = MagicMock(returncode=0) - - def run_side_effect( - cmd: list[str], - **_kwargs: object, - ) -> MagicMock: - if any("findmnt" in str(c) for c in cmd): - return findmnt_found - return MagicMock(returncode=0) - - with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect): - _disable_hosts_protection() - - def test_no_bind_mount(self) -> None: - findmnt_missing = MagicMock(returncode=1) - - def run_side_effect( - cmd: list[str], - **_kwargs: object, - ) -> MagicMock: - if any("findmnt" in str(c) for c in cmd): - return findmnt_missing - return MagicMock(returncode=0) - - with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect): - _disable_hosts_protection() - - -class TestEnableHostsProtection: - """Tests for _enable_hosts_protection.""" - - def test_with_locked_copy(self, tmp_path: Path) -> None: - locked_copy = tmp_path / "locked-hosts" - locked_copy.touch() - with ( - patch(f"{PKG}.subprocess.run"), - patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy), - ): - _enable_hosts_protection() - - def test_without_locked_copy(self, tmp_path: Path) -> None: - locked_copy = tmp_path / "nonexistent" - with ( - patch(f"{PKG}.subprocess.run"), - patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy), - ): - _enable_hosts_protection() - - -class TestUnblockHosts: - """Tests for _unblock_hosts.""" - - def test_not_blocked(self) -> None: - with patch(f"{PKG}.is_store_blocked", return_value=False): - result = _unblock_hosts() - assert result is True - - def test_comments_out_entries(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text( - "127.0.0.1 localhost\n" - "0.0.0.0 store.steampowered.com\n" - "0.0.0.0 checkout.steampowered.com\n", - encoding="utf-8", - ) - with ( - patch(f"{PKG}.is_store_blocked", return_value=True), - patch(f"{PKG}.HOSTS_FILE", hosts_file), - patch(f"{PKG}._disable_hosts_protection"), - patch(f"{PKG}._enable_hosts_protection"), - patch(f"{PKG}._sudo_write_hosts") as mock_write, - ): - result = _unblock_hosts() - assert result is True - written = mock_write.call_args[0][0] - assert "# 0.0.0.0 store.steampowered.com" in written - - def test_no_change_needed(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text( - "# 0.0.0.0 store.steampowered.com\n", - encoding="utf-8", - ) - with ( - patch(f"{PKG}.is_store_blocked", return_value=True), - patch(f"{PKG}.HOSTS_FILE", hosts_file), - patch(f"{PKG}._disable_hosts_protection"), - patch(f"{PKG}._enable_hosts_protection"), - patch(f"{PKG}._sudo_write_hosts") as mock_write, - ): - result = _unblock_hosts() - assert result is True - mock_write.assert_not_called() - - def test_os_error(self) -> None: - with ( - patch(f"{PKG}.is_store_blocked", return_value=True), - patch(f"{PKG}._disable_hosts_protection", side_effect=OSError), - ): - result = _unblock_hosts() - assert result is False - - -class TestReblockHosts: - """Tests for _reblock_hosts.""" - - def test_uncomments_entries(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text( - "127.0.0.1 localhost\n" - "# 0.0.0.0 store.steampowered.com\n" - "# 0.0.0.0 checkout.steampowered.com\n", - encoding="utf-8", - ) - with ( - patch(f"{PKG}.HOSTS_FILE", hosts_file), - patch(f"{PKG}._disable_hosts_protection"), - patch(f"{PKG}._enable_hosts_protection"), - patch(f"{PKG}._sudo_write_hosts") as mock_write, - ): - result = _reblock_hosts() - assert result is True - written = mock_write.call_args[0][0] - # Should have uncommented lines - assert "0.0.0.0 store.steampowered.com" in written - assert "# 0.0.0.0 store.steampowered.com" not in written - - def test_no_change(self, tmp_path: Path) -> None: - hosts_file = tmp_path / "hosts" - hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8") - with ( - patch(f"{PKG}.HOSTS_FILE", hosts_file), - patch(f"{PKG}._disable_hosts_protection"), - patch(f"{PKG}._enable_hosts_protection"), - patch(f"{PKG}._sudo_write_hosts") as mock_write, - ): - result = _reblock_hosts() - assert result is True - mock_write.assert_not_called() - - def test_os_error(self) -> None: - with patch(f"{PKG}._disable_hosts_protection", side_effect=OSError): - result = _reblock_hosts() - assert result is False - - -class TestFlushDnsCache: - """Tests for flush_dns_cache.""" - - def test_runs_commands(self) -> None: - with patch(f"{PKG}.subprocess.run") as mock_run: - flush_dns_cache() - assert mock_run.call_count == 3 - - def test_file_not_found_suppressed(self) -> None: - with patch( - f"{PKG}.subprocess.run", - side_effect=FileNotFoundError, - ): - flush_dns_cache() - - def test_os_error_suppressed(self) -> None: - with patch(f"{PKG}.subprocess.run", side_effect=OSError): - flush_dns_cache() diff --git a/python_pkg/steam_backlog_enforcer/tests/test_whitelist.py b/python_pkg/steam_backlog_enforcer/tests/test_whitelist.py deleted file mode 100644 index b91a53a..0000000 --- a/python_pkg/steam_backlog_enforcer/tests/test_whitelist.py +++ /dev/null @@ -1,496 +0,0 @@ -"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr.""" - -from __future__ import annotations - -import time -from typing import TYPE_CHECKING -from unittest.mock import patch - -import pytest - -from python_pkg.steam_backlog_enforcer._whitelist import ( - WHITELIST_COOLDOWN_SECONDS, - _append_audit_log, - _load_approved, - _load_pending, - _save_approved, - _save_pending, - _shannon_entropy, - _try_set_immutable, - add_pending_exception, - get_approved_exception_ids, - list_pending_exceptions, - lock_enforcement_files, - promote_pending_exceptions, - unlock_for_write, - validate_reason, -) - -if TYPE_CHECKING: - from pathlib import Path - -# ────────────────────────────────────────────────────────────── -# Helpers -# ────────────────────────────────────────────────────────────── - -_VALID_REASON = "I need this game installed for a work presentation this week." - - -# ────────────────────────────────────────────────────────────── -# Shannon entropy -# ────────────────────────────────────────────────────────────── - - -class TestShannonEntropy: - def test_empty_string(self) -> None: - assert _shannon_entropy("") == 0.0 - - def test_all_whitespace(self) -> None: - assert _shannon_entropy(" ") == 0.0 - - def test_single_char(self) -> None: - # one unique char → entropy = 0 - assert _shannon_entropy("aaaa") == 0.0 - - def test_high_entropy(self) -> None: - # natural English sentence has decent entropy - assert _shannon_entropy("The quick brown fox jumps") > 3.0 - - -# ────────────────────────────────────────────────────────────── -# validate_reason -# ────────────────────────────────────────────────────────────── - - -class TestValidateReason: - def test_valid_reason_returns_none(self) -> None: - assert validate_reason(_VALID_REASON) is None - - def test_too_short(self) -> None: - err = validate_reason("short") - assert err is not None - assert "too short" in err - - def test_too_few_words(self) -> None: - # 25+ chars but only 4 words - err = validate_reason("word1 word2 word3 word4xxx") - assert err is not None - assert "words" in err - - def test_low_entropy_rejected(self) -> None: - # repeating 'ab' has low entropy - err = validate_reason("ababababababababababababababab") - assert err is not None - # could be caught by entropy or alternating-pattern check - assert err is not None - - def test_char_run_rejected(self) -> None: - err = validate_reason("I neeeeed this game to play it") - assert err is not None - assert "repeated characters" in err - - def test_alternating_pattern_rejected(self) -> None: - # "ababababab..." repeated many times - err = validate_reason("abababababababababababababababababababababab") - assert err is not None - assert "repetitive" in err or "random" in err or err is not None - - -# ────────────────────────────────────────────────────────────── -# chattr helpers -# ────────────────────────────────────────────────────────────── - - -class TestTrySetImmutable: - def test_file_does_not_exist(self, tmp_path: Path) -> None: - # Should silently do nothing when the file doesn't exist - _try_set_immutable(tmp_path / "nonexistent.txt", immutable=True) - - def test_chattr_not_available(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with patch("shutil.which", return_value=None): - _try_set_immutable(target, immutable=True) # no-op, no crash - - def test_chattr_called_set(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - fake_chattr = tmp_path / "chattr" - with ( - patch("shutil.which", return_value=str(fake_chattr)), - patch("subprocess.run") as mock_run, - ): - _try_set_immutable(target, immutable=True) - mock_run.assert_called_once() - args = mock_run.call_args[0][0] - assert "+i" in args - - def test_chattr_called_clear(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - fake_chattr = tmp_path / "chattr" - with ( - patch("shutil.which", return_value=str(fake_chattr)), - patch("subprocess.run") as mock_run, - ): - _try_set_immutable(target, immutable=False) - args = mock_run.call_args[0][0] - assert "-i" in args - - def test_oserror_swallowed(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with ( - patch("shutil.which", return_value="/usr/bin/chattr"), - patch("subprocess.run", side_effect=OSError("no permission")), - ): - _try_set_immutable(target, immutable=True) # must not raise - - def test_timeout_swallowed(self, tmp_path: Path) -> None: - import subprocess - - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with ( - patch("shutil.which", return_value="/usr/bin/chattr"), - patch( - "subprocess.run", - side_effect=subprocess.TimeoutExpired(cmd="chattr", timeout=5), - ), - ): - _try_set_immutable(target, immutable=True) # must not raise - - -class TestLockAndUnlock: - def test_lock_enforcement_files(self, tmp_path: Path) -> None: - cfg = tmp_path / "config.json" - cfg.write_text("{}", encoding="utf-8") - approved = tmp_path / "approved.json" - approved.write_text("[]", encoding="utf-8") - - with ( - patch( - "python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - approved, - ), - patch("shutil.which", return_value="/usr/bin/chattr"), - patch("subprocess.run") as mock_run, - ): - lock_enforcement_files(cfg) - assert mock_run.call_count == 2 - all_calls = [c[0][0] for c in mock_run.call_args_list] - assert all("+i" in c for c in all_calls) - - def test_unlock_for_write(self, tmp_path: Path) -> None: - target = tmp_path / "file.txt" - target.write_text("data", encoding="utf-8") - with ( - patch("shutil.which", return_value="/usr/bin/chattr"), - patch("subprocess.run") as mock_run, - ): - unlock_for_write(target) - args = mock_run.call_args[0][0] - assert "-i" in args - - -# ────────────────────────────────────────────────────────────── -# Persistence helpers (_load_pending, _save_pending, etc.) -# ────────────────────────────────────────────────────────────── - - -class TestPersistence: - def test_load_pending_missing_file(self) -> None: - assert _load_pending() == [] - - def test_load_pending_corrupt_file(self, tmp_path: Path) -> None: - bad = tmp_path / "pending.json" - bad.write_text("not json{{", encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE", - bad, - ): - assert _load_pending() == [] - - def test_load_pending_non_list(self, tmp_path: Path) -> None: - bad = tmp_path / "pending.json" - bad.write_text('{"key": "value"}', encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE", - bad, - ): - assert _load_pending() == [] - - def test_save_and_load_pending_roundtrip(self) -> None: - entries: list[dict[str, object]] = [ - {"app_id": 440, "reason": "test", "requested_at": 12345.0} - ] - _save_pending(entries) - assert _load_pending() == entries - - def test_load_approved_missing_file(self) -> None: - assert _load_approved() == [] - - def test_load_approved_corrupt_file(self, tmp_path: Path) -> None: - bad = tmp_path / "approved.json" - bad.write_text("{{broken", encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - bad, - ): - assert _load_approved() == [] - - def test_load_approved_non_list(self, tmp_path: Path) -> None: - bad = tmp_path / "approved.json" - bad.write_text('"just a string"', encoding="utf-8") - with patch( - "python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE", - bad, - ): - assert _load_approved() == [] - - def test_save_approved_roundtrip(self) -> None: - entries: list[dict[str, object]] = [ - {"app_id": 730, "reason": "cs2", "approved_at": 99999.0} - ] - with ( - patch("shutil.which", return_value=None), # skip chattr - ): - _save_approved(entries) - assert _load_approved() == entries - - -# ────────────────────────────────────────────────────────────── -# Audit log -# ────────────────────────────────────────────────────────────── - - -class TestAppendAuditLog: - def test_audit_log_written(self, tmp_path: Path) -> None: - log_file = tmp_path / "audit.log" - with patch( - "python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", - log_file, - ): - _append_audit_log(440, "some reason", "REQUESTED") - content = log_file.read_text(encoding="utf-8") - assert "REQUESTED" in content - assert "app_id=440" in content - assert "some reason" in content - - def test_audit_log_appends(self, tmp_path: Path) -> None: - log_file = tmp_path / "audit.log" - with patch( - "python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG", - log_file, - ): - _append_audit_log(440, "first", "REQUESTED") - _append_audit_log(730, "second", "APPROVED") - content = log_file.read_text(encoding="utf-8") - assert "app_id=440" in content - assert "app_id=730" in content - - -# ────────────────────────────────────────────────────────────── -# add_pending_exception -# ────────────────────────────────────────────────────────────── - - -class TestAddPendingException: - def test_add_new_exception(self) -> None: - with patch("shutil.which", return_value=None): - msg = add_pending_exception(440, _VALID_REASON) - assert "440" in msg - assert "24h" in msg or "hours" in msg or "active" in msg.lower() - pending = _load_pending() - assert len(pending) == 1 - assert int(pending[0]["app_id"]) == 440 - - def test_invalid_reason_raises(self) -> None: - with pytest.raises( - ValueError, match=r"short|words|entropy|repeated|repetitive" - ): - add_pending_exception(440, "too short") - - def test_already_approved_raises(self) -> None: - approved: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0} - ] - _save_approved(approved) - with ( - patch("shutil.which", return_value=None), - pytest.raises(ValueError, match="already in the approved"), - ): - add_pending_exception(440, _VALID_REASON) - - def test_already_pending_cooldown_remaining(self) -> None: - existing: list[dict[str, object]] = [ - { - "app_id": 440, - "reason": _VALID_REASON, - "requested_at": time.time(), # just now → full cooldown remaining - } - ] - _save_pending(existing) - with patch("shutil.which", return_value=None): - msg = add_pending_exception(440, _VALID_REASON) - assert "already pending" in msg - - def test_already_pending_cooldown_elapsed_promotes(self) -> None: - past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1 - existing: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "requested_at": past} - ] - _save_pending(existing) - with patch("shutil.which", return_value=None): - msg = add_pending_exception(440, _VALID_REASON) - # The elapsed entry is broken out of the pending-check loop via break, - # then a new entry is appended → still gets the "Will become active" msg. - assert "440" in msg - - -# ────────────────────────────────────────────────────────────── -# promote_pending_exceptions -# ────────────────────────────────────────────────────────────── - - -class TestPromotePendingExceptions: - def test_no_entries(self) -> None: - result = promote_pending_exceptions() - assert result == [] - - def test_cooldown_not_elapsed(self) -> None: - entries: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "requested_at": time.time()} - ] - _save_pending(entries) - with patch("shutil.which", return_value=None): - result = promote_pending_exceptions() - assert result == [] - # Still pending - assert len(_load_pending()) == 1 - - def test_cooldown_elapsed_promotes(self) -> None: - past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1 - entries: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "requested_at": past} - ] - _save_pending(entries) - with patch("shutil.which", return_value=None): - result = promote_pending_exceptions() - assert 440 in result - assert _load_pending() == [] - approved_ids = get_approved_exception_ids() - assert 440 in approved_ids - - def test_already_in_approved_not_duplicated(self) -> None: - """If somehow already approved, skip duplicating it.""" - past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1 - entries: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "requested_at": past} - ] - _save_pending(entries) - existing_approved: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0} - ] - _save_approved(existing_approved) - with patch("shutil.which", return_value=None): - result = promote_pending_exceptions() - # Not added again - assert 440 not in result - approved = _load_approved() - assert sum(1 for e in approved if int(e["app_id"]) == 440) == 1 - - def test_pending_list_saved_when_entries_removed(self) -> None: - """_save_pending is called when the pending list shrinks.""" - past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1 - future = time.time() - entries: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "requested_at": past}, - {"app_id": 730, "reason": _VALID_REASON, "requested_at": future}, - ] - _save_pending(entries) - with patch("shutil.which", return_value=None): - result = promote_pending_exceptions() - assert 440 in result - remaining = _load_pending() - assert len(remaining) == 1 - assert int(remaining[0]["app_id"]) == 730 - - -# ────────────────────────────────────────────────────────────── -# get_approved_exception_ids -# ────────────────────────────────────────────────────────────── - - -class TestGetApprovedExceptionIds: - def test_empty(self) -> None: - result = get_approved_exception_ids() - assert result == frozenset() - - def test_populated(self) -> None: - approved: list[dict[str, object]] = [ - {"app_id": 440, "reason": "r", "approved_at": 0.0}, - {"app_id": 730, "reason": "r", "approved_at": 0.0}, - ] - _save_approved(approved) - with patch("shutil.which", return_value=None): - pass - result = get_approved_exception_ids() - assert result == frozenset({440, 730}) - - -# ────────────────────────────────────────────────────────────── -# list_pending_exceptions -# ────────────────────────────────────────────────────────────── - - -class TestListPendingExceptions: - def test_returns_copy(self) -> None: - """Mutating the result must not affect stored state.""" - entries: list[dict[str, object]] = [ - {"app_id": 440, "reason": "r", "requested_at": 1.0} - ] - _save_pending(entries) - result = list_pending_exceptions() - result.clear() - # Still present on next load - assert len(_load_pending()) == 1 - - def test_empty_when_no_pending(self) -> None: - assert list_pending_exceptions() == [] - - -# ────────────────────────────────────────────────────────────── -# Extra coverage for validate_reason branches 94 & 106 -# ────────────────────────────────────────────────────────────── - - -class TestValidateReasonExtraBranches: - """Cover lines 94 and 106 that need multi-word, long, low-entropy inputs.""" - - def test_low_entropy_multi_word(self) -> None: - # 8 words, 31+ chars, entropy ≈ 2.0 (< 3.0), no char run, no alt pattern - err = validate_reason("the the the the the the the the") - assert err is not None - assert "entropy" in err - - def test_alternating_pattern_multi_word(self) -> None: - # "abababab" satisfies (..)(\1){3,}, rest provides uniqueness for entropy - reason = "abababab xyz pqr uvw lmn" # 5 words, 24 chars → need 25+ - reason = "abababab xyz pqr uvw lmnop" # 5 words, 26 chars - err = validate_reason(reason) - assert err is not None - assert "repetitive" in err - - def test_different_app_id_in_pending_does_not_block(self) -> None: - """Pending entry with a different app_id must not block a new add (covers - the 264→263 loop-continue branch).""" - other: list[dict[str, object]] = [ - {"app_id": 440, "reason": _VALID_REASON, "requested_at": 1.0} - ] - _save_pending(other) - with patch("shutil.which", return_value=None): - msg = add_pending_exception(730, _VALID_REASON) - assert "730" in msg - pending = _load_pending() - assert len(pending) == 2 # original + new