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