diff --git a/steam_backlog_enforcer/_cmd_done.py b/steam_backlog_enforcer/_cmd_done.py
index 5613b8c..5808d3f 100644
--- a/steam_backlog_enforcer/_cmd_done.py
+++ b/steam_backlog_enforcer/_cmd_done.py
@@ -5,6 +5,10 @@ from __future__ import annotations
import logging
from python_pkg.steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
+from python_pkg.steam_backlog_enforcer._scanning_confidence import (
+ _confidence_fail_reasons,
+ _refresh_candidate_confidence,
+)
from python_pkg.steam_backlog_enforcer.config import Config, State, load_snapshot
from python_pkg.steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
@@ -26,9 +30,7 @@ from python_pkg.steam_backlog_enforcer.hltb import (
)
from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games
from python_pkg.steam_backlog_enforcer.scanning import (
- _confidence_fail_reasons,
_pick_next_shortest_candidate,
- _refresh_candidate_confidence,
pick_next_game,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
diff --git a/steam_backlog_enforcer/_hltb_search.py b/steam_backlog_enforcer/_hltb_search.py
new file mode 100644
index 0000000..2ec5b95
--- /dev/null
+++ b/steam_backlog_enforcer/_hltb_search.py
@@ -0,0 +1,471 @@
+"""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,
+ 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()
+
+
+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": 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)
+
+
+def _build_search_variants(game_name: str) -> list[str]:
+ """Return fallback search terms for one Steam game title."""
+ base = game_name.strip()
+ variants = [base]
+ no_year = re.sub(r"\s*\(\d{4}\)$", "", base).strip()
+ if no_year and no_year != base:
+ variants.append(no_year)
+ 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
+
+
+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
+ 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, ctx.count_comp)
+
+ # 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,
+ count_comp: dict[int, int] | None = None,
+) -> list[HLTBResult]:
+ """Fetch HLTB data for a batch of games using one shared session."""
+ # 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)
+
+ 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,
+ count_comp=count_comp,
+ )
+
+ return search_results
diff --git a/steam_backlog_enforcer/_scanning_confidence.py b/steam_backlog_enforcer/_scanning_confidence.py
new file mode 100644
index 0000000..e46775e
--- /dev/null
+++ b/steam_backlog_enforcer/_scanning_confidence.py
@@ -0,0 +1,249 @@
+"""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 (
+ 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, 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, 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/steam_backlog_enforcer/hltb.py b/steam_backlog_enforcer/hltb.py
index 8b2a24b..24ed965 100644
--- a/steam_backlog_enforcer/hltb.py
+++ b/steam_backlog_enforcer/hltb.py
@@ -13,30 +13,23 @@ Fetches leisure completionist hour estimates from howlongtobeat.com with:
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_search import (
+ _fetch_batch,
+ _get_auth_info,
+ _get_hltb_search_url,
+ _search_one,
+ _SearchCtx,
)
from python_pkg.steam_backlog_enforcer._hltb_types import (
- _SAVE_INTERVAL,
- _SUBSET_SUFFIXES,
HLTB_BASE_URL,
MAX_CONCURRENT,
- MIN_SIMILARITY,
HLTBResult,
ProgressCb,
- _AuthInfo,
load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_polls_cache,
@@ -47,444 +40,8 @@ logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
-# HLTB API setup (done once, not per-request like the library)
+# Confidence-only batch fetch (no leisure/DLC detail pages)
# ──────────────────────────────────────────────────────────────
-
-
-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()
-
-
-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": 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)
-
-
-def _build_search_variants(game_name: str) -> list[str]:
- """Return fallback search terms for one Steam game title."""
- base = game_name.strip()
- variants = [base]
- no_year = re.sub(r"\s*\(\d{4}\)$", "", base).strip()
- if no_year and no_year != base:
- variants.append(no_year)
- 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
-
-
-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
- 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, ctx.count_comp)
-
- # 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,
- count_comp: dict[int, int] | None = None,
-) -> list[HLTBResult]:
- """Fetch HLTB data for a batch of games using one shared session."""
- # 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)
-
- 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,
- count_comp=count_comp,
- )
-
- return search_results
-
-
async def _fetch_batch_confidence_only(
games: list[tuple[int, str]],
cache: dict[int, float],
diff --git a/steam_backlog_enforcer/main.py b/steam_backlog_enforcer/main.py
index 3e54615..b926549 100644
--- a/steam_backlog_enforcer/main.py
+++ b/steam_backlog_enforcer/main.py
@@ -12,6 +12,7 @@ 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._whitelist import (
WHITELIST_COOLDOWN_SECONDS,
add_pending_exception,
@@ -40,6 +41,7 @@ from python_pkg.steam_backlog_enforcer.library_hider import (
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 (
@@ -355,6 +357,29 @@ def cmd_unhide(config: Config, _state: State) -> None:
_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),
@@ -371,6 +396,7 @@ COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
"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),
}
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS).
diff --git a/steam_backlog_enforcer/scanning.py b/steam_backlog_enforcer/scanning.py
index a467252..7566272 100644
--- a/steam_backlog_enforcer/scanning.py
+++ b/steam_backlog_enforcer/scanning.py
@@ -7,10 +7,13 @@ import time
from typing import Any
from python_pkg.steam_backlog_enforcer._hltb_types import (
- load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_polls_cache,
- save_hltb_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,
@@ -28,7 +31,6 @@ from python_pkg.steam_backlog_enforcer.game_install import (
uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.hltb import (
- fetch_hltb_confidence_cached,
fetch_hltb_times_cached,
)
from python_pkg.steam_backlog_enforcer.protondb import (
@@ -40,10 +42,6 @@ from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
logger = logging.getLogger(__name__)
_TAMPER_CHECK_LIMIT = 3
-_MIN_COMP_100_POLLS = 3
-_MIN_COUNT_COMP = 15
-_MIN_CONFIDENCE_SUM = 18
-
# ──────────────────────────────────────────────────────────────
# Scanning & game selection
@@ -162,220 +160,131 @@ def _pick_playable_candidate(
return None
-def pick_next_game(games: list[GameInfo], state: State, config: Config) -> None:
- """Select the next game: shortest completionist time first.
+_PICK_LIST_SIZE = 10
- Games with silver-or-worse ProtonDB ratings (or gold trending
- downward) are automatically skipped as unplayable on Linux.
- """
- skip = set(state.finished_app_ids)
- candidates = [g for g in games if not g.is_complete and g.app_id not in skip]
+_NO_CONF_MSG = (
+ "\nNo assignable games found "
+ "(HLTB confidence thresholds: comp_100 polls>=3, "
+ "count_comp>=15, sum>=18)."
+)
- if not candidates:
- _echo(
- "\nNo assignable games found "
- "(HLTB confidence thresholds: comp_100 polls>=3, "
- "count_comp>=15, sum>=18)."
- )
- state.current_app_id = None
- state.current_game_name = ""
- state.save()
- return
- # Sort: games with known HLTB time first (shortest), then unknown.
- def sort_key(g: GameInfo) -> tuple[int, float]:
- if g.completionist_hours > 0:
- return (0, g.completionist_hours)
- return (1, g.name.lower().encode().hex().__hash__())
+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__())
- 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:
- if confidence_skipped > 0 and linux_skipped == 0:
- _echo(
- "\nNo assignable games found "
- "(HLTB confidence thresholds: comp_100 polls>=3, "
- "count_comp>=15, sum>=18)."
- )
+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:
- _echo("\nNo playable games left (all have poor ProtonDB ratings)!")
- state.current_app_id = None
- state.current_game_name = ""
- state.save()
- return
+ 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
state.save()
-
- hours_str = ""
- if chosen.completionist_hours > 0:
- hours_str = f" (~{chosen.completionist_hours:.1f}h leisure+dlc)"
+ 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)
-
- # Uninstall all other games first, then auto-install the assigned one.
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,
+ chosen.app_id, chosen.name, config.steam_id, use_steam_protocol=True
)
-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 pick_next_game(games: list[GameInfo], state: State, config: Config) -> None:
+ """Present a ranked list of eligible games and let the user pick one.
-
-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.
+ 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 game.comp_100_count > 0 or game.count_comp > 0:
+ skip = set(state.finished_app_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
- _refresh_candidate_confidence_batch([game])
+ candidates.sort(key=_sort_key)
+ _apply_cached_confidence_to_candidates(candidates)
+ qualified, confidence_skipped, linux_skipped = _collect_qualified_candidates(
+ candidates
+ )
-
-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, 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, 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:
+ if not qualified:
_echo(
- f" Skipping {game.name} (AppID={game.app_id}): "
- f"HLTB confidence too low ({'; '.join(reasons)})"
+ _NO_CONF_MSG
+ if confidence_skipped > 0 and linux_skipped == 0
+ else "\nNo playable games left (all have poor ProtonDB ratings)!"
)
- return False
- return True
+ 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(
@@ -407,89 +316,32 @@ def _pick_next_shortest_candidate(
return None, confidence_skipped, linux_skipped
-def _backfill_polls_for_finished(
- state: State,
- games: list[GameInfo],
-) -> dict[int, int]:
- """Lazily fetch poll counts for already-finished games missing them.
+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.
- 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.
+ 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).
"""
- 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})")
+ 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
# ──────────────────────────────────────────────────────────────
diff --git a/steam_backlog_enforcer/tests/test_cmd_done.py b/steam_backlog_enforcer/tests/test_cmd_done.py
index 8c7435a..36230c6 100644
--- a/steam_backlog_enforcer/tests/test_cmd_done.py
+++ b/steam_backlog_enforcer/tests/test_cmd_done.py
@@ -5,7 +5,6 @@ from __future__ import annotations
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer._cmd_done import (
- _should_reassign_candidate,
_try_reassign_shorter_game,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
@@ -446,186 +445,3 @@ class TestTryReassignShorterGame:
assert not result
mock_pick.assert_not_called()
-
- def test_reassigns_when_current_hours_unknown(self) -> None:
- """If current game has unknown hours, allow a confident replacement."""
- snap = [
- _snap(app_id=1, name="Current", unlocked_achievements=5),
- _snap(
- app_id=2, name="Known", unlocked_achievements=5, completionist_hours=9.0
- ),
- ]
- state = State(current_app_id=2, current_game_name="Known")
- known_game = GameInfo(
- app_id=2,
- name="Known",
- total_achievements=10,
- unlocked_achievements=5,
- playtime_minutes=60,
- completionist_hours=9.0,
- comp_100_count=3,
- count_comp=15,
- )
- with (
- patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
- patch(
- f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
- return_value=(known_game, 0, 0),
- ),
- patch(f"{CMD_DONE_PKG}.pick_next_game"),
- patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
- patch(f"{CMD_DONE_PKG}.hide_other_games"),
- ):
- result = _try_reassign_shorter_game(
- {2: 9.0},
- 1,
- -1.0,
- state,
- Config(),
- )
-
- assert result
-
- def test_try_reassign_returns_false_when_playable_not_shorter(self) -> None:
- """_try_reassign_shorter_game should not reassign to longer candidates."""
- snap = [
- _snap(
- app_id=1,
- name="Current",
- unlocked_achievements=5,
- completionist_hours=8.0,
- comp_100_count=10,
- count_comp=40,
- ),
- _snap(
- app_id=2,
- name="Longer",
- unlocked_achievements=5,
- completionist_hours=12.0,
- comp_100_count=10,
- count_comp=40,
- ),
- ]
- longer = GameInfo(
- app_id=2,
- name="Longer",
- total_achievements=10,
- unlocked_achievements=5,
- playtime_minutes=60,
- completionist_hours=12.0,
- comp_100_count=10,
- count_comp=40,
- )
-
- with (
- patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
- patch(
- f"{CMD_DONE_PKG}.load_hltb_polls_cache",
- return_value={1: 10, 2: 10},
- ),
- patch(
- f"{CMD_DONE_PKG}.load_hltb_count_comp_cache",
- return_value={1: 40, 2: 40},
- ),
- patch(
- f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
- return_value=(longer, 0, 0),
- ),
- patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick_next,
- patch(f"{CMD_DONE_PKG}._echo"),
- ):
- result = _try_reassign_shorter_game(
- hltb_cache={1: 8.0, 2: 12.0},
- app_id=1,
- hours=8.0,
- state=State(),
- config=Config(),
- )
-
- assert not result
- mock_pick_next.assert_not_called()
-
- def test_try_reassign_stops_when_should_reassign_is_false(self) -> None:
- """Covers early return when policy says not to reassign."""
- snap = [
- _snap(
- app_id=1,
- name="Current",
- unlocked_achievements=5,
- completionist_hours=8.0,
- comp_100_count=10,
- count_comp=40,
- ),
- _snap(
- app_id=2,
- name="Candidate",
- unlocked_achievements=5,
- completionist_hours=6.0,
- comp_100_count=10,
- count_comp=40,
- ),
- ]
- candidate = GameInfo(
- app_id=2,
- name="Candidate",
- total_achievements=10,
- unlocked_achievements=5,
- playtime_minutes=60,
- completionist_hours=6.0,
- comp_100_count=10,
- count_comp=40,
- )
-
- with (
- patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
- patch(
- f"{CMD_DONE_PKG}.load_hltb_polls_cache",
- return_value={1: 10, 2: 10},
- ),
- patch(
- f"{CMD_DONE_PKG}.load_hltb_count_comp_cache",
- return_value={1: 40, 2: 40},
- ),
- patch(
- f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
- return_value=(candidate, 0, 0),
- ),
- patch(
- f"{CMD_DONE_PKG}._should_reassign_candidate",
- return_value=False,
- ),
- patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick_next,
- patch(f"{CMD_DONE_PKG}._echo"),
- ):
- result = _try_reassign_shorter_game(
- hltb_cache={1: 8.0, 2: 6.0},
- app_id=1,
- hours=8.0,
- state=State(),
- config=Config(),
- )
-
- assert not result
- mock_pick_next.assert_not_called()
-
-
-class TestShouldReassignCandidate:
- """Tests for _should_reassign_candidate."""
-
- def test_returns_false_when_candidate_not_shorter(self) -> None:
- candidate = GameInfo(
- app_id=2,
- name="Candidate",
- total_achievements=10,
- unlocked_achievements=5,
- playtime_minutes=60,
- completionist_hours=9.0,
- comp_100_count=3,
- count_comp=15,
- )
- should = _should_reassign_candidate(
- candidate,
- 8.0,
- force_reassign=False,
- )
- assert should is False
diff --git a/steam_backlog_enforcer/tests/test_cmd_done_part2.py b/steam_backlog_enforcer/tests/test_cmd_done_part2.py
new file mode 100644
index 0000000..41584a2
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_cmd_done_part2.py
@@ -0,0 +1,217 @@
+"""Tests for _cmd_done module (part 2)."""
+
+from __future__ import annotations
+
+from unittest.mock import patch
+
+from python_pkg.steam_backlog_enforcer._cmd_done import (
+ _should_reassign_candidate,
+ _try_reassign_shorter_game,
+)
+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"
+
+
+def _snap(**overrides: object) -> dict[str, object]:
+ snapshot: dict[str, object] = {
+ "app_id": 1,
+ "name": "G",
+ "total_achievements": 10,
+ "unlocked_achievements": 0,
+ "playtime_minutes": 60,
+ "completionist_hours": -1,
+ "comp_100_count": 3,
+ "count_comp": 15,
+ }
+ snapshot["app_id"] = overrides.get("app_id", 1)
+ snapshot.update(overrides)
+ return snapshot
+
+
+class TestTryReassignShorterGame2:
+ """Tests for _try_reassign_shorter_game (continued)."""
+
+ def test_reassigns_when_current_hours_unknown(self) -> None:
+ """If current game has unknown hours, allow a confident replacement."""
+ snap = [
+ _snap(app_id=1, name="Current", unlocked_achievements=5),
+ _snap(
+ app_id=2, name="Known", unlocked_achievements=5, completionist_hours=9.0
+ ),
+ ]
+ state = State(current_app_id=2, current_game_name="Known")
+ known_game = GameInfo(
+ app_id=2,
+ name="Known",
+ total_achievements=10,
+ unlocked_achievements=5,
+ playtime_minutes=60,
+ completionist_hours=9.0,
+ comp_100_count=3,
+ count_comp=15,
+ )
+ with (
+ patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
+ patch(
+ f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
+ return_value=(known_game, 0, 0),
+ ),
+ patch(f"{CMD_DONE_PKG}.pick_next_game"),
+ patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
+ patch(f"{CMD_DONE_PKG}.hide_other_games"),
+ ):
+ result = _try_reassign_shorter_game(
+ {2: 9.0},
+ 1,
+ -1.0,
+ state,
+ Config(),
+ )
+
+ assert result
+
+ def test_try_reassign_returns_false_when_playable_not_shorter(self) -> None:
+ """_try_reassign_shorter_game should not reassign to longer candidates."""
+ snap = [
+ _snap(
+ app_id=1,
+ name="Current",
+ unlocked_achievements=5,
+ completionist_hours=8.0,
+ comp_100_count=10,
+ count_comp=40,
+ ),
+ _snap(
+ app_id=2,
+ name="Longer",
+ unlocked_achievements=5,
+ completionist_hours=12.0,
+ comp_100_count=10,
+ count_comp=40,
+ ),
+ ]
+ longer = GameInfo(
+ app_id=2,
+ name="Longer",
+ total_achievements=10,
+ unlocked_achievements=5,
+ playtime_minutes=60,
+ completionist_hours=12.0,
+ comp_100_count=10,
+ count_comp=40,
+ )
+
+ with (
+ patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
+ patch(
+ f"{CMD_DONE_PKG}.load_hltb_polls_cache",
+ return_value={1: 10, 2: 10},
+ ),
+ patch(
+ f"{CMD_DONE_PKG}.load_hltb_count_comp_cache",
+ return_value={1: 40, 2: 40},
+ ),
+ patch(
+ f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
+ return_value=(longer, 0, 0),
+ ),
+ patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick_next,
+ patch(f"{CMD_DONE_PKG}._echo"),
+ ):
+ result = _try_reassign_shorter_game(
+ hltb_cache={1: 8.0, 2: 12.0},
+ app_id=1,
+ hours=8.0,
+ state=State(),
+ config=Config(),
+ )
+
+ assert not result
+ mock_pick_next.assert_not_called()
+
+ def test_try_reassign_stops_when_should_reassign_is_false(self) -> None:
+ """Covers early return when policy says not to reassign."""
+ snap = [
+ _snap(
+ app_id=1,
+ name="Current",
+ unlocked_achievements=5,
+ completionist_hours=8.0,
+ comp_100_count=10,
+ count_comp=40,
+ ),
+ _snap(
+ app_id=2,
+ name="Candidate",
+ unlocked_achievements=5,
+ completionist_hours=6.0,
+ comp_100_count=10,
+ count_comp=40,
+ ),
+ ]
+ candidate = GameInfo(
+ app_id=2,
+ name="Candidate",
+ total_achievements=10,
+ unlocked_achievements=5,
+ playtime_minutes=60,
+ completionist_hours=6.0,
+ comp_100_count=10,
+ count_comp=40,
+ )
+
+ with (
+ patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
+ patch(
+ f"{CMD_DONE_PKG}.load_hltb_polls_cache",
+ return_value={1: 10, 2: 10},
+ ),
+ patch(
+ f"{CMD_DONE_PKG}.load_hltb_count_comp_cache",
+ return_value={1: 40, 2: 40},
+ ),
+ patch(
+ f"{CMD_DONE_PKG}._pick_next_shortest_candidate",
+ return_value=(candidate, 0, 0),
+ ),
+ patch(
+ f"{CMD_DONE_PKG}._should_reassign_candidate",
+ return_value=False,
+ ),
+ patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick_next,
+ patch(f"{CMD_DONE_PKG}._echo"),
+ ):
+ result = _try_reassign_shorter_game(
+ hltb_cache={1: 8.0, 2: 6.0},
+ app_id=1,
+ hours=8.0,
+ state=State(),
+ config=Config(),
+ )
+
+ assert not result
+ mock_pick_next.assert_not_called()
+
+
+class TestShouldReassignCandidate:
+ """Tests for _should_reassign_candidate."""
+
+ def test_returns_false_when_candidate_not_shorter(self) -> None:
+ candidate = GameInfo(
+ app_id=2,
+ name="Candidate",
+ total_achievements=10,
+ unlocked_achievements=5,
+ playtime_minutes=60,
+ completionist_hours=9.0,
+ comp_100_count=3,
+ count_comp=15,
+ )
+ should = _should_reassign_candidate(
+ candidate,
+ 8.0,
+ force_reassign=False,
+ )
+ assert should is False
diff --git a/steam_backlog_enforcer/tests/test_enforce_loop.py b/steam_backlog_enforcer/tests/test_enforce_loop.py
index ba049f2..9d87682 100644
--- a/steam_backlog_enforcer/tests/test_enforce_loop.py
+++ b/steam_backlog_enforcer/tests/test_enforce_loop.py
@@ -9,12 +9,10 @@ from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer._enforce_loop import (
_enforce_auto_install,
_enforce_hide_games,
- _enforce_loop_iteration,
_enforce_setup,
_guard_installed_games,
_load_owned_app_ids_cache,
_save_owned_app_ids_cache,
- do_enforce,
get_all_owned_app_ids,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
@@ -373,185 +371,3 @@ class TestEnforceHideGames:
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)
-
-
-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/steam_backlog_enforcer/tests/test_enforce_loop_part2.py b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py
new file mode 100644
index 0000000..222c47b
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_enforce_loop_part2.py
@@ -0,0 +1,195 @@
+"""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/steam_backlog_enforcer/tests/test_game_install.py b/steam_backlog_enforcer/tests/test_game_install.py
index 58d7e7f..09354fc 100644
--- a/steam_backlog_enforcer/tests/test_game_install.py
+++ b/steam_backlog_enforcer/tests/test_game_install.py
@@ -14,11 +14,7 @@ from python_pkg.steam_backlog_enforcer.game_install import (
_ensure_steam_running,
_get_real_user,
_get_uid_gid_for_user,
- _read_install_dir,
- _remove_manifest,
_trigger_steam_install,
- get_installed_games,
- install_game,
is_game_installed,
)
@@ -282,247 +278,3 @@ class TestEnsureSteamRunning:
),
):
_ensure_steam_running()
-
-
-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/steam_backlog_enforcer/tests/test_game_install_part3.py b/steam_backlog_enforcer/tests/test_game_install_part3.py
new file mode 100644
index 0000000..b79eb4c
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_game_install_part3.py
@@ -0,0 +1,263 @@
+"""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/steam_backlog_enforcer/tests/test_hltb.py b/steam_backlog_enforcer/tests/test_hltb.py
index 959ca6f..bb87402 100644
--- a/steam_backlog_enforcer/tests/test_hltb.py
+++ b/steam_backlog_enforcer/tests/test_hltb.py
@@ -9,13 +9,15 @@ from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
-from python_pkg.steam_backlog_enforcer.hltb import (
+from python_pkg.steam_backlog_enforcer._hltb_search import (
_AuthInfo,
_build_search_payload,
- _get_auth_info,
_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,
)
@@ -77,14 +79,18 @@ class TestGetHltbSearchUrl:
def test_discovers_url(self) -> None:
mock_info = MagicMock()
mock_info.search_url = "/api/search/abc"
- with patch("python_pkg.steam_backlog_enforcer.hltb.HTMLRequests") as mock_html:
+ 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.HTMLRequests") as mock_html:
+ 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"
@@ -92,14 +98,18 @@ class TestGetHltbSearchUrl:
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.HTMLRequests") as mock_html:
+ 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.HTMLRequests") as mock_html:
+ 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"
diff --git a/steam_backlog_enforcer/tests/test_hltb_part2.py b/steam_backlog_enforcer/tests/test_hltb_part2.py
index a600baf..a3df9e8 100644
--- a/steam_backlog_enforcer/tests/test_hltb_part2.py
+++ b/steam_backlog_enforcer/tests/test_hltb_part2.py
@@ -7,10 +7,10 @@ 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,
- _AuthInfo,
_fetch_batch_confidence_only,
fetch_hltb_confidence,
fetch_hltb_confidence_cached,
diff --git a/steam_backlog_enforcer/tests/test_hltb_search.py b/steam_backlog_enforcer/tests/test_hltb_search.py
index ba4c2a6..a3777ab 100644
--- a/steam_backlog_enforcer/tests/test_hltb_search.py
+++ b/steam_backlog_enforcer/tests/test_hltb_search.py
@@ -3,26 +3,20 @@
from __future__ import annotations
import asyncio
-import json
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_detail import (
- _extract_leisure_hours,
- _parse_game_page,
-)
-from python_pkg.steam_backlog_enforcer.hltb import (
- _SAVE_INTERVAL,
- HLTBResult,
- _AuthInfo,
+from python_pkg.steam_backlog_enforcer._hltb_search import (
_fetch_batch,
- _pick_best_hltb_entry,
_search_one,
_SearchCtx,
)
+from python_pkg.steam_backlog_enforcer._hltb_types import (
+ _SAVE_INTERVAL,
+)
if TYPE_CHECKING:
from collections.abc import Callable
@@ -246,7 +240,7 @@ class TestSearchOne:
ctx.counter["done"] = _SAVE_INTERVAL - 1
with patch(
- "python_pkg.steam_backlog_enforcer.hltb.save_hltb_cache"
+ "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()
@@ -258,11 +252,11 @@ class TestFetchBatchHltb:
def test_no_auth(self) -> None:
with (
patch(
- "python_pkg.steam_backlog_enforcer.hltb._get_hltb_search_url",
+ "python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url",
return_value="https://example.com",
),
patch(
- "python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
+ "python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info",
new_callable=AsyncMock,
return_value=None,
),
@@ -273,260 +267,3 @@ class TestFetchBatchHltb:
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._get_hltb_search_url",
- return_value="https://example.com",
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
- new_callable=AsyncMock,
- return_value=auth,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._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._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._get_hltb_search_url",
- return_value="https://example.com",
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
- new_callable=AsyncMock,
- return_value=auth,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._search_one",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._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._get_hltb_search_url",
- return_value="https://example.com",
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._get_auth_info",
- new_callable=AsyncMock,
- return_value=auth,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._search_one",
- new_callable=AsyncMock,
- return_value=None,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.hltb._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
diff --git a/steam_backlog_enforcer/tests/test_hltb_search_part2.py b/steam_backlog_enforcer/tests/test_hltb_search_part2.py
new file mode 100644
index 0000000..3891a37
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_hltb_search_part2.py
@@ -0,0 +1,307 @@
+"""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 (
+ _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
diff --git a/steam_backlog_enforcer/tests/test_main_part2.py b/steam_backlog_enforcer/tests/test_main_part2.py
index f6b4b67..35efb08 100644
--- a/steam_backlog_enforcer/tests/test_main_part2.py
+++ b/steam_backlog_enforcer/tests/test_main_part2.py
@@ -2,19 +2,15 @@
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 (
_enforce_on_done,
_finalize_completion,
cmd_done,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
-from python_pkg.steam_backlog_enforcer.main import main
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
@@ -302,8 +298,6 @@ class TestEnforceOnDone:
_enforce_on_done(config, state)
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
-
-class TestCmdDone:
"""Tests for cmd_done."""
def test_no_game_assigned(self) -> None:
@@ -425,54 +419,3 @@ class TestCmdDone:
patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=True),
):
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()
diff --git a/steam_backlog_enforcer/tests/test_main_part3.py b/steam_backlog_enforcer/tests/test_main_part3.py
new file mode 100644
index 0000000..ee6a0d4
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_main_part3.py
@@ -0,0 +1,309 @@
+"""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}._try_reassign_shorter_game", return_value=False),
+ 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}._try_reassign_shorter_game", return_value=False),
+ 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}._try_reassign_shorter_game", return_value=False),
+ 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}._try_reassign_shorter_game", return_value=False),
+ 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=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: 50.0}),
+ patch(f"{CMD_DONE_PKG}._try_reassign_shorter_game", return_value=True),
+ ):
+ 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/steam_backlog_enforcer/tests/test_polls_tracking.py b/steam_backlog_enforcer/tests/test_polls_tracking.py
index e934c4d..9c2c40f 100644
--- a/steam_backlog_enforcer/tests/test_polls_tracking.py
+++ b/steam_backlog_enforcer/tests/test_polls_tracking.py
@@ -6,7 +6,7 @@ import json
from typing import TYPE_CHECKING
from unittest.mock import patch
-from python_pkg.steam_backlog_enforcer import _cmd_done, scanning
+from python_pkg.steam_backlog_enforcer import _cmd_done
from python_pkg.steam_backlog_enforcer._hltb_types import (
HLTBResult,
load_hltb_cache,
@@ -350,380 +350,3 @@ class TestReportAssignedConfidence:
_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)
-
-
-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"{_SCAN}._backfill_polls_for_finished",
- return_value={1: 1, 2: 5},
- ),
- patch(f"{_SCAN}._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"{_SCAN}._backfill_polls_for_finished", return_value={1: 4}),
- patch(f"{_SCAN}._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._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"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
- ):
- result = scanning._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"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
- ):
- scanning._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"{_SCAN}._backfill_polls_for_finished",
- return_value={1: 0, 2: 5},
- ),
- patch(f"{_SCAN}._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,
- )
- from unittest.mock import MagicMock
-
- 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"{_SCAN}._backfill_polls_for_finished",
- return_value={1: 5, 2: 5},
- ),
- patch(f"{_SCAN}._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"{_SCAN}.fetch_hltb_confidence_cached") as mock_fetch:
- scanning._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"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
- patch(f"{_SCAN}._echo"),
- ):
- scanning._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"{_SCAN}.fetch_hltb_confidence_cached", side_effect=fake_fetch
- ) as mock_fetch,
- patch(f"{_SCAN}._echo"),
- ):
- kept = scanning._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/steam_backlog_enforcer/tests/test_polls_tracking_part2.py b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py
new file mode 100644
index 0000000..34fd530
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_polls_tracking_part2.py
@@ -0,0 +1,417 @@
+"""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/steam_backlog_enforcer/tests/test_scanning.py b/steam_backlog_enforcer/tests/test_scanning.py
index 5d45d29..311e09b 100644
--- a/steam_backlog_enforcer/tests/test_scanning.py
+++ b/steam_backlog_enforcer/tests/test_scanning.py
@@ -8,12 +8,7 @@ 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 (
- _filter_hltb_confident_candidates,
- _force_refresh_candidate_confidence,
- _pick_next_shortest_candidate,
_pick_playable_candidate,
- _refresh_candidate_confidence_batch,
- do_check,
do_scan,
pick_next_game,
)
@@ -223,14 +218,12 @@ class TestPickNextGame:
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
- patch(
- "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
- ),
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,
@@ -239,6 +232,7 @@ class TestPickNextGame:
"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
@@ -270,6 +264,7 @@ class TestPickNextGame:
"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
@@ -293,14 +288,12 @@ class TestPickNextGame:
config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True)
state = State()
with (
- patch(
- "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
- ),
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,
@@ -309,6 +302,7 @@ class TestPickNextGame:
"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
@@ -318,14 +312,12 @@ class TestPickNextGame:
config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False)
state = State()
with (
- patch(
- "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
- ),
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,
@@ -333,6 +325,7 @@ class TestPickNextGame:
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()
@@ -356,6 +349,7 @@ class TestPickNextGame:
"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
@@ -379,6 +373,7 @@ class TestPickNextGame:
"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
@@ -394,9 +389,6 @@ class TestPickNextGame:
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
- patch(
- "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
- ),
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
@@ -405,6 +397,10 @@ class TestPickNextGame:
"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,
@@ -413,6 +409,7 @@ class TestPickNextGame:
"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
@@ -435,7 +432,8 @@ class TestPickNextGame:
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
- "python_pkg.steam_backlog_enforcer.scanning._force_refresh_candidate_confidence"
+ "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",
@@ -446,350 +444,3 @@ class TestPickNextGame:
assert state.current_app_id is None
mock_pick.assert_not_called()
assert any("No assignable games found" in line for line in echoed)
-
- 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._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.is_game_installed",
- return_value=True,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
- return_value=0,
- ),
- ):
- 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._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,
- ),
- ):
- 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.load_hltb_polls_cache",
- return_value={1: 1, 2: 3},
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.load_hltb_count_comp_cache",
- return_value={1: 8, 2: 20},
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning._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,
- ),
- ):
- pick_next_game([low, fallback], state, config)
-
- assert state.current_app_id == 2
- mock_refresh_batch.assert_not_called()
-
- def test_stops_after_first_confident_assignment(self) -> None:
- """Only candidates up to the winning one are checked/skipped."""
- low = _game(app_id=1, name="Low", hours=1.0)
- low.comp_100_count = 1
- low.count_comp = 2
- good = _game(app_id=2, name="Good", hours=2.0)
- good.comp_100_count = 10
- good.count_comp = 50
- never_checked = _game(app_id=3, name="NeverChecked", hours=3.0)
- never_checked.comp_100_count = 0
- never_checked.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._refresh_candidate_confidence"
- ) 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.is_game_installed",
- return_value=True,
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
- return_value=0,
- ),
- ):
- pick_next_game([low, good, never_checked], state, config)
-
- assert state.current_app_id == 2
- mock_refresh.assert_called_once_with(low)
- assert any("Skipping Low" in line for line in echoed)
- assert not any("Skipping NeverChecked" in line for line in echoed)
-
-
-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._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.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.load_hltb_cache",
- side_effect=[{30: 9.5}, {30: -1.0}],
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.load_hltb_polls_cache",
- return_value={30: 0},
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.load_hltb_count_comp_cache",
- return_value={30: 0},
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_confidence_cached",
- return_value={30: -1.0},
- ),
- patch(
- "python_pkg.steam_backlog_enforcer.scanning.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._refresh_candidate_confidence_batch",
- ),
- patch("python_pkg.steam_backlog_enforcer.scanning._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_complete(self) -> None:
- 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/steam_backlog_enforcer/tests/test_scanning_part3.py b/steam_backlog_enforcer/tests/test_scanning_part3.py
new file mode 100644
index 0000000..fd850e9
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_part3.py
@@ -0,0 +1,280 @@
+"""Tests for scanning module (part 3): TestPickNextGame continued."""
+
+from __future__ import annotations
+
+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)
diff --git a/steam_backlog_enforcer/tests/test_scanning_part4.py b/steam_backlog_enforcer/tests/test_scanning_part4.py
new file mode 100644
index 0000000..e505133
--- /dev/null
+++ b/steam_backlog_enforcer/tests/test_scanning_part4.py
@@ -0,0 +1,328 @@
+"""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)