chore: remove steam_backlog_enforcer — extracted to own repo

Moved to https://github.com/kuhyx/steam-backlog-enforcer with full git
history, rewritten imports, standalone pyproject.toml, and CI.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-28 07:27:32 +02:00
parent a7cd5ca336
commit acfb1c48a0
58 changed files with 32 additions and 16715 deletions

View File

@ -3,8 +3,11 @@
## Project Overview
A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed
components span personal productivity tools: Steam backlog management, alarm/shutdown scheduling, screen
locking, Linux system configuration, and Android phone focus enforcement.
components span personal productivity tools: alarm/shutdown scheduling, screen locking, Linux system
configuration, and Android phone focus enforcement.
Steam backlog enforcer has been extracted to its own repo:
[`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer).
Archived / unmaintained projects live in the sibling repository
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
@ -28,17 +31,6 @@ Archived / unmaintained projects live in the sibling repository
### Python Packages (`python_pkg/`)
- **steam_backlog_enforcer/** — Steam game backlog enforcer with HLTB hour tracking, game library hider,
ProtonDB compatibility checks, and installation automation
- `main.py` — entry point and enforce loop
- `enforcer.py` — core enforcement logic
- `hltb.py` / `_hltb_search.py` / `_hltb_detail.py` — HowLongToBeat API integration
- `steam_api.py` — Steam library and install API
- `library_hider.py` — hides non-current games from Steam
- `protondb.py` — ProtonDB compatibility rating lookup
- `scanning.py` — scan and select next game
- `tests/` — pytest tests; requires real Steam state; `conftest.py` redirects all paths to tmp_path
- **screen_locker/** — Tkinter/systemd screen locker with workout tracking and sick-day management
- `screen_lock.py` — main locker UI
- `_early_bird.py` — early-bird workout check
@ -156,7 +148,6 @@ before committing. The `ai-evidence-contract` hook will reject commits without i
python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100
# Run a single package
python -m pytest python_pkg/steam_backlog_enforcer/ --cov=python_pkg.steam_backlog_enforcer --cov-branch --cov-fail-under=100
python -m pytest python_pkg/screen_locker/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100
python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100
python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100
@ -235,15 +226,12 @@ For every commit that touches `.py`, `.sh`, `.c`, `.go`, `.ts`, etc.:
# Type aliases for test dicts (keeps mypy happy)
Event = dict[str, Any]
# Mock external calls — never hit real APIs/filesystem/Steam
with patch("python_pkg.steam_backlog_enforcer.main.some_func") as mock:
# Mock external calls — never hit real APIs/filesystem
with patch("python_pkg.screen_locker.screen_lock.some_func") as mock:
...
# Use PropertyMock for property exceptions
type(mock_obj).property_name = PropertyMock(side_effect=TypeError())
# steam_backlog_enforcer: conftest.py redirects ALL paths to tmp_path
# Never assume real state.json or steamapps path is safe to touch in tests
```
### Branch Coverage Tips

View File

@ -0,0 +1,25 @@
{
"intent": "Remove steam_backlog_enforcer from monorepo after extracting it to its own GitHub repo.",
"scope": [
"python_pkg/steam_backlog_enforcer/ — deleted",
"CLAUDE.md — removed steam_backlog_enforcer documentation sections",
"No other packages affected"
],
"changes": [
"Deleted python_pkg/steam_backlog_enforcer/ entirely (extracted to github.com/kuhyx/steam-backlog-enforcer)",
"Updated CLAUDE.md: removed steam_backlog_enforcer architecture section, removed its test command, updated project overview and test patterns"
],
"verification": [
{
"command": "python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100 -q",
"result": "pass",
"evidence": "902 passed, 100% branch coverage across all remaining packages"
}
],
"risks": [
"Any local scripts pointing to python_pkg/steam_backlog_enforcer/ will break — use the standalone repo instead"
],
"rollback": [
"Clone github.com/kuhyx/steam-backlog-enforcer and place it back under python_pkg/ with original imports (python_pkg.steam_backlog_enforcer.*)"
]
}

View File

@ -1,55 +0,0 @@
# Steam Backlog Enforcer
Forces you to 100% complete one Steam game at a time before moving on.
## Features
- **Achievement tracking**: Picks the next game by shortest HLTB completionist time
- **Store blocking**: Blocks `store.steampowered.com` via `/etc/hosts`
- **Game uninstalling**: Removes all installed games except the assigned one
- **Process enforcement**: Kills unauthorized game processes
- **Tampering detection**: Detects achievement unlocks on non-assigned games
- **HLTB integration**: Estimates completion time with persistent cache
## Setup
```bash
python -m python_pkg.steam_backlog_enforcer.main setup
```
## Commands
| Command | Description |
| ----------- | ------------------------------------------ |
| `scan` | Scan library, fetch HLTB data, assign game |
| `check` | Check if assigned game is complete |
| `status` | Show current assignment and blocking |
| `list` | List incomplete games from snapshot |
| `skip` | Skip the currently assigned game |
| `enforce` | Run enforcer (block, uninstall, kill) |
| `unblock` | Remove store blocking |
| `reset` | Reset all state |
| `installed` | List currently installed Steam games |
| `uninstall` | Interactively uninstall non-assigned games |
| `setup` | First-time configuration |
## Enforce mode
```bash
sudo python -m python_pkg.steam_backlog_enforcer.main enforce
```
This will:
1. Block Steam store in `/etc/hosts`
2. Uninstall all games except the assigned one
3. Continuously kill any unauthorized game processes
## Game Uninstall
Directly removes appmanifest files and game directories from `~/.local/share/Steam/steamapps/`.
Preserves Proton versions and Steam Linux Runtime.
```bash
python -m python_pkg.steam_backlog_enforcer.main uninstall
```

View File

@ -1 +0,0 @@
"""Steam Backlog Enforcer - forces you to finish your Steam games."""

View File

@ -1,328 +0,0 @@
"""Done-flow helpers and cmd_done command for Steam Backlog Enforcer."""
from __future__ import annotations
import logging
import sys
from python_pkg.steam_backlog_enforcer._enforce_loop import get_all_owned_app_ids
from python_pkg.steam_backlog_enforcer.config import Config, State, load_snapshot
from python_pkg.steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
send_notification,
)
from python_pkg.steam_backlog_enforcer.game_install import (
_echo,
install_game,
is_game_installed,
uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.hltb import (
fetch_hltb_confidence_cached,
fetch_hltb_times_cached,
load_hltb_cache,
load_hltb_polls_cache,
save_hltb_cache,
)
from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games
from python_pkg.steam_backlog_enforcer.scanning import pick_next_game
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
_REASSIGN_REFRESH_LIMIT = 50
_SKIP_DAYS = 7
logger = logging.getLogger(__name__)
def _prompt_keep_or_skip(game: GameInfo) -> bool:
"""Ask the user whether to keep the freshly-picked ``game``.
Returns ``True`` to accept the pick, ``False`` to skip it (which the
caller will translate into a 7-day skip entry on ``State``). When
stdin is not a TTY (e.g. background daemon, piped invocation), the
pick is accepted silently to preserve the legacy non-interactive
behaviour.
"""
if not sys.stdin.isatty():
return True
hours_str = ""
if game.completionist_hours > 0:
hours_str = f" (~{game.completionist_hours:.1f}h leisure+dlc)"
_echo(f"\n Next pick: {game.name} (AppID={game.app_id}){hours_str}")
while True:
try:
answer = (
input(f" Keep this game? [Y/n] (n = skip for {_SKIP_DAYS} days): ")
.strip()
.lower()
)
except EOFError:
return True
if answer in {"", "y", "yes"}:
return True
if answer in {"n", "no"}:
return False
_echo(" Please answer 'y' or 'n'.")
def _backfill_polls_for_finished(
state: State,
extra_app_id: int | None = None,
) -> dict[int, int]:
"""Lazily fetch poll counts for already-finished games missing them.
If ``extra_app_id`` is provided and its poll count is missing, it is
refreshed alongside finished games (used to populate polls for the
currently-assigned game on first run after the schema upgrade).
"""
polls_cache = load_hltb_polls_cache()
snapshot_data = load_snapshot() or []
name_by_id = {d["app_id"]: d["name"] for d in snapshot_data}
candidate_ids = list(state.finished_app_ids)
if extra_app_id is not None and polls_cache.get(extra_app_id, 0) == 0:
candidate_ids.append(extra_app_id)
missing = [
(aid, name_by_id[aid])
for aid in candidate_ids
if aid in name_by_id and polls_cache.get(aid, 0) == 0
]
if not missing:
return polls_cache
_echo(f" Backfilling HLTB poll counts for {len(missing)} game(s)...")
cache = load_hltb_cache()
preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache}
for aid, _name in missing:
cache.pop(aid, None)
save_hltb_cache(cache, polls_cache)
fetch_hltb_confidence_cached(missing)
refreshed_hours = load_hltb_cache()
refreshed_polls = load_hltb_polls_cache()
for aid, prior_hours in preserved_hours.items():
if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0:
refreshed_hours[aid] = prior_hours
save_hltb_cache(refreshed_hours, refreshed_polls)
return refreshed_polls
def _report_assigned_confidence(
app_id: int,
state: State,
) -> None:
"""Print HLTB poll-count confidence for the currently-assigned game."""
polls_cache = _backfill_polls_for_finished(state, extra_app_id=app_id)
chosen_polls = polls_cache.get(app_id, 0)
finished_polls = [
(polls_cache[aid], aid)
for aid in state.finished_app_ids
if polls_cache.get(aid, 0) > 0 and aid != app_id
]
snapshot_data = load_snapshot() or []
name_by_id = {d["app_id"]: d["name"] for d in snapshot_data}
warning = ""
if finished_polls:
min_polls = min(p for p, _ in finished_polls)
if 0 < chosen_polls < min_polls:
warning = " ⚠ NEW LOW — estimate may be unreliable"
elif chosen_polls == 0:
warning = " ⚠ no polls recorded — estimate may be unreliable"
elif chosen_polls == 0:
warning = " ⚠ no polls recorded — estimate may be unreliable"
_echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}")
if finished_polls:
min_polls, min_aid = min(finished_polls)
min_name = name_by_id.get(min_aid, f"AppID={min_aid}")
_echo(f" Historical min among finished: {min_polls} ({min_name})")
def _apply_cached_hours_to_games(
games: list[GameInfo],
hltb_cache: dict[int, float],
) -> None:
"""Overlay cached HLTB hours onto games (including cached misses)."""
for game in games:
if game.app_id in hltb_cache:
game.completionist_hours = hltb_cache[game.app_id]
def _refresh_uncached_shortlist_hours(
games: list[GameInfo],
hltb_cache: dict[int, float],
skip: set[int],
*,
upper_bound_hours: float | None = None,
) -> None:
"""Refresh likely-short uncached games to avoid stale snapshot decisions."""
shorter_uncached = [
(g.app_id, g.name)
for g in sorted(
(
game
for game in games
if not game.is_complete
and game.app_id not in skip
and game.completionist_hours > 0
and game.app_id not in hltb_cache
and (
upper_bound_hours is None
or game.completionist_hours < upper_bound_hours
)
),
key=lambda game: game.completionist_hours,
)[:_REASSIGN_REFRESH_LIMIT]
]
if shorter_uncached:
refreshed = fetch_hltb_times_cached(shorter_uncached)
hltb_cache.update(refreshed)
def _finalize_completion(
config: Config,
state: State,
game_name: str,
app_id: int,
) -> None:
"""Mark game complete, pick next, hide non-assigned games, notify."""
_echo(f"\n COMPLETED: {game_name}!")
state.finished_app_ids.append(app_id)
snapshot_data = load_snapshot()
_echo("\nPicking next game...")
if not snapshot_data:
_echo(" No snapshot found. Run 'scan' first.")
state.current_app_id = None
state.current_game_name = ""
state.save()
return
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
hltb_cache = load_hltb_cache()
skip = set(state.finished_app_ids) | state.active_skipped_ids()
_refresh_uncached_shortlist_hours(games, hltb_cache, skip)
_apply_cached_hours_to_games(games, hltb_cache)
pick_next_game(games, state, config, on_select=_prompt_keep_or_skip)
if state.current_app_id is None:
_echo(" No more games to assign!")
return
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f"\n Library: hid {hidden} games")
if not is_game_installed(state.current_app_id):
logger.info(
"Assigned game still missing after library reconciliation; "
"re-triggering install"
)
_echo(
"\n Assigned game still missing after library reconciliation; "
"re-triggering install..."
)
_echo(f"\n Auto-installing {state.current_game_name}...")
install_game(
state.current_app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
)
send_notification(
"Game Complete!",
f"Finished {game_name}! Now playing: {state.current_game_name}",
)
_echo(f"\nAll done! Go play {state.current_game_name}!")
def _enforce_on_done(config: Config, state: State) -> None:
"""Run a single enforcement pass during the 'done' command.
Kills unauthorized game processes, uninstalls unauthorized games,
and ensures the assigned game is installed.
"""
if state.current_app_id is None:
return
if config.kill_unauthorized_games:
violations = enforce_allowed_game(
state.current_app_id,
kill_unauthorized=True,
)
for pid, app_id in violations:
_echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})")
if config.uninstall_other_games:
count = uninstall_other_games(state.current_app_id)
if count:
_echo(f" Uninstalled {count} unauthorized game(s)")
if not is_game_installed(state.current_app_id):
_echo(f" Re-installing {state.current_game_name}...")
install_game(
state.current_app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
)
# Reconcile library: hide non-assigned games and unhide the assigned one.
# Without this, an interrupted earlier completion can leave the new
# assigned game hidden and stale games visible.
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f" Library: hid {hidden} games")
def cmd_done(config: Config, state: State) -> None:
"""Check completion, pick next game, uninstall & hide.
All-in-one command for after finishing a game:
1. Verify 100% achievements on Steam.
2. Pick the next game (shortest HLTB leisure+dlc time).
3. Uninstall all non-assigned games.
4. Hide all non-assigned games in the Steam library.
5. Install the newly assigned game.
"""
if state.current_app_id is None:
_echo("No game currently assigned. Run 'scan' first.")
return
client = SteamAPIClient(config.steam_api_key, config.steam_id)
game_name = state.current_game_name
app_id = state.current_app_id
_echo(f"Checking {game_name} (AppID={app_id})...")
game = client.refresh_single_game(app_id, game_name)
if game is None:
_echo(" Could not fetch achievement data from Steam.")
return
_echo(
f" Progress: {game.unlocked_achievements}/{game.total_achievements}"
f" ({game.completion_pct:.1f}%)"
)
hltb_cache = load_hltb_cache()
hours = hltb_cache.get(app_id, -1.0)
if hours < 0:
hltb_cache = fetch_hltb_times_cached([(app_id, game_name)])
hours = hltb_cache.get(app_id, -1.0)
if hours > 0:
_echo(f" HLTB leisure+dlc estimate: {hours:.1f} hours")
_report_assigned_confidence(app_id, state)
if not game.is_complete:
remaining = game.total_achievements - game.unlocked_achievements
_echo(f"\n NOT COMPLETE: {remaining} achievements remaining. Keep going!")
_enforce_on_done(config, state)
return
_finalize_completion(config, state, game_name, app_id)

View File

@ -1,329 +0,0 @@
"""Enforcement daemon loop and related helpers."""
from __future__ import annotations
import json
import logging
import time
from typing import Any
from python_pkg.steam_backlog_enforcer._whitelist import (
lock_enforcement_files,
promote_pending_exceptions,
)
from python_pkg.steam_backlog_enforcer.config import (
CONFIG_DIR,
CONFIG_FILE,
Config,
State,
_atomic_write,
load_snapshot,
)
from python_pkg.steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
send_notification,
)
from python_pkg.steam_backlog_enforcer.game_install import (
_echo,
get_installed_games,
install_game,
is_game_installed,
is_protected_app,
uninstall_game,
uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.library_hider import hide_other_games
from python_pkg.steam_backlog_enforcer.steam_api import SteamAPIClient
from python_pkg.steam_backlog_enforcer.store_blocker import block_store
logger = logging.getLogger(__name__)
_OWNED_IDS_CACHE_FILE = CONFIG_DIR / "owned_app_ids_cache.json"
_OWNED_IDS_CACHE_TTL_SECONDS = 3600
def _load_owned_app_ids_cache(steam_id: str) -> list[int] | None:
"""Return fresh cached owned app IDs for this steam_id, if available."""
if not steam_id or not _OWNED_IDS_CACHE_FILE.exists():
return None
try:
data: dict[str, Any] = json.loads(
_OWNED_IDS_CACHE_FILE.read_text(encoding="utf-8")
)
except (json.JSONDecodeError, OSError, ValueError):
return None
cached_steam_id = str(data.get("steam_id", ""))
if cached_steam_id != steam_id:
return None
fetched_at = float(data.get("fetched_at", 0.0) or 0.0)
age = time.time() - fetched_at
if age > _OWNED_IDS_CACHE_TTL_SECONDS:
return None
raw_ids = data.get("app_ids", [])
if not isinstance(raw_ids, list):
return None
return [int(app_id) for app_id in raw_ids]
def _save_owned_app_ids_cache(steam_id: str, app_ids: list[int]) -> None:
"""Persist owned app IDs cache for this steam_id."""
payload = {
"steam_id": steam_id,
"fetched_at": time.time(),
"app_ids": app_ids,
}
_atomic_write(_OWNED_IDS_CACHE_FILE, json.dumps(payload, indent=2) + "\n")
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
def get_all_owned_app_ids(config: Config) -> list[int]:
"""Get all owned game app IDs from Steam API plus snapshot fallback.
Snapshot data contains only games with achievements, so API data is the
primary source for library hiding. Snapshot IDs are merged in to keep
behavior resilient when the API result is partial.
"""
snapshot = load_snapshot() or []
snapshot_ids = [int(d["app_id"]) for d in snapshot if "app_id" in d]
cached_ids = _load_owned_app_ids_cache(config.steam_id)
if cached_ids is not None:
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*cached_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
logger.info("Using cached Steam owned IDs (%d entries).", len(cached_ids))
return merged_ids
try:
client = SteamAPIClient(config.steam_api_key, config.steam_id)
owned = client.get_owned_games()
api_ids = [int(g["appid"]) for g in owned if "appid" in g]
_save_owned_app_ids_cache(config.steam_id, api_ids)
merged_ids: list[int] = []
seen: set[int] = set()
for app_id in [*api_ids, *snapshot_ids]:
if app_id in seen:
continue
seen.add(app_id)
merged_ids.append(app_id)
except (OSError, RuntimeError, ValueError):
if snapshot_ids:
return snapshot_ids
logger.warning("Could not fetch owned game list for hiding.")
return []
else:
return merged_ids
# ──────────────────────────────────────────────────────────────
# Enforce mode (daemon loop)
# ──────────────────────────────────────────────────────────────
# How often the enforce loop runs (seconds).
ENFORCE_INTERVAL = 3
def _guard_installed_games(allowed_app_id: int | None) -> int:
"""Remove any unauthorized game manifests + files. Runs every loop.
Returns number of games removed this pass.
"""
if allowed_app_id is None:
return 0
installed = get_installed_games()
count = 0
for app_id, name in installed:
if app_id == allowed_app_id:
continue
if is_protected_app(app_id):
continue
logger.warning(
"Unauthorized game detected — removing: %s (AppID=%d)", name, app_id
)
if uninstall_game(app_id, name):
count += 1
send_notification(
"Game Removed!",
f"Uninstalled {name} (AppID={app_id}). "
f"Only the assigned game is allowed.",
)
return count
def _enforce_setup(config: Config, state: State) -> None:
"""Perform initial setup for enforcement mode.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
# Initial store block.
if config.block_store:
if block_store():
_echo(" Steam store: BLOCKED")
else:
_echo(" Steam store: FAILED (need sudo?)")
# Initial cleanup.
if config.uninstall_other_games:
_echo(" Uninstalling non-assigned games...")
count = uninstall_other_games(state.current_app_id)
_echo(f" Uninstalled {count} games")
# Auto-install the assigned game.
_enforce_auto_install(config, state)
# Hide all other games in the Steam library.
_enforce_hide_games(config, state)
def _enforce_auto_install(config: Config, state: State) -> None:
"""Auto-install the assigned game if not already installed.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
app_id = state.current_app_id
if app_id is None:
return
if not is_game_installed(app_id):
_echo(f" Auto-installing {state.current_game_name}...")
if install_game(
app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
):
send_notification(
"Game Installing",
f"{state.current_game_name} is being downloaded.",
)
else:
_echo(" Could not auto-install. Install manually from Steam.")
else:
_echo(f" Assigned game already installed: {state.current_game_name}")
def _enforce_hide_games(config: Config, state: State) -> None:
"""Hide non-assigned games in the Steam library.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f" Library: hid {hidden} games (only assigned game visible)")
else:
_echo(" Library: games already hidden")
else:
_echo(" Library hiding: skipped (no owned game list — run 'scan' first)")
def _enforce_loop_iteration(config: Config, state: State) -> None:
"""Perform one iteration of the enforcement loop.
Args:
config: Enforcer configuration.
state: Current enforcer state.
"""
if state.current_app_id is None:
return
# A) Kill unauthorized game processes.
if config.kill_unauthorized_games:
violations = enforce_allowed_game(
state.current_app_id,
kill_unauthorized=True,
)
for pid, app_id in violations:
_echo(f" Killed unauthorized game: AppID={app_id} (PID={pid})")
send_notification(
"Game Blocked!",
f"Killed unauthorized game (AppID={app_id}). "
f"Focus on {state.current_game_name}!",
)
# B) Remove any newly-installed unauthorized games.
if config.uninstall_other_games:
removed = _guard_installed_games(state.current_app_id)
if removed > 0:
_echo(f" Guard removed {removed} unauthorized game(s)")
# C) Re-install assigned game if it was somehow removed.
app_id = state.current_app_id
if app_id is not None and not is_game_installed(app_id):
logger.info(
"Assigned game disappeared — re-installing %s",
state.current_game_name,
)
install_game(
app_id,
state.current_game_name,
config.steam_id,
)
# D) Promote any cooldown-elapsed pending exceptions to approved.
newly_approved = promote_pending_exceptions()
for aid in newly_approved:
logger.info("Exception approved: AppID=%d", aid)
# E) Re-apply immutable flag so config cannot be edited without root.
lock_enforcement_files(CONFIG_FILE)
def do_enforce(config: Config, state: State) -> None:
"""Run the enforcer: block store, uninstall other games, kill processes.
This is a persistent loop that continuously:
1. Keeps the Steam store blocked.
2. Removes any newly-installed unauthorized games.
3. Auto-installs the assigned game if missing.
4. Kills any running unauthorized game processes.
"""
if state.current_app_id is None:
_echo("No game assigned. Run 'scan' first.")
return
_echo(f"Enforcing: {state.current_game_name} (AppID={state.current_app_id})")
_enforce_setup(config, state)
_echo(f" Enforce loop: ACTIVE (every {ENFORCE_INTERVAL}s)")
_echo(" Guarding: processes + installs + store")
_echo(" Press Ctrl+C to stop.\n")
try:
while True:
# Reload state from disk so CLI changes (e.g. new game
# assignment via ``done`` / ``scan``) take effect immediately
# without needing to restart the daemon.
try:
fresh = State.load()
except (json.JSONDecodeError, OSError, ValueError) as exc:
logger.warning("Failed to reload state: %s", exc)
time.sleep(ENFORCE_INTERVAL)
continue
state.current_app_id = fresh.current_app_id
state.current_game_name = fresh.current_game_name
state.finished_app_ids = fresh.finished_app_ids
_enforce_loop_iteration(config, state)
time.sleep(ENFORCE_INTERVAL)
except KeyboardInterrupt:
_echo("\nEnforcer stopped.")

View File

@ -1,354 +0,0 @@
"""Detail page parsing and leisure time / DLC fetching for HLTB."""
from __future__ import annotations
import asyncio
from http import HTTPStatus
import json
import logging
import re
from typing import Any
import aiohttp
from python_pkg.steam_backlog_enforcer._hltb_types import (
_SAVE_INTERVAL,
HLTB_BASE_URL,
MAX_CONCURRENT,
HLTBResult,
ProgressCb,
_HLTBExtras,
save_hltb_cache,
)
logger = logging.getLogger(__name__)
_NEXT_DATA_RE = re.compile(
r'<script id="__NEXT_DATA__" type="application/json">(.*?)</script>',
)
def _parse_game_page(html: str) -> dict[str, Any] | None:
"""Extract game data dict from a HLTB game page's __NEXT_DATA__."""
match = _NEXT_DATA_RE.search(html)
if not match:
return None
try:
data = json.loads(match.group(1))
result: dict[str, Any] = data["props"]["pageProps"]["game"]["data"]
except (json.JSONDecodeError, KeyError, TypeError):
return None
return result
def _as_positive_int(value: object) -> int:
"""Convert HLTB numeric JSON values to a positive int, or 0 when invalid."""
if isinstance(value, int):
return max(0, value)
if isinstance(value, float):
int_value = int(value)
return max(0, int_value)
if isinstance(value, str):
try:
int_value = int(value)
return max(0, int_value)
except ValueError:
return 0
return 0
def _platform_comp_high_candidates(game_data: dict[str, Any]) -> list[int]:
"""Collect positive ``comp_high`` values from ``platformData`` entries."""
platform_data = game_data.get("platformData", [])
if not isinstance(platform_data, list):
return []
candidates = []
for entry in platform_data:
if isinstance(entry, dict):
v = _as_positive_int(entry.get("comp_high", 0))
if v > 0:
candidates.append(v)
return candidates
def _extract_comp_100_avg_and_high(game_data: dict[str, Any]) -> tuple[float, float]:
"""Extract (average comp_100, high comp_100) from game detail data.
Returns hours as floats: (avg_hours, high_hours). Returns (-1, -1) when
insufficient data is present. The average is ``comp_100`` (seconds) from
``game[0]``; the high is ``comp_100_h``.
"""
games = game_data.get("game", [])
if not isinstance(games, list) or not games:
return -1, -1
if not isinstance(games[0], dict):
return -1, -1
base = games[0]
avg_s = _as_positive_int(base.get("comp_100", 0))
high_s = _as_positive_int(base.get("comp_100_h", 0))
avg_h = round(avg_s / 3600, 2) if avg_s > 0 else -1
high_h = round(high_s / 3600, 2) if high_s > 0 else avg_h
return avg_h, high_h
def _extract_base_leisure_hours(game_data: dict[str, Any]) -> float:
"""Extract base-game leisure hours from game detail data.
Returns the highest (slowest) time to beat across all play styles.
Candidates considered:
1. ``comp_high`` from each entry in ``platformData`` the per-platform
slowest individual submission displayed on the HLTB page.
2. The ``_h`` (leisure/high) fields from ``game[0]``:
``comp_main_h``, ``comp_plus_h``, ``comp_100_h``, ``comp_all_h``.
3. Falls back to average times: ``comp_main``, ``comp_plus``, ``comp_100``.
"""
games = game_data.get("game", [])
if not isinstance(games, list) or not games:
return -1
if not isinstance(games[0], dict):
return -1
base = games[0]
candidates = _platform_comp_high_candidates(game_data)
# 2. Leisure/high fields from the game record
for field in ("comp_main_h", "comp_plus_h", "comp_100_h", "comp_all_h"):
v = _as_positive_int(base.get(field, 0))
if v > 0:
candidates.append(v)
leisure_s = max(candidates) if candidates else 0
# 3. Fallback: average completion times
if leisure_s <= 0:
avg_candidates = [
_as_positive_int(base.get("comp_main", 0)),
_as_positive_int(base.get("comp_plus", 0)),
_as_positive_int(base.get("comp_100", 0)),
]
leisure_s = max(avg_candidates)
if leisure_s <= 0:
return -1
return round(leisure_s / 3600, 2)
def _extract_dlc_relationships(game_data: dict[str, Any]) -> list[tuple[int, float]]:
"""Extract DLC relationship IDs and fallback hours from detail data."""
relationships = game_data.get("relationships", [])
if not isinstance(relationships, list):
return []
dlcs: list[tuple[int, float]] = []
for rel in relationships:
if not isinstance(rel, dict):
continue
if str(rel.get("game_type", "")).lower() != "dlc":
continue
dlc_id = _as_positive_int(rel.get("game_id", 0))
fallback_comp_100 = _as_positive_int(rel.get("comp_100", 0))
if fallback_comp_100 > 0:
fallback_hours = round(fallback_comp_100 / 3600, 2)
else:
fallback_hours = 0.0
dlcs.append((dlc_id, fallback_hours))
return dlcs
def _extract_leisure_hours(game_data: dict[str, Any]) -> float:
"""Compute total leisure hours: base game + all DLCs.
Uses the highest (slowest) time across ``platformData comp_high`` and
leisure ``_h`` fields from ``game[0]``. Falls back to average completion
times. Also sums leisure time from any DLC listed in ``relationships``.
"""
base_hours = _extract_base_leisure_hours(game_data)
if base_hours <= 0:
return -1
total_hours = base_hours
# Add DLC leisure times from relationships.
for _dlc_id, fallback_hours in _extract_dlc_relationships(game_data):
total_hours += fallback_hours
return round(total_hours, 2)
async def _fetch_detail_one(
sem: asyncio.Semaphore,
session: aiohttp.ClientSession,
hltb_game_id: int,
) -> dict[str, Any] | None:
"""Fetch a single HLTB game detail page and parse its data."""
async with sem:
url = f"{HLTB_BASE_URL}/game/{hltb_game_id}"
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
),
"accept": "text/html",
"referer": "https://howlongtobeat.com/",
}
try:
async with session.get(url, headers=headers) as resp:
if resp.status == HTTPStatus.OK:
html = await resp.text()
return _parse_game_page(html)
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.debug(
"HLTB detail fetch failed for game_id=%d: %s",
hltb_game_id,
exc,
)
return None
def _process_game_detail(
game_data: dict[str, Any],
dlc_rels: list[tuple[int, float]],
dlc_hours_by_id: dict[int, float],
) -> tuple[float, float, float]:
"""Return (leisure_hours, rush_hours, leisure_100h) for one game's detail data."""
leisure = _extract_leisure_hours(game_data)
if leisure > 0:
leisure = _apply_dlc_leisure_overrides(leisure, dlc_rels, dlc_hours_by_id)
avg_h, high_h = _extract_comp_100_avg_and_high(game_data)
rush_h = -1.0
if avg_h > 0:
dlc_rush = sum(fh for _, fh in dlc_rels if fh > 0)
rush_h = round(avg_h + dlc_rush, 2)
l100 = -1.0
if high_h > 0:
l100 = _apply_dlc_leisure_overrides(high_h, dlc_rels, dlc_hours_by_id)
return leisure, rush_h, l100
async def _fetch_leisure_times(
search_results: list[HLTBResult],
cache: dict[int, float],
polls: dict[int, int],
progress_cb: ProgressCb | None,
extras: _HLTBExtras | None = None,
) -> None:
"""Fetch leisure times from game detail pages for all search results.
Updates ``cache`` in-place with leisure hours (including DLC time).
Also populates ``extras.rush`` (avg comp_100 + DLC) and
``extras.leisure_100h`` (comp_100_h + DLC leisure).
The ``polls`` and ``extras.count_comp`` are forwarded to
:func:`save_hltb_cache` so confidence metrics persist.
"""
if extras is None:
extras = _HLTBExtras()
valid = [r for r in search_results if r.hltb_game_id > 0]
if not valid:
return
timeout = aiohttp.ClientTimeout(total=30, sock_read=20)
sem = asyncio.Semaphore(MAX_CONCURRENT)
connector = aiohttp.TCPConnector(
limit=MAX_CONCURRENT,
keepalive_timeout=30,
)
total = len(valid)
done = 0
found = 0
async with aiohttp.ClientSession(
timeout=timeout,
connector=connector,
) as session:
coros = [_fetch_detail_one(sem, session, r.hltb_game_id) for r in valid]
details = await asyncio.gather(*coros)
dlc_relationships_by_app, dlc_ids = _collect_dlc_relationships(valid, details)
dlc_hours_by_id = await _fetch_dlc_leisure_hours(sem, session, dlc_ids)
for r, game_data in zip(valid, details, strict=False):
done += 1
if game_data is not None:
dlc_rels = dlc_relationships_by_app.get(r.app_id, [])
leisure, rush_h, l100 = _process_game_detail(
game_data, dlc_rels, dlc_hours_by_id
)
if leisure > 0:
r.completionist_hours = leisure
cache[r.app_id] = leisure
found += 1
if rush_h > 0:
extras.rush[r.app_id] = rush_h
if l100 > 0:
extras.leisure_100h[r.app_id] = l100
if progress_cb is not None:
progress_cb(done, total, found, r.game_name)
if not done % _SAVE_INTERVAL:
save_hltb_cache(cache, polls, extras)
def _collect_dlc_relationships(
valid: list[HLTBResult],
details: list[dict[str, Any] | None],
) -> tuple[dict[int, list[tuple[int, float]]], list[int]]:
"""Collect DLC relationship IDs for all base-game detail responses."""
by_app: dict[int, list[tuple[int, float]]] = {}
unique_dlc_ids: set[int] = set()
for result, game_data in zip(valid, details, strict=False):
if game_data is None:
continue
dlc_rels = _extract_dlc_relationships(game_data)
by_app[result.app_id] = dlc_rels
for dlc_id, _fallback_hours in dlc_rels:
if dlc_id > 0:
unique_dlc_ids.add(dlc_id)
return by_app, sorted(unique_dlc_ids)
async def _fetch_dlc_leisure_hours(
sem: asyncio.Semaphore,
session: aiohttp.ClientSession,
dlc_ids: list[int],
) -> dict[int, float]:
"""Fetch leisure hours for each DLC game id."""
if not dlc_ids:
return {}
coros = [_fetch_detail_one(sem, session, dlc_id) for dlc_id in dlc_ids]
dlc_details = await asyncio.gather(*coros)
dlc_hours_by_id: dict[int, float] = {}
for dlc_id, dlc_data in zip(dlc_ids, dlc_details, strict=False):
if dlc_data is None:
continue
dlc_leisure = _extract_base_leisure_hours(dlc_data)
if dlc_leisure > 0:
dlc_hours_by_id[dlc_id] = dlc_leisure
return dlc_hours_by_id
def _apply_dlc_leisure_overrides(
base_hours: float,
dlc_rels: list[tuple[int, float]],
dlc_hours_by_id: dict[int, float],
) -> float:
"""Replace fallback DLC hours with detailed leisure hours when available."""
adjusted = base_hours
for dlc_id, fallback_hours in dlc_rels:
dlc_leisure = dlc_hours_by_id.get(dlc_id, -1.0)
if dlc_leisure > 0:
adjusted += dlc_leisure - fallback_hours
return round(adjusted, 2)

View File

@ -1,552 +0,0 @@
"""Internal HLTB search helpers: URL discovery, auth, matching, and batch fetch."""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from difflib import SequenceMatcher
from http import HTTPStatus
import json
import logging
import re
import time
from typing import Any
import aiohttp
from howlongtobeatpy.HTMLRequests import HTMLRequests
from python_pkg.steam_backlog_enforcer._hltb_detail import (
_fetch_leisure_times,
)
from python_pkg.steam_backlog_enforcer._hltb_types import (
_SAVE_INTERVAL,
_SUBSET_SUFFIXES,
MAX_CONCURRENT,
MIN_SIMILARITY,
HLTBResult,
ProgressCb,
_AuthInfo,
_HLTBExtras,
save_hltb_cache,
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
# HLTB API setup (done once, not per-request like the library)
# ──────────────────────────────────────────────────────────────
def _get_hltb_search_url() -> str:
"""Discover the current HLTB search API endpoint.
Scrapes the homepage for JS bundles containing the fetch URL.
Falls back to ``/api/finder`` if extraction fails.
"""
try:
search_info = HTMLRequests.send_website_request_getcode(
parse_all_scripts=False,
)
if search_info is None:
search_info = HTMLRequests.send_website_request_getcode(
parse_all_scripts=True,
)
if search_info and search_info.search_url:
url: str = HTMLRequests.BASE_URL + search_info.search_url
return url
except (OSError, RuntimeError, ValueError, TypeError):
logger.debug("Failed to discover HLTB search URL, using default")
return "https://howlongtobeat.com/api/finder"
async def _get_auth_info(
search_url: str,
session: aiohttp.ClientSession,
) -> _AuthInfo | None:
"""Fetch the HLTB auth token and honeypot key/val (one GET request)."""
init_url = search_url + "/init"
ts = int(time.time() * 1000)
headers = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
),
"referer": "https://howlongtobeat.com/",
}
try:
async with session.get(
init_url,
params={"t": ts},
headers=headers,
) as resp:
if resp.status == HTTPStatus.OK:
data = await resp.json()
token: str | None = data.get("token")
if token is None:
return None
return _AuthInfo(
token=token,
hp_key=data.get("hpKey", ""),
hp_val=data.get("hpVal", ""),
)
except (aiohttp.ClientError, asyncio.TimeoutError):
logger.warning("Failed to get HLTB auth token")
return None
def _similarity(a: str, b: str) -> float:
"""Case-insensitive SequenceMatcher ratio between two strings."""
return SequenceMatcher(None, a.lower(), b.lower()).ratio()
_TM_RE = re.compile("[™®©\uff0a]")
_COLON_WORD_RE = re.compile(r":(\s|$)")
_STANDALONE_PUNCT_RE = re.compile(r"^[-/|]$")
_AMP_RE = re.compile(r"\s*&\s*")
def _sanitize_search_name(name: str) -> str:
"""Strip HLTB-breaking characters from a game name for searchTerms.
Removes trademark/copyright symbols, colons at word-end, standalone
punctuation tokens (dash, slash, pipe), and replaces & with 'and'.
"""
cleaned = _TM_RE.sub("", name)
cleaned = _AMP_RE.sub(" and ", cleaned)
cleaned = _COLON_WORD_RE.sub(" ", cleaned)
tokens = [t for t in cleaned.split() if not _STANDALONE_PUNCT_RE.match(t)]
return " ".join(tokens)
def _build_search_payload(game_name: str, auth: _AuthInfo | None = None) -> str:
"""Build the JSON POST body for an HLTB search."""
payload: dict[str, Any] = {
"searchType": "games",
"searchTerms": _sanitize_search_name(game_name).split(),
"searchPage": 1,
"size": 20,
"searchOptions": {
"games": {
"userId": 0,
"platform": "",
"sortCategory": "popular",
"rangeCategory": "main",
"rangeTime": {"min": 0, "max": 0},
"gameplay": {
"perspective": "",
"flow": "",
"genre": "",
"difficulty": "",
},
"rangeYear": {"max": "", "min": ""},
"modifier": "",
},
"users": {"sortCategory": "postcount"},
"lists": {"sortCategory": "follows"},
"filter": "",
"sort": 0,
"randomizer": 0,
},
"useCache": True,
}
if auth and auth.hp_key:
payload[auth.hp_key] = auth.hp_val
return json.dumps(payload)
_STEAM_SUFFIX_RE = re.compile(
r"\s+(?:\(Legacy\)|\(Classic\)|\(beta\)|\(Remastered\)|Legacy|Classic|RHCP"
r"|\(Phase\s+\d+\))\s*$",
re.IGNORECASE,
)
def _build_search_variants(game_name: str) -> list[str]:
"""Return fallback search terms for one Steam game title.
Tries progressively simplified names so HLTB search finds a result even
when the Steam title contains edition suffixes, Steam-only labels, or
subtitle decorators that HLTB does not index under.
Order matters: most-specific first, then stripped-down fallbacks.
Simplifications are chained so e.g. "Foo - Bar Edition"
"Foo - Bar" "Foo" and "Foo - Bar Edition" "Foo Edition" "Foo".
"""
base = game_name.strip()
seen: set[str] = set()
variants: list[str] = []
def _add(name: str) -> None:
s = name.strip()
if s and s not in seen:
seen.add(s)
variants.append(s)
_add(base)
# Strip Steam-only labels that HLTB never uses
no_steam = _STEAM_SUFFIX_RE.sub("", base).strip()
_add(no_steam)
# Strip trailing year "(YYYY)"
no_year = re.sub(r"\s*\(\d{4}\)$", "", base).strip()
_add(no_year)
# Strip " - subtitle" portion (e.g. "Brothers - A Tale of Two Sons" → "Brothers")
no_subtitle = re.sub(r"\s+-\s+.*$", "", base).strip()
_add(no_subtitle)
# Also strip edition from the subtitle-stripped name.
# e.g. "Rocksmith 2014 Edition - Remastered" → "Rocksmith 2014 Edition"
# → "Rocksmith 2014"
if no_subtitle != base:
_add(re.sub(r"\s+\w+\s+Edition\s*$", "", no_subtitle, flags=re.IGNORECASE))
_add(re.sub(r"\s+Edition\s*$", "", no_subtitle, flags=re.IGNORECASE))
# Strip "GOTY Edition" / "Gold Edition" / "Definitive Edition" etc. from base
no_edition = re.sub(r"\s+\w+\s+Edition\s*$", "", base, flags=re.IGNORECASE).strip()
_add(no_edition)
# Strip just " Edition" at end from base
no_bare_edition = re.sub(r"\s+Edition\s*$", "", base, flags=re.IGNORECASE).strip()
_add(no_bare_edition)
# Strip ": subtitle" portion (e.g. "Batman: Arkham Asylum" → "Batman")
no_colon_sub = re.sub(r"\s*:.*$", "", base).strip()
_add(no_colon_sub)
return variants
def _collect_candidates(
query_name: str,
data: dict[str, Any],
) -> list[tuple[dict[str, Any], float]]:
"""Build candidate list from one HLTB response payload."""
candidates: list[tuple[dict[str, Any], float]] = []
lower_name = query_name.lower()
for entry in data.get("data", []):
entry_name = entry.get("game_name", "")
entry_alias = entry.get("game_alias", "") or ""
is_dlc = str(entry.get("game_type", "")).lower() == "dlc"
sim = max(
_similarity(query_name, entry_name),
_similarity(query_name, entry_alias),
)
is_full_edition = (
(not is_dlc) and entry_name.lower().startswith(lower_name + ":")
) or ((not is_dlc) and entry_name.lower().startswith(lower_name + " -"))
if sim >= MIN_SIMILARITY or is_full_edition:
comp_100 = entry.get("comp_100", 0)
if comp_100 and comp_100 > 0:
candidates.append((entry, sim))
return candidates
def _build_result_from_best(
app_id: int,
original_name: str,
query_name: str,
best: tuple[dict[str, Any], float],
) -> HLTBResult:
"""Convert selected HLTB entry into HLTBResult."""
entry, sim = best
hours = round(entry["comp_100"] / 3600, 2)
logger.debug(
("HLTB match for '%s' via '%s': '%s' (id=%s, comp_100=%s, sim=%.3f)"),
original_name,
query_name,
entry.get("game_name"),
entry.get("game_id"),
entry.get("comp_100"),
sim,
)
return HLTBResult(
app_id=app_id,
game_name=original_name,
completionist_hours=hours,
similarity=sim,
hltb_game_id=entry.get("game_id", 0),
comp_100_count=int(entry.get("comp_100_count", 0) or 0),
count_comp=int(entry.get("count_comp", 0) or 0),
)
def _pick_best_hltb_entry(
search_name: str,
candidates: list[tuple[dict[str, Any], float]],
) -> tuple[dict[str, Any], float] | None:
"""Pick the best HLTB entry, preferring full editions over demos/chapters.
When a short name like "FAITH" matches both "FAITH" (demo) and
"FAITH: The Unholy Trinity" (full game), prefer the full game
since Steam often lists the full game under the shorter name.
When an exact match like "Timberman" (26 h) competes against an
unrelated subtitle entry like "Timberman: The Big Adventure" (2 h),
the exact match wins because it has more hours.
"""
if not candidates:
return None
# Prefer base games over DLC entries when both are present.
non_dlc = [c for c in candidates if str(c[0].get("game_type", "")).lower() != "dlc"]
usable = non_dlc or candidates
if len(usable) == 1:
return usable[0]
lower = search_name.lower()
best_exact = _find_exact_match(usable, lower)
best_extended = _find_best_extended(usable, lower)
return _resolve_exact_vs_extended(best_exact, best_extended, usable)
def _find_exact_match(
usable: list[tuple[dict[str, Any], float]],
lower: str,
) -> tuple[dict[str, Any], float] | None:
"""Find best exact name/alias match (highest comp_100)."""
return next(
(
(e, s)
for e, s in sorted(
usable,
key=lambda x: x[0].get("comp_100", 0),
reverse=True,
)
if (e.get("game_name") or "").lower() == lower
or (e.get("game_alias") or "").lower() == lower
),
None,
)
def _find_best_extended(
usable: list[tuple[dict[str, Any], float]],
lower: str,
) -> tuple[dict[str, Any], float] | None:
"""Find best extended entry ("Name: Subtitle" / "Name - Subtitle").
Skips subset entries (prologue, demo, etc.).
"""
best: tuple[dict[str, Any], float] | None = None
for entry, sim in usable:
game_type = str(entry.get("game_type", "")).lower()
if game_type not in ("", "game"):
continue
entry_name = (entry.get("game_name") or "").lower()
if entry_name.startswith((lower + ":", lower + " -")):
suffix = entry_name[len(lower) :].lstrip(" :-")
if not any(suffix.startswith(kw) for kw in _SUBSET_SUFFIXES) and (
best is None or entry.get("comp_100", 0) > best[0].get("comp_100", 0)
):
best = (entry, sim)
return best
def _resolve_exact_vs_extended(
best_exact: tuple[dict[str, Any], float] | None,
best_extended: tuple[dict[str, Any], float] | None,
usable: list[tuple[dict[str, Any], float]],
) -> tuple[dict[str, Any], float]:
"""Decide between exact match, extended entry, or highest similarity."""
if best_exact is not None and best_extended is not None:
exact_hours = best_exact[0].get("comp_100", 0)
extended_hours = best_extended[0].get("comp_100", 0)
exact_confidence = int(best_exact[0].get("comp_100_count", 0) or 0) + int(
best_exact[0].get("count_comp", 0) or 0
)
extended_confidence = int(best_extended[0].get("comp_100_count", 0) or 0) + int(
best_extended[0].get("count_comp", 0) or 0
)
# Prefer the extended entry only when it has strictly more hours
# than the exact match AND at least as much confidence.
# This lets "FAITH: The Unholy Trinity" (full game) beat
# a low-confidence exact demo while preventing low-confidence
# mods like "Celeste - Strawberry Jam" from beating
# the exact base game.
if extended_hours > exact_hours and extended_confidence >= exact_confidence:
return best_extended
return best_exact
if best_exact is not None:
return best_exact
if best_extended is not None:
return best_extended
# Fall back to highest similarity.
return max(usable, key=lambda x: x[1])
# ──────────────────────────────────────────────────────────────
# Async fetching with shared session & progress
# ──────────────────────────────────────────────────────────────
@dataclass
class _SearchCtx:
"""Shared context for HLTB search requests."""
session: aiohttp.ClientSession
search_url: str
headers: dict[str, str]
cache: dict[int, float]
polls: dict[int, int] = field(default_factory=dict)
count_comp: dict[int, int] = field(default_factory=dict)
auth: _AuthInfo | None = None
counter: dict[str, int] = field(default_factory=dict)
total: int = 0
progress_cb: ProgressCb | None = None
hltb_game_id: dict[int, int] = field(default_factory=dict)
async def _search_one(
sem: asyncio.Semaphore,
ctx: _SearchCtx,
app_id: int,
name: str,
) -> HLTBResult | None:
"""Search HLTB for one game via direct POST, update cache."""
async with sem:
result: HLTBResult | None = None
for query_name in _build_search_variants(name):
payload = _build_search_payload(query_name, ctx.auth)
try:
async with ctx.session.post(
ctx.search_url,
headers=ctx.headers,
data=payload,
) as resp:
if resp.status != HTTPStatus.OK:
continue
data = await resp.json()
candidates = _collect_candidates(query_name, data)
best = _pick_best_hltb_entry(query_name, candidates)
if best is None:
continue
result = _build_result_from_best(app_id, name, query_name, best)
break
except (aiohttp.ClientError, asyncio.TimeoutError) as exc:
logger.debug("HLTB search failed for '%s': %s", query_name, exc)
# Update cache immediately (miss = -1).
if result is not None:
ctx.cache[app_id] = result.completionist_hours
ctx.polls[app_id] = result.comp_100_count
ctx.count_comp[app_id] = result.count_comp
if result.hltb_game_id > 0:
ctx.hltb_game_id[app_id] = result.hltb_game_id
ctx.counter["found"] += 1
else:
ctx.cache[app_id] = -1
ctx.polls[app_id] = 0
ctx.count_comp[app_id] = 0
ctx.counter["done"] += 1
done = ctx.counter["done"]
# Incremental save every _SAVE_INTERVAL lookups.
if not done % _SAVE_INTERVAL:
save_hltb_cache(
ctx.cache,
ctx.polls,
_HLTBExtras(count_comp=ctx.count_comp, hltb_game_id=ctx.hltb_game_id),
)
# Report progress.
if ctx.progress_cb is not None:
ctx.progress_cb(done, ctx.total, ctx.counter["found"], name)
return result
async def _fetch_batch(
games: list[tuple[int, str]],
cache: dict[int, float],
polls: dict[int, int],
progress_cb: ProgressCb | None,
extras: _HLTBExtras | None = None,
) -> list[HLTBResult]:
"""Fetch HLTB data for a batch of games using one shared session."""
if extras is None:
extras = _HLTBExtras()
# 1. Discover the search URL (sync, one-time).
search_url = _get_hltb_search_url()
logger.info("HLTB search URL: %s", search_url)
timeout = aiohttp.ClientTimeout(total=20, sock_read=15)
# 2. Get auth info (separate session — avoids reuse issues).
async with aiohttp.ClientSession(timeout=timeout) as init_session:
auth = await _get_auth_info(search_url, init_session)
if auth is None:
logger.warning("Could not get HLTB auth info, aborting fetch.")
return []
logger.info("HLTB auth token acquired.")
# 3. Build shared headers for all search requests.
headers: dict[str, str] = {
"content-type": "application/json",
"accept": "*/*",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
),
"referer": "https://howlongtobeat.com/",
"x-auth-token": auth.token,
}
if auth.hp_key:
headers["x-hp-key"] = auth.hp_key
headers["x-hp-val"] = auth.hp_val
# 4. Fire all searches through a single persistent session.
sem = asyncio.Semaphore(MAX_CONCURRENT)
counter = {"done": 0, "found": 0}
total = len(games)
connector = aiohttp.TCPConnector(
limit=MAX_CONCURRENT,
keepalive_timeout=30,
)
async with aiohttp.ClientSession(
timeout=timeout,
connector=connector,
) as session:
ctx = _SearchCtx(
session=session,
search_url=search_url,
headers=headers,
cache=cache,
polls=polls,
count_comp=extras.count_comp,
auth=auth,
counter=counter,
total=total,
progress_cb=progress_cb,
hltb_game_id=extras.hltb_game_id,
)
tasks = [
_search_one(
sem,
ctx,
app_id,
name,
)
for app_id, name in games
]
results = await asyncio.gather(*tasks)
search_results = [r for r in results if r is not None]
# 5. Fetch leisure times + DLC from game detail pages.
logger.info(
"Fetching leisure times for %d games from detail pages...",
len(search_results),
)
await _fetch_leisure_times(
search_results,
cache,
polls,
progress_cb=None,
extras=extras,
)
return search_results

View File

@ -1,236 +0,0 @@
"""Shared types, constants, and cache I/O for the HLTB integration."""
from __future__ import annotations
from collections.abc import Callable
from dataclasses import dataclass
import json
import logging
from typing import Any
from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
logger = logging.getLogger(__name__)
HLTB_CACHE_FILE = CONFIG_DIR / "hltb_cache.json"
MAX_CONCURRENT = 60 # parallel requests to HLTB
_SAVE_INTERVAL = 50 # flush cache to disk every N results
MIN_SIMILARITY = 0.5
HLTB_BASE_URL = "https://howlongtobeat.com"
# Suffixes that indicate a subset release (prologue, demo, etc.).
# Used to avoid preferring "Game - Prologue" over "Game" when both exist.
_SUBSET_SUFFIXES = frozenset(
{
"prologue",
"demo",
"trial",
"lite",
"prelude",
}
)
# Type for progress callbacks: (done, total, found, game_name)
ProgressCb = Callable[[int, int, int, str], None]
@dataclass
class HLTBResult:
"""Result from a HowLongToBeat lookup."""
app_id: int
game_name: str
completionist_hours: float
similarity: float
hltb_game_id: int = 0
comp_100_count: int = 0
count_comp: int = 0
rush_hours: float = -1
leisure_100h: float = -1
class _HLTBExtras:
"""Mutable accumulator for HLTB data beyond the core hours cache.
Passed through the fetch pipeline so callers stay within the 5-arg limit.
"""
def __init__(
self,
count_comp: dict[int, int] | None = None,
rush: dict[int, float] | None = None,
leisure_100h: dict[int, float] | None = None,
hltb_game_id: dict[int, int] | None = None,
) -> None:
"""Initialize with optional pre-populated dicts."""
self.count_comp: dict[int, int] = count_comp if count_comp is not None else {}
self.rush: dict[int, float] = rush if rush is not None else {}
self.leisure_100h: dict[int, float] = (
leisure_100h if leisure_100h is not None else {}
)
self.hltb_game_id: dict[int, int] = (
hltb_game_id if hltb_game_id is not None else {}
)
@dataclass
class _AuthInfo:
"""HLTB API authentication details."""
token: str
hp_key: str = ""
hp_val: str = ""
def _read_raw_cache() -> dict[int, dict[str, Any]]:
"""Read the persistent HLTB cache, normalizing legacy float entries.
Cache schema on disk (current):
{
"<app_id>": {
"hours": <float>,
"polls": <int>,
"count_comp": <int>,
"rush_hours": <float>,
"leisure_100h": <float>,
"hltb_game_id": <int>
}
}
Legacy format (single float value per app) is migrated transparently.
"""
if not HLTB_CACHE_FILE.exists():
return {}
try:
data = json.loads(HLTB_CACHE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError):
logger.warning("Corrupt HLTB cache, starting fresh.")
return {}
out: dict[int, dict[str, Any]] = {}
for k, v in data.items():
try:
aid = int(k)
except (TypeError, ValueError):
continue
if isinstance(v, dict):
out[aid] = {
"hours": float(v.get("hours", -1)),
"polls": int(v.get("polls", 0)),
"count_comp": int(v.get("count_comp", 0)),
"rush_hours": float(v.get("rush_hours", -1)),
"leisure_100h": float(v.get("leisure_100h", -1)),
"hltb_game_id": int(v.get("hltb_game_id", 0)),
}
else:
try:
out[aid] = {
"hours": float(v),
"polls": 0,
"count_comp": 0,
"rush_hours": -1,
"leisure_100h": -1,
"hltb_game_id": 0,
}
except (TypeError, ValueError):
continue
return out
def load_hltb_cache() -> dict[int, float]:
"""Load the hours portion of the HLTB cache.
Returns: dict mapping app_id -> completionist_hours (-1 = no data on HLTB).
"""
return {aid: v["hours"] for aid, v in _read_raw_cache().items()}
def load_hltb_polls_cache() -> dict[int, int]:
"""Load the polled-completionist-times portion of the HLTB cache.
Returns: dict mapping app_id -> ``comp_100_count`` (0 = unknown).
"""
return {aid: v["polls"] for aid, v in _read_raw_cache().items()}
def load_hltb_count_comp_cache() -> dict[int, int]:
"""Load the ``count_comp`` portion of the HLTB cache.
Returns: dict mapping app_id -> ``count_comp`` (0 = unknown).
"""
return {aid: v["count_comp"] for aid, v in _read_raw_cache().items()}
def load_hltb_rush_cache() -> dict[int, float]:
"""Load the rush-hours (avg comp_100 + DLC) portion of the HLTB cache.
Returns: dict mapping app_id -> rush_hours (-1 = not yet computed).
"""
return {aid: v["rush_hours"] for aid, v in _read_raw_cache().items()}
def load_hltb_leisure_100h_cache() -> dict[int, float]:
"""Load the leisure-100h (comp_100_h + DLC) portion of the HLTB cache.
Returns: dict mapping app_id -> leisure_100h (-1 = not yet computed).
"""
return {aid: v["leisure_100h"] for aid, v in _read_raw_cache().items()}
def load_hltb_game_id_cache() -> dict[int, int]:
"""Load the HLTB game ID portion of the cache.
Returns: dict mapping app_id -> hltb_game_id (0 = not yet looked up).
"""
return {aid: v["hltb_game_id"] for aid, v in _read_raw_cache().items()}
def save_hltb_cache(
cache: dict[int, float],
polls: dict[int, int] | None = None,
extras: _HLTBExtras | None = None,
) -> None:
"""Save the HLTB cache to disk, including confidence and stats metrics."""
polls = polls or {}
if extras is None:
extras = _HLTBExtras()
# Preserve existing per-game data when the caller didn't populate the maps.
# A partial save (e.g. confidence-only) must not clobber rush/leisure/game-id
# data that a prior detail fetch already wrote.
needs_existing = (
not extras.hltb_game_id or not extras.rush or not extras.leisure_100h
)
if needs_existing:
existing = _read_raw_cache()
game_id_map: dict[int, int] = extras.hltb_game_id or {
aid: v["hltb_game_id"] for aid, v in existing.items()
}
rush_map: dict[int, float] = extras.rush or {
aid: v["rush_hours"] for aid, v in existing.items() if v["rush_hours"] > 0
}
leisure_map: dict[int, float] = extras.leisure_100h or {
aid: v["leisure_100h"]
for aid, v in existing.items()
if v["leisure_100h"] > 0
}
else:
game_id_map = extras.hltb_game_id
rush_map = extras.rush
leisure_map = extras.leisure_100h
out = {
str(aid): {
"hours": hours,
"polls": polls.get(aid, 0),
"count_comp": extras.count_comp.get(aid, 0),
"rush_hours": rush_map.get(aid, -1),
"leisure_100h": leisure_map.get(aid, -1),
"hltb_game_id": game_id_map.get(aid, 0),
}
for aid, hours in cache.items()
}
try:
_atomic_write(
HLTB_CACHE_FILE,
json.dumps(out, indent=2) + "\n",
)
except OSError:
logger.exception("Failed to save HLTB cache")

View File

@ -1,252 +0,0 @@
"""Confidence-checking and candidate-filtering helpers for scanning."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
from python_pkg.steam_backlog_enforcer._hltb_types import (
_HLTBExtras,
load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_polls_cache,
save_hltb_cache,
)
from python_pkg.steam_backlog_enforcer.game_install import _echo
from python_pkg.steam_backlog_enforcer.hltb import fetch_hltb_confidence_cached
if TYPE_CHECKING:
from python_pkg.steam_backlog_enforcer.config import State
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
logger = logging.getLogger(__name__)
_MIN_COMP_100_POLLS = 3
_MIN_COUNT_COMP = 15
_MIN_CONFIDENCE_SUM = 18
def _apply_cached_confidence_to_candidates(candidates: list[GameInfo]) -> None:
"""Overlay cached confidence counters onto candidate game objects."""
polls_cache = load_hltb_polls_cache()
count_comp_cache = load_hltb_count_comp_cache()
for game in candidates:
if game.app_id in polls_cache:
game.comp_100_count = polls_cache[game.app_id]
if game.app_id in count_comp_cache:
game.count_comp = count_comp_cache[game.app_id]
def _confidence_fail_reasons(game: GameInfo) -> list[str]:
"""Return threshold-failure reasons for a game's HLTB confidence data."""
reasons: list[str] = []
if game.comp_100_count < _MIN_COMP_100_POLLS:
reasons.append(f"comp_100 polls {game.comp_100_count} < {_MIN_COMP_100_POLLS}")
if game.count_comp < _MIN_COUNT_COMP:
reasons.append(f"count_comp {game.count_comp} < {_MIN_COUNT_COMP}")
total = game.comp_100_count + game.count_comp
if total < _MIN_CONFIDENCE_SUM:
reasons.append(f"comp_100+count_comp {total} < {_MIN_CONFIDENCE_SUM}")
return reasons
def _refresh_candidate_confidence(game: GameInfo) -> None:
"""Refresh confidence metrics for one candidate when cache looks stale.
Only refreshes when both metrics are missing (0), which typically means
the game was cached before confidence fields were added.
"""
if game.comp_100_count > 0 or game.count_comp > 0:
return
_refresh_candidate_confidence_batch([game])
def _force_refresh_candidate_confidence(game: GameInfo) -> None:
"""Force-refresh one candidate's confidence metrics from HLTB."""
_refresh_candidate_confidence_batch([game], force=True)
def _refresh_candidate_confidence_batch(
candidates: list[GameInfo],
*,
force: bool = False,
) -> None:
"""Refresh missing confidence metrics for candidates in one HLTB batch.
This prevents O(N) one-game API loops when many snapshot entries predate
confidence fields and therefore have ``comp_100_count==0`` and
``count_comp==0``.
"""
missing = [
game
for game in candidates
if force or (game.comp_100_count == 0 and game.count_comp == 0)
]
if not missing:
return
refresh_slice = missing
if len(refresh_slice) == 1:
game = refresh_slice[0]
_echo(f" Refreshing HLTB confidence for {game.name} (AppID={game.app_id})...")
else:
_echo(f" Refreshing HLTB confidence for {len(refresh_slice)} candidate(s)...")
cache = load_hltb_cache()
polls = load_hltb_polls_cache()
count_comp = load_hltb_count_comp_cache()
app_ids = [game.app_id for game in refresh_slice]
names = [(game.app_id, game.name) for game in refresh_slice]
prior_hours = {aid: cache.get(aid, -1) for aid in app_ids}
for aid in app_ids:
cache.pop(aid, None)
polls.pop(aid, None)
count_comp.pop(aid, None)
save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp))
fetch_hltb_confidence_cached(names)
refreshed_hours = load_hltb_cache()
refreshed_polls = load_hltb_polls_cache()
refreshed_count_comp = load_hltb_count_comp_cache()
for aid, old_hours in prior_hours.items():
if old_hours > 0 and refreshed_hours.get(aid, -1) <= 0:
refreshed_hours[aid] = old_hours
save_hltb_cache(
refreshed_hours, refreshed_polls, _HLTBExtras(count_comp=refreshed_count_comp)
)
for game in refresh_slice:
game.comp_100_count = refreshed_polls.get(game.app_id, 0)
game.count_comp = refreshed_count_comp.get(game.app_id, 0)
def _filter_hltb_confident_candidates(
candidates: list[GameInfo],
) -> list[GameInfo]:
"""Keep only candidates that satisfy HLTB confidence thresholds."""
_refresh_candidate_confidence_batch(candidates)
kept: list[GameInfo] = []
for game in candidates:
reasons = _confidence_fail_reasons(game)
if reasons:
_echo(
f" Skipping {game.name} (AppID={game.app_id}): "
f"HLTB confidence too low ({'; '.join(reasons)})"
)
continue
kept.append(game)
return kept
def _candidate_passes_hltb_confidence(game: GameInfo) -> bool:
"""Return True if candidate passes confidence with cache-first behavior.
Only refreshes when confidence fields are missing (both zero), which keeps
normal runs cache-friendly and avoids repeated refetches for known
low-confidence entries.
"""
reasons = _confidence_fail_reasons(game)
if not reasons:
return True
# Re-check once when confidence fields are missing in cache.
_refresh_candidate_confidence(game)
reasons = _confidence_fail_reasons(game)
if reasons:
_echo(
f" Skipping {game.name} (AppID={game.app_id}): "
f"HLTB confidence too low ({'; '.join(reasons)})"
)
return False
return True
def _backfill_polls_for_finished(
state: State,
games: list[GameInfo],
) -> dict[int, int]:
"""Lazily fetch poll counts for already-finished games missing them.
Reads the polls cache, identifies finished games whose poll count is
still ``0`` (typically because the cache predates the polls schema),
and triggers a one-shot HLTB search to backfill them. Returns the
refreshed polls cache.
"""
polls_cache = load_hltb_polls_cache()
name_by_id = {g.app_id: g.name for g in games}
missing = [
(aid, name_by_id[aid])
for aid in state.finished_app_ids
if aid in name_by_id and polls_cache.get(aid, 0) == 0
]
if not missing:
return polls_cache
logger.info(
"Backfilling HLTB poll counts for %d already-finished games...",
len(missing),
)
# Force a fresh search by removing the hours entries we want to refetch.
# (fetch_hltb_times_cached skips entries already in the hours cache.)
cache = load_hltb_cache()
preserved_hours = {aid: cache[aid] for aid, _ in missing if aid in cache}
for aid, _name in missing:
cache.pop(aid, None)
save_hltb_cache(cache, polls_cache)
fetch_hltb_confidence_cached(missing)
# Restore any previously-known hours that the refetch may have replaced
# with a worse match (we trust prior leisure+dlc estimates).
refreshed_hours = load_hltb_cache()
refreshed_polls = load_hltb_polls_cache()
for aid, prior_hours in preserved_hours.items():
if prior_hours > 0 and refreshed_hours.get(aid, -1) <= 0:
refreshed_hours[aid] = prior_hours
save_hltb_cache(refreshed_hours, refreshed_polls)
return refreshed_polls
def _report_poll_confidence(
chosen: GameInfo,
games: list[GameInfo],
state: State,
) -> None:
"""Print HLTB poll-count confidence info for the just-assigned game.
Shows the chosen game's ``comp_100_count`` (number of polled
completionist times on HowLongToBeat) and the historical minimum
among the user's previously-finished games. Marks a new historical
low so the user can be skeptical of unreliable estimates.
"""
polls_cache = _backfill_polls_for_finished(state, games)
chosen_polls = polls_cache.get(chosen.app_id, chosen.comp_100_count)
chosen.comp_100_count = chosen_polls
finished_polls = [
(polls_cache[aid], aid)
for aid in state.finished_app_ids
if polls_cache.get(aid, 0) > 0
]
if not finished_polls:
_echo(f" HLTB confidence: {chosen_polls} polled completionist times")
return
min_polls, min_aid = min(finished_polls)
name_by_id = {g.app_id: g.name for g in games}
min_name = name_by_id.get(min_aid, f"AppID={min_aid}")
warning = ""
if 0 < chosen_polls < min_polls:
warning = " ⚠ NEW LOW — estimate may be unreliable"
elif chosen_polls == 0:
warning = " ⚠ no polls recorded — estimate may be unreliable"
_echo(f" HLTB confidence: {chosen_polls} polled completionist times{warning}")
_echo(f" Historical min among finished: {min_polls} ({min_name})")

View File

@ -1,350 +0,0 @@
"""Backlog completion-time statistics for Steam Backlog Enforcer."""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import logging
import secrets
from typing import TYPE_CHECKING
from urllib.parse import quote_plus
from python_pkg.steam_backlog_enforcer._hltb_types import (
HLTB_BASE_URL,
load_hltb_cache,
load_hltb_game_id_cache,
load_hltb_leisure_100h_cache,
load_hltb_rush_cache,
)
from python_pkg.steam_backlog_enforcer._scanning_confidence import (
_apply_cached_confidence_to_candidates,
_confidence_fail_reasons,
_refresh_candidate_confidence_batch,
)
from python_pkg.steam_backlog_enforcer.config import load_snapshot
from python_pkg.steam_backlog_enforcer.game_install import _echo
from python_pkg.steam_backlog_enforcer.hltb import fetch_hltb_detail_missing
from python_pkg.steam_backlog_enforcer.protondb import (
ProtonDBRating,
fetch_protondb_ratings,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
if TYPE_CHECKING:
from python_pkg.steam_backlog_enforcer.config import Config, State
logger = logging.getLogger(__name__)
_HOURS_PER_DAY_PRESETS = (2.0, 4.0, 6.0, 8.0)
_LINE = "" * 70
_HLTB_SEARCH_BASE = "https://howlongtobeat.com/?q="
@dataclass
class _GameTimes:
"""Per-game time estimates for stats display."""
game: GameInfo
worst_hours: float
rush_hours: float
leisure_100h: float
hltb_game_id: int = field(default=0)
def _filter_qualifying_games(
games: list[GameInfo],
state: State,
) -> tuple[list[_GameTimes], int, int, int]:
"""Return qualifying incomplete games with their time estimates.
Applies the same HLTB-confidence and Linux-compatibility filters as the
game picker. The current game and already-finished games are excluded.
Returns:
(qualified_list, hltb_skipped, linux_skipped, no_data_skipped)
"""
rush_cache = load_hltb_rush_cache()
leisure_100h_cache = load_hltb_leisure_100h_cache()
game_id_cache = load_hltb_game_id_cache()
hours_cache = load_hltb_cache()
exclude = set(state.finished_app_ids)
if state.current_app_id is not None:
exclude.add(state.current_app_id)
candidates = [g for g in games if not g.is_complete and g.app_id not in exclude]
_apply_cached_confidence_to_candidates(candidates)
_refresh_candidate_confidence_batch(candidates)
hltb_skipped = 0
linux_skipped = 0
no_data_skipped = 0
app_ids_to_check: list[int] = []
conf_ok: list[GameInfo] = []
for game in candidates:
if _confidence_fail_reasons(game):
hltb_skipped += 1
continue
conf_ok.append(game)
app_ids_to_check.append(game.app_id)
ratings: dict[int, ProtonDBRating] = {}
if app_ids_to_check:
ratings = fetch_protondb_ratings(app_ids_to_check)
qualified: list[_GameTimes] = []
for game in conf_ok:
rating = ratings.get(game.app_id, ProtonDBRating(app_id=game.app_id))
if not rating.is_playable:
linux_skipped += 1
continue
rush = rush_cache.get(game.app_id, -1)
leisure = leisure_100h_cache.get(game.app_id, -1)
# worst_hours = max of: snapshot completionist, HLTB hours cache (fallback
# when snapshot is stale/missing), and leisure_100h (slowest 100% time).
snap_hours = game.completionist_hours if game.completionist_hours > 0 else -1
cache_hours = hours_cache.get(game.app_id, -1)
worst_candidates = [v for v in (snap_hours, cache_hours, leisure) if v > 0]
worst = max(worst_candidates) if worst_candidates else -1
if worst <= 0 and rush <= 0 and leisure <= 0:
no_data_skipped += 1
continue
qualified.append(
_GameTimes(
game=game,
worst_hours=worst,
rush_hours=rush,
leisure_100h=leisure,
hltb_game_id=game_id_cache.get(game.app_id, 0),
)
)
return qualified, hltb_skipped, linux_skipped, no_data_skipped
def _ensure_rush_data(qualified: list[_GameTimes]) -> bool:
"""Auto-fetch rush/leisure detail for games that are missing it.
Returns True when a fetch was performed; the caller should then re-run
``_filter_qualifying_games`` to pick up the updated caches.
"""
total_q = len(qualified)
missing = sum(1 for e in qualified if e.rush_hours <= 0)
if not qualified or not missing:
return False
_echo(f"Fetching HLTB detail for {missing}/{total_q} games missing rush/leisure...")
game_pairs = [(e.game.app_id, e.game.name) for e in qualified]
fetch_hltb_detail_missing(game_pairs)
return True
def _print_worst_example(entries: list[_GameTimes]) -> None:
"""Print a randomly selected example from the worst-case qualified games."""
examples = [e for e in entries if e.worst_hours > 0]
if not examples:
return
example = secrets.choice(examples)
_echo(f"\n Example game: {example.game.name!r}")
_echo(f" Worst case: {example.worst_hours:.1f} h")
if example.rush_hours > 0:
_echo(f" Rush: {example.rush_hours:.1f} h")
if example.leisure_100h > 0:
_echo(f" Leisure: {example.leisure_100h:.1f} h")
if example.hltb_game_id > 0:
_echo(f" HLTB: {HLTB_BASE_URL}/game/{example.hltb_game_id}")
else:
_echo(f" HLTB: {_HLTB_SEARCH_BASE}{quote_plus(example.game.name)}")
def _sum_hours(entries: list[_GameTimes], attr: str) -> tuple[float, int]:
"""Sum a time attribute across entries; return (total_hours, missing_count).
Games where the attribute is 0 contribute 0 to the sum and are counted
in ``missing_count`` so the user knows the estimate may be an undercount.
"""
total = 0.0
missing = 0
for e in entries:
val: float = getattr(e, attr)
if val > 0:
total += val
else:
missing += 1
return round(total, 1), missing
def _format_completion_date(hours: float, daily_hours: float) -> str:
"""Return 'N days (YYYY-MM-DD)' for finishing hours at daily_hours per day."""
if hours <= 0 or daily_hours <= 0:
return "N/A"
days = int(hours / daily_hours)
target = datetime.now(timezone.utc) + timedelta(days=days)
return f"{days} days ({target.strftime('%Y-%m-%d')})"
def _print_scenario(
label: str,
total_hours: float,
missing: int,
total_games: int,
) -> None:
"""Print a single time-scenario block."""
_echo(f"\n {label}")
if total_hours <= 0:
_echo(" No data available.")
return
missing_note = (
f" ({missing}/{total_games} games had no data, hours underestimated)"
if missing
else ""
)
_echo(f" Total: {total_hours:,.1f} h{missing_note}")
for daily in _HOURS_PER_DAY_PRESETS:
estimate = _format_completion_date(total_hours, daily)
_echo(f" @ {daily:.0f} h/day → {estimate}")
def _print_pace_scenario(state: State, remaining: int, games_done: int) -> None:
"""Print the pace-based completion estimate.
``games_done`` should be the count of 100%-complete games in the library
snapshot (``sum(1 for g in games if g.is_complete)``), not the enforcer's
own ``finished_app_ids`` list, which misses games completed outside the
enforcer flow.
"""
_echo("\n 1. AT YOUR CURRENT PACE")
if not state.enforcement_started_at:
_echo(" No start date recorded.")
_echo(" Set enforcement_started_at in state.json (ISO-8601 UTC)")
_echo(" to enable this estimate.")
return
try:
started = datetime.fromisoformat(state.enforcement_started_at)
except ValueError:
_echo(f" Invalid enforcement_started_at: {state.enforcement_started_at!r}")
return
now = datetime.now(timezone.utc)
days_elapsed = max(1, (now - started).days)
if games_done == 0:
_echo(f" Started: {started.strftime('%Y-%m-%d')}")
_echo(" No games finished yet — pace cannot be estimated.")
return
rate = games_done / days_elapsed
_echo(f" Started: {started.strftime('%Y-%m-%d')}")
_echo(f" Finished: {games_done} games in {days_elapsed} days")
_echo(
f" Pace: {rate:.4f} games/day (1 game every {1 / rate:.1f} days)"
)
_echo(f" Remaining: {remaining} games")
days_to_go = int(remaining / rate)
finish = now + timedelta(days=days_to_go)
_echo(f" Est. complete: {days_to_go} days ({finish.strftime('%Y-%m-%d')})")
def cmd_stats(_config: Config, state: State) -> None:
"""Display backlog completion-time statistics.
Filters games by the same HLTB-confidence and Linux-compatibility rules
used when picking the next game. Auto-fetches missing rush/leisure detail
data before printing. Shows four scenarios:
1. At your current pace (games finished per day since enforcement started).
2. Rush avg comp_100 + DLC completion time per HLTB.
3. Leisure comp_100_h (slowest 100 %) + DLC leisure per HLTB.
4. Worst absolute maximum recorded time (any category) per HLTB.
"""
snapshot = load_snapshot()
if snapshot is None:
_echo("No snapshot found. Run 'scan' first.")
return
games = [GameInfo.from_snapshot(d) for d in snapshot]
# Count all 100%-achievement games in library (more accurate than
# finished_app_ids, which only tracks enforcer-assigned completions).
games_done = sum(1 for g in games if g.is_complete)
qualified, hltb_skip, linux_skip, no_data_skip = _filter_qualifying_games(
games, state
)
if _ensure_rush_data(qualified):
# Re-filter picks up updated rush/leisure caches; ProtonDB is now cached.
qualified, hltb_skip, linux_skip, no_data_skip = _filter_qualifying_games(
games, state
)
total_q = len(qualified)
_echo(f"\n{'' * 70}")
_echo(" BACKLOG COMPLETION ESTIMATES")
_echo(f"{'' * 70}")
_echo(f"\n Qualifying games: {total_q}")
if hltb_skip:
_echo(f" HLTB-skipped: {hltb_skip} (confidence too low)")
if linux_skip:
_echo(f" Linux-skipped: {linux_skip} (poor ProtonDB rating)")
if no_data_skip:
_echo(f" No-data-skipped: {no_data_skip} (no HLTB hours at all)")
missing_rush_final = sum(1 for e in qualified if e.rush_hours <= 0)
if missing_rush_final:
_echo(
f"\n Note: {missing_rush_final}/{total_q} games still missing"
" rush/leisure data (HLTB search may not have matched them)."
)
elif total_q:
_echo(
f"\n Detail data: rush + leisure available for all {total_q}"
" qualifying games."
)
if state.current_app_id:
_echo(
f"\n Current game: {state.current_game_name} (excluded from totals)"
)
_echo(f" Finished games: {games_done} (excluded from totals)")
_echo(f"\n{_LINE}")
_print_pace_scenario(state, total_q, games_done)
worst_total, worst_missing = _sum_hours(qualified, "worst_hours")
rush_total, rush_missing = _sum_hours(qualified, "rush_hours")
leisure_total, leisure_missing = _sum_hours(qualified, "leisure_100h")
_echo(f"\n{_LINE}")
_print_scenario(
"2. RUSH (avg comp_100 + DLC — typical fast completionist)",
rush_total,
rush_missing,
total_q,
)
_echo(f"\n{_LINE}")
_print_scenario(
"3. LEISURE (comp_100_h + DLC — slow/comfortable 100 %)",
leisure_total,
leisure_missing,
total_q,
)
_echo(f"\n{_LINE}")
_print_scenario(
"4. WORST CASE (max recorded time, any category, + DLC)",
worst_total,
worst_missing,
total_q,
)
_print_worst_example(qualified)
_echo(f"\n{_LINE}\n")

View File

@ -1,347 +0,0 @@
"""Whitelist hardening: time-locked exceptions, reason validation, immutability."""
from __future__ import annotations
from collections import Counter
import contextlib
import json
import logging
import math
import re
import shutil
import subprocess
import time
from typing import TYPE_CHECKING, cast
from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
if TYPE_CHECKING:
from pathlib import Path
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
# File paths (patched in tests via conftest)
# ──────────────────────────────────────────────────────────────
PENDING_EXCEPTIONS_FILE: Path = CONFIG_DIR / "pending_exceptions.json"
APPROVED_EXCEPTIONS_FILE: Path = CONFIG_DIR / "approved_exceptions.json"
EXCEPTION_AUDIT_LOG: Path = CONFIG_DIR / "exception_audit.log"
# ──────────────────────────────────────────────────────────────
# Constants
# ──────────────────────────────────────────────────────────────
WHITELIST_COOLDOWN_SECONDS: int = 86400 # 24 hours
_MIN_REASON_WORDS: int = 5
_MIN_REASON_LENGTH: int = 25
_MIN_ENTROPY: float = 3.0
# Reject runs of the same character longer than this (e.g. "aaaa").
_MAX_CHAR_RUN: int = 3
# ──────────────────────────────────────────────────────────────
# Reason validation
# ──────────────────────────────────────────────────────────────
def _shannon_entropy(text: str) -> float:
"""Return Shannon entropy (bits per character) for *text*.
Whitespace is excluded before counting so spaces don't inflate entropy.
Args:
text: Input string to measure.
Returns:
Entropy in bits per character, or 0.0 for empty input.
"""
chars = [c.lower() for c in text if not c.isspace()]
if not chars:
return 0.0
total = len(chars)
counts = Counter(chars)
return -sum((c / total) * math.log2(c / total) for c in counts.values())
def validate_reason(reason: str) -> str | None:
"""Validate that a whitelist exception reason is genuine.
Returns None when the reason is acceptable, or a human-readable error
string that explains why it was rejected.
Args:
reason: User-supplied justification text.
Returns:
None if valid, or an error message string if invalid.
"""
stripped = reason.strip()
if len(stripped) < _MIN_REASON_LENGTH:
return (
f"Reason is too short ({len(stripped)} chars; "
f"need at least {_MIN_REASON_LENGTH})."
)
words = stripped.split()
if len(words) < _MIN_REASON_WORDS:
return (
f"Reason must contain at least {_MIN_REASON_WORDS} words "
f"(got {len(words)})."
)
entropy = _shannon_entropy(stripped)
if entropy < _MIN_ENTROPY:
return (
f"Reason appears to be random characters "
f"(entropy {entropy:.2f} < {_MIN_ENTROPY}). "
"Write a genuine justification."
)
# Reject runs of the same character: aaaa, bbbbbb, etc.
if re.search(r"(.)\1{3,}", stripped, re.IGNORECASE):
return "Reason contains repeated characters. Write a genuine justification."
# Reject simple two-character alternating patterns: ababab, asasas, etc.
if re.search(r"(..)(\1){3,}", stripped, re.IGNORECASE):
return "Reason contains repetitive patterns. Write a genuine justification."
return None
# ──────────────────────────────────────────────────────────────
# Immutability helpers
# ──────────────────────────────────────────────────────────────
def _try_set_immutable(path: Path, *, immutable: bool) -> None:
"""Silently attempt to set or clear the immutable flag on *path*.
This is a best-effort operation it fails silently if chattr is not
available, the process lacks the required capability, or the filesystem
does not support the flag.
Args:
path: File to modify.
immutable: True to set +i, False to clear -i.
"""
if not path.exists():
return
chattr = shutil.which("chattr")
if chattr is None:
return
flag = "+i" if immutable else "-i"
with contextlib.suppress(OSError, subprocess.TimeoutExpired):
subprocess.run(
[chattr, flag, str(path)],
capture_output=True,
check=False,
timeout=5,
)
def lock_enforcement_files(config_file: Path) -> None:
"""Apply chattr +i to enforcement-critical config files.
Called at the end of each enforce-loop iteration. Requires that the
daemon is running as root (or has CAP_LINUX_IMMUTABLE).
Args:
config_file: Path to the main config.json.
"""
_try_set_immutable(config_file, immutable=True)
_try_set_immutable(APPROVED_EXCEPTIONS_FILE, immutable=True)
def unlock_for_write(path: Path) -> None:
"""Clear the immutable flag before writing *path*.
Args:
path: File to unlock.
"""
_try_set_immutable(path, immutable=False)
# ──────────────────────────────────────────────────────────────
# Persistence helpers
# ──────────────────────────────────────────────────────────────
def _load_pending() -> list[dict[str, object]]:
"""Load pending exception entries from disk."""
if not PENDING_EXCEPTIONS_FILE.exists():
return []
try:
data: object = json.loads(PENDING_EXCEPTIONS_FILE.read_text(encoding="utf-8"))
if isinstance(data, list):
return cast("list[dict[str, object]]", data)
except (json.JSONDecodeError, OSError, ValueError):
pass
return []
def _save_pending(entries: list[dict[str, object]]) -> None:
"""Persist pending exception entries to disk."""
_atomic_write(PENDING_EXCEPTIONS_FILE, json.dumps(entries, indent=2) + "\n")
def _load_approved() -> list[dict[str, object]]:
"""Load approved exception entries from disk."""
if not APPROVED_EXCEPTIONS_FILE.exists():
return []
try:
data: object = json.loads(APPROVED_EXCEPTIONS_FILE.read_text(encoding="utf-8"))
if isinstance(data, list):
return cast("list[dict[str, object]]", data)
except (json.JSONDecodeError, OSError, ValueError):
pass
return []
def _save_approved(entries: list[dict[str, object]]) -> None:
"""Persist approved exception entries to disk."""
unlock_for_write(APPROVED_EXCEPTIONS_FILE)
_atomic_write(APPROVED_EXCEPTIONS_FILE, json.dumps(entries, indent=2) + "\n")
_try_set_immutable(APPROVED_EXCEPTIONS_FILE, immutable=True)
def _append_audit_log(app_id: int, reason: str, event: str) -> None:
"""Append one line to the append-only audit log.
Each line has the format::
ISO-TIMESTAMP | EVENT | app_id=NNN | reason='...'
Args:
app_id: Steam application ID involved.
reason: Justification text supplied by the user.
event: Short event label such as ``REQUESTED`` or ``APPROVED``.
"""
timestamp = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
line = f"{timestamp} | {event} | app_id={app_id} | reason={reason!r}\n"
EXCEPTION_AUDIT_LOG.parent.mkdir(parents=True, exist_ok=True)
with EXCEPTION_AUDIT_LOG.open("a", encoding="utf-8") as fh:
fh.write(line)
# ──────────────────────────────────────────────────────────────
# Public API
# ──────────────────────────────────────────────────────────────
def add_pending_exception(app_id: int, reason: str) -> str:
"""Request a new whitelist exception for *app_id*.
The entry becomes active only after ``WHITELIST_COOLDOWN_SECONDS`` have
elapsed (24 h by default). Returns a human-readable status message.
Args:
app_id: Steam application ID to add.
reason: Validated justification text (must pass :func:`validate_reason`).
Returns:
Human-readable confirmation or remaining-cooldown message.
Raises:
ValueError: If the reason fails validation or the ID is already approved.
"""
err = validate_reason(reason)
if err is not None:
raise ValueError(err)
approved = _load_approved()
if any(int(e["app_id"]) == app_id for e in approved):
msg = f"AppID {app_id} is already in the approved exceptions list."
raise ValueError(msg)
pending = _load_pending()
for entry in pending:
if int(entry["app_id"]) == app_id:
elapsed = time.time() - float(entry["requested_at"])
remaining = WHITELIST_COOLDOWN_SECONDS - elapsed
if remaining > 0:
hours = int(remaining // 3600)
mins = int((remaining % 3600) // 60)
return (
f"AppID {app_id} is already pending; approves in {hours}h {mins}m."
)
# Cooldown already elapsed for this pending entry — promote now.
break
entry_new: dict[str, object] = {
"app_id": app_id,
"reason": reason,
"requested_at": time.time(),
}
pending.append(entry_new)
_save_pending(pending)
_append_audit_log(app_id, reason, "REQUESTED")
hours = WHITELIST_COOLDOWN_SECONDS // 3600
return (
f"Exception requested for AppID {app_id}. "
f"Will become active in {hours}h. Reason logged."
)
def promote_pending_exceptions() -> list[int]:
"""Move cooldown-elapsed pending entries to the approved list.
Called by the enforce daemon on each loop iteration. Returns the list
of app IDs that were promoted this call.
Returns:
List of newly approved app IDs (may be empty).
"""
pending = _load_pending()
now = time.time()
still_pending: list[dict[str, object]] = []
newly_approved: list[int] = []
for entry in pending:
elapsed = now - float(entry["requested_at"])
if elapsed >= WHITELIST_COOLDOWN_SECONDS:
app_id = int(entry["app_id"])
approved = _load_approved()
if not any(int(e["app_id"]) == app_id for e in approved):
approved.append(
{
"app_id": app_id,
"reason": entry["reason"],
"approved_at": now,
}
)
_save_approved(approved)
_append_audit_log(app_id, str(entry["reason"]), "APPROVED")
newly_approved.append(app_id)
else:
still_pending.append(entry)
if len(still_pending) != len(pending):
_save_pending(still_pending)
return newly_approved
def get_approved_exception_ids() -> frozenset[int]:
"""Return the frozenset of currently approved exception app IDs.
Does NOT trigger promotion call :func:`promote_pending_exceptions`
explicitly when timely promotion is required (e.g. the enforce loop).
Returns:
Frozenset of approved app IDs.
"""
approved = _load_approved()
return frozenset(int(e["app_id"]) for e in approved)
def list_pending_exceptions() -> list[dict[str, object]]:
"""Return a copy of the current pending exception list.
Returns:
List of pending exception dicts with keys app_id, reason, requested_at.
"""
return list(_load_pending())

View File

@ -1,180 +0,0 @@
"""Configuration management for Steam Backlog Enforcer."""
from __future__ import annotations
import contextlib
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import json
import logging
import os
from pathlib import Path
import sys
import tempfile
from typing import Any
CONFIG_DIR = Path.home() / ".config" / "steam_backlog_enforcer"
CONFIG_FILE = CONFIG_DIR / "config.json"
STATE_FILE = CONFIG_DIR / "state.json"
SNAPSHOT_FILE = CONFIG_DIR / "snapshot.json"
LOG_FILE = CONFIG_DIR / "enforcer.log"
# Steam store domains to block.
BLOCKED_DOMAINS = [
"store.steampowered.com",
"checkout.steampowered.com",
"store.akamai.steamstatic.com",
"storefront.steampowered.com",
"store.cloudflare.steamstatic.com",
]
HOSTS_FILE = Path("/etc/hosts")
logger = logging.getLogger(__name__)
def _atomic_write(path: Path, data: str) -> None:
"""Write data to a file atomically via a temporary file + rename."""
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, suffix=".tmp")
tmp_path = Path(tmp)
try:
os.write(fd, data.encode("utf-8"))
os.close(fd)
tmp_path.replace(path)
except BaseException:
with contextlib.suppress(OSError):
os.close(fd)
with contextlib.suppress(OSError):
tmp_path.unlink()
raise
@dataclass
class Config:
"""User configuration."""
steam_api_key: str = ""
steam_id: str = ""
block_store: bool = True
kill_unauthorized_games: bool = True
uninstall_other_games: bool = True
desktop_notifications: bool = True
def save(self) -> None:
"""Persist config to disk."""
_atomic_write(
CONFIG_FILE,
json.dumps(self.__dict__, indent=2) + "\n",
)
@classmethod
def load(cls) -> Config:
"""Load config from disk, or return defaults."""
if CONFIG_FILE.exists():
data = json.loads(CONFIG_FILE.read_text(encoding="utf-8"))
return cls(
**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}
)
return cls()
@dataclass
class State:
"""Persistent state across runs."""
current_app_id: int | None = None
current_game_name: str = ""
finished_app_ids: list[int] = field(default_factory=list)
skipped_until: dict[str, str] = field(default_factory=dict)
enforcement_started_at: str = ""
"""ISO-8601 UTC timestamp set on the first game assignment."""
"""Map of ``str(app_id)`` → ISO-8601 UTC timestamp when the skip expires.
Games in this map are excluded from auto-assignment until the timestamp
elapses. Populated when the user declines a freshly-picked game via the
interactive prompt in ``cmd_done``.
"""
def skip_for_days(self, app_id: int, days: int) -> None:
"""Mark ``app_id`` as skipped for ``days`` days from now (UTC)."""
expires = datetime.now(timezone.utc) + timedelta(days=days)
self.skipped_until[str(app_id)] = expires.isoformat()
def active_skipped_ids(self) -> set[int]:
"""Return currently-skipped app IDs and prune expired entries.
Mutates ``self.skipped_until`` to drop expired or malformed entries.
Callers should ``save()`` if they want the prune persisted.
"""
now = datetime.now(timezone.utc)
active: set[int] = set()
to_remove: list[str] = []
for aid_str, ts in self.skipped_until.items():
try:
expiry = datetime.fromisoformat(ts)
except ValueError:
to_remove.append(aid_str)
continue
if expiry > now:
active.add(int(aid_str))
else:
to_remove.append(aid_str)
for aid_str in to_remove:
del self.skipped_until[aid_str]
return active
def save(self) -> None:
"""Persist state to disk."""
_atomic_write(
STATE_FILE,
json.dumps(self.__dict__, indent=2) + "\n",
)
@classmethod
def load(cls) -> State:
"""Load state from disk, or return defaults."""
if STATE_FILE.exists():
try:
data = json.loads(STATE_FILE.read_text(encoding="utf-8"))
except (json.JSONDecodeError, OSError, ValueError):
logger.warning("Corrupt state file, using defaults.")
return cls()
return cls(
**{k: v for k, v in data.items() if k in cls.__dataclass_fields__}
)
return cls()
def save_snapshot(data: list[dict[str, Any]]) -> None:
"""Save an achievement snapshot to disk."""
_atomic_write(
SNAPSHOT_FILE,
json.dumps(data, indent=2) + "\n",
)
def load_snapshot() -> list[dict[str, Any]] | None:
"""Load the cached achievement snapshot, or None if absent."""
if SNAPSHOT_FILE.exists():
result: list[dict[str, Any]] = json.loads(
SNAPSHOT_FILE.read_text(encoding="utf-8")
)
return result
return None
def interactive_setup() -> Config:
"""Run first-time interactive setup."""
api_key = input("Enter your Steam Web API key: ").strip()
if not api_key:
sys.exit(1)
steam_id = input("Enter your Steam64 ID: ").strip()
if not steam_id:
sys.exit(1)
config = Config(steam_api_key=api_key, steam_id=steam_id)
config.save()
CONFIG_FILE.chmod(0o600)
return config

View File

@ -1,97 +0,0 @@
"""Enforce that only the assigned game may run."""
from __future__ import annotations
import logging
import os
from pathlib import Path
import shutil
import signal
import subprocess
from python_pkg.steam_backlog_enforcer.game_install import (
is_protected_app,
)
logger = logging.getLogger(__name__)
def get_running_steam_game_pids() -> dict[int, int]:
"""Scan /proc to find running Steam game processes.
Returns: dict mapping PID -> SteamAppId.
"""
running: dict[int, int] = {}
proc = Path("/proc")
for entry in proc.iterdir():
if not entry.name.isdigit():
continue
try:
environ = (entry / "environ").read_bytes()
pairs = environ.split(b"\x00")
for pair in pairs:
if pair.startswith(b"SteamAppId="):
value = pair.split(b"=", 1)[1].decode("utf-8", errors="replace")
if value.isdigit():
running[int(entry.name)] = int(value)
break
except (PermissionError, OSError, ValueError):
continue
return running
def enforce_allowed_game(
allowed_app_id: int | None,
*,
kill_unauthorized: bool = True,
) -> list[tuple[int, int]]:
"""Check running games; optionally kill unauthorized ones.
Returns list of (pid, app_id) that were killed or detected.
"""
if allowed_app_id is None:
return []
running = get_running_steam_game_pids()
violations: list[tuple[int, int]] = []
for pid, app_id in running.items():
if allowed_app_id is not None and app_id == allowed_app_id:
continue
# Skip Steam client itself (app_id 0 or very low IDs).
if app_id == 0:
continue
if is_protected_app(app_id):
continue
violations.append((pid, app_id))
if kill_unauthorized:
kill_process(pid, app_id)
return violations
def kill_process(pid: int, app_id: int) -> None:
"""Kill a process by PID."""
try:
logger.warning("Killing unauthorized game (AppID=%d, PID=%d)", app_id, pid)
os.kill(pid, signal.SIGTERM)
except ProcessLookupError:
logger.debug("Process %d already gone.", pid)
except PermissionError:
logger.exception("No permission to kill PID %d.", pid)
def send_notification(title: str, body: str) -> None:
"""Send a desktop notification."""
_notify_send = shutil.which("notify-send") or "/usr/bin/notify-send"
try:
subprocess.run(
[_notify_send, title, body, "--icon=dialog-warning"],
capture_output=True,
timeout=5,
check=False,
)
except (FileNotFoundError, OSError):
logger.debug("notify-send not available.")

View File

@ -1,431 +0,0 @@
"""Game installation and uninstallation management."""
from __future__ import annotations
import contextlib
import logging
import os
from pathlib import Path
import pwd
import re
import shutil
import subprocess
import sys
import time
from python_pkg.steam_backlog_enforcer._whitelist import get_approved_exception_ids
logger = logging.getLogger(__name__)
# Real Steam directory — used as a safety check to block destructive
# operations that leak through during testing.
_REAL_STEAMAPPS = Path("~/.local/share/Steam/steamapps").expanduser()
def _assert_not_real_steam(path: Path) -> None:
"""Raise if *path* is inside the real Steam directory during tests.
Defence-in-depth guard: when running under pytest, even if test
fixtures fail to redirect ``STEAMAPPS_PATH``, destructive
operations (uninstall, rmtree, unlink) will refuse to touch
real files. In production runs this is a no-op.
"""
if "PYTEST_CURRENT_TEST" not in os.environ:
return # production run — real Steam paths are expected
try:
path.resolve().relative_to(_REAL_STEAMAPPS.resolve())
except ValueError:
return # path is NOT under real Steam — safe to proceed
if STEAMAPPS_PATH.resolve() == _REAL_STEAMAPPS.resolve():
msg = (
f"SAFETY: refusing destructive operation on real Steam path "
f"{path!s} — STEAMAPPS_PATH was not redirected by test fixtures"
)
raise RuntimeError(msg)
def _echo(msg: str = "", *, end: str = "\n", flush: bool = False) -> None:
"""Write user-facing CLI output to stdout.
Args:
msg: Text to output.
end: String appended after the message.
flush: Whether to flush stdout immediately.
"""
sys.stdout.write(msg + end)
if flush:
sys.stdout.flush()
# Steam infrastructure app IDs that should NEVER be uninstalled.
PROTECTED_APP_IDS = {
228980, # Steamworks Common Redistributables
1070560, # Steam Linux Runtime 1.0 (scout)
1391110, # Steam Linux Runtime 2.0 (soldier)
1628350, # Steam Linux Runtime 3.0 (sniper)
961940, # Steam Linux Runtime (legacy)
# Proton versions (never uninstall these)
858280, # Proton 3.7 (Beta)
930400, # Proton 3.16 (Beta)
1054830, # Proton 4.2
1113280, # Proton 4.11
1245040, # Proton 5.0
1420170, # Proton 5.13
1580130, # Proton 6.3
1887720, # Proton 7.0
2230260, # Proton 7.0 (alt)
2348590, # Proton 8.0
2805730, # Proton 9.0
3201940, # Proton 9.0 (alt)
3658110, # Proton 10.0
2180100, # Proton Hotfix
1493710, # Proton Experimental
1161040, # Proton BattlEye Runtime
1007020, # Proton EasyAntiCheat Runtime
}
STEAMAPPS_PATH = Path("~/.local/share/Steam/steamapps").expanduser()
def _trigger_steam_install(app_id: int, label: str) -> bool:
"""Ask Steam to install a game via the ``steam://install`` URI.
Returns True if the URI handler was invoked successfully.
"""
xdg_open = shutil.which("xdg-open") or "/usr/bin/xdg-open"
try:
subprocess.run(
[xdg_open, f"steam://install/{app_id}"],
capture_output=True,
timeout=15,
check=False,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return False
else:
logger.info("Triggered Steam install for %s via protocol handler", label)
return True
# ──────────────────────────────────────────────────────────────
# Game install management
# ──────────────────────────────────────────────────────────────
def _get_real_user() -> str | None:
"""Get the real (non-root) user when running under sudo."""
return os.environ.get("SUDO_USER") or os.environ.get("USER")
def _get_uid_gid_for_user(username: str) -> tuple[int, int]:
"""Get (uid, gid) for a username."""
try:
pw = pwd.getpwnam(username)
except KeyError:
return 1000, 1000
else:
return pw.pw_uid, pw.pw_gid
def is_game_installed(app_id: int) -> bool:
"""Check if a game is installed by looking for its appmanifest.
A manifest with StateFlags != 4 (FullyInstalled) means the game is
still downloading or queued, which still counts as "install triggered".
"""
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
return manifest.exists()
def _ensure_steam_running() -> None:
"""Start the Steam client if it is not already running."""
# Check if any steam process is running (main client, not just helpers).
try:
result = subprocess.run(
["/usr/bin/pgrep", "-f", "steam.sh"],
capture_output=True,
text=True,
check=False,
)
if result.returncode == 0:
logger.debug("Steam client already running")
return
except FileNotFoundError:
pass
real_user = _get_real_user()
logger.info("Starting Steam client...")
try:
if os.geteuid() == 0 and real_user and real_user != "root":
uid, _ = _get_uid_gid_for_user(real_user)
dbus_default = f"unix:path=/run/user/{uid}/bus"
dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default)
xauth_default = f"/home/{real_user}/.Xauthority"
xauth = os.environ.get("XAUTHORITY", xauth_default)
cmd = [
"sudo",
"-u",
real_user,
"env",
f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
f"XAUTHORITY={xauth}",
f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}",
"steam",
"-silent",
]
else:
cmd = ["steam", "-silent"]
subprocess.Popen(
cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
# Give Steam time to initialize and start scanning manifests.
time.sleep(15)
except FileNotFoundError:
logger.exception("Steam executable not found")
def install_game(
app_id: int,
game_name: str,
steam_id: str,
*,
use_steam_protocol: bool = False,
) -> bool:
"""Install a game by triggering a Steam download.
When *use_steam_protocol* is True the ``steam://install`` URI handler
is used, which lets Steam determine the correct install directory from
its own metadata. This avoids mismatches between the display name and
the canonical ``installdir`` that can cause "Missing game executable"
errors. Falls back to writing a fabricated appmanifest if the URI
handler is unavailable.
When *use_steam_protocol* is False (the default) a minimal
appmanifest with StateFlags=1026 is written directly. This is
suitable for non-interactive / daemon contexts where opening a Steam
dialog is undesirable.
Args:
app_id: Steam application ID.
game_name: Human-readable game name.
steam_id: Steam64 ID of the account that owns the game.
use_steam_protocol: Prefer the ``steam://install`` URI handler.
Returns True if the install was triggered successfully.
"""
label = game_name or f"AppID={app_id}"
if is_game_installed(app_id):
logger.info("Game already installed: %s", label)
return True
if use_steam_protocol:
_ensure_steam_running()
if _trigger_steam_install(app_id, label):
return True
logger.debug("steam:// protocol failed; falling back to manifest")
# Build a minimal appmanifest. StateFlags 1026 = UpdateRequired (2) +
# UpdateStarted (1024), which tells Steam "this app needs downloading".
manifest_content = (
'"AppState"\n'
"{\n"
f'\t"appid"\t\t"{app_id}"\n'
'\t"universe"\t\t"1"\n'
f'\t"name"\t\t"{game_name}"\n'
'\t"StateFlags"\t\t"1026"\n'
f'\t"installdir"\t\t"{game_name}"\n'
'\t"LastUpdated"\t\t"0"\n'
'\t"LastPlayed"\t\t"0"\n'
'\t"SizeOnDisk"\t\t"0"\n'
'\t"StagingSize"\t\t"0"\n'
'\t"buildid"\t\t"0"\n'
f'\t"LastOwner"\t\t"{steam_id}"\n'
'\t"UpdateResult"\t\t"0"\n'
'\t"BytesToDownload"\t\t"0"\n'
'\t"BytesDownloaded"\t\t"0"\n'
'\t"BytesToStage"\t\t"0"\n'
'\t"BytesStaged"\t\t"0"\n'
'\t"TargetBuildID"\t\t"0"\n'
'\t"AutoUpdateBehavior"\t\t"0"\n'
'\t"AllowOtherDownloadsWhileRunning"\t\t"0"\n'
'\t"ScheduledAutoUpdate"\t\t"0"\n'
'\t"InstalledDepots"\n'
"\t{\n"
"\t}\n"
'\t"UserConfig"\n'
"\t{\n"
"\t}\n"
'\t"MountedConfig"\n'
"\t{\n"
"\t}\n"
"}\n"
)
manifest_path = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
try:
with manifest_path.open("w", encoding="utf-8") as fh:
fh.write(manifest_content)
# Fix ownership so the Steam client (running as the real user) can
# read and update the manifest.
real_user = _get_real_user()
if os.geteuid() == 0 and real_user and real_user != "root":
uid, gid = _get_uid_gid_for_user(real_user)
os.chown(manifest_path, uid, gid)
logger.info("Created appmanifest for %s — Steam will auto-download", label)
except OSError:
logger.exception("Failed to create appmanifest for %s", label)
return False
# Make sure Steam is running so it picks up the manifest.
_ensure_steam_running()
return True
# ──────────────────────────────────────────────────────────────
# Game uninstall management
# ──────────────────────────────────────────────────────────────
def get_installed_games() -> list[tuple[int, str]]:
"""Parse appmanifest files to find installed games.
Returns: list of (app_id, game_name) tuples.
"""
installed: list[tuple[int, str]] = []
for manifest_file in STEAMAPPS_PATH.glob("appmanifest_*.acf"):
with contextlib.suppress(OSError):
content = manifest_file.read_text(encoding="utf-8")
app_id_match = re.search(r'"appid"\s+"(\d+)"', content)
name_match = re.search(r'"name"\s+"([^"]+)"', content)
if app_id_match:
app_id = int(app_id_match.group(1))
name = name_match.group(1) if name_match else f"Unknown ({app_id})"
installed.append((app_id, name))
installed.sort(key=lambda x: x[1].lower())
return installed
def _read_install_dir(manifest: Path) -> Path | None:
"""Read installdir from a game's appmanifest file."""
if not manifest.exists():
return None
try:
content = manifest.read_text(encoding="utf-8")
match = re.search(r'"installdir"\s+"([^"]+)"', content)
if match:
return STEAMAPPS_PATH / "common" / match.group(1)
except OSError:
pass
return None
def _remove_manifest(manifest: Path, game_name: str, app_id: int) -> bool:
"""Remove a game manifest file.
Args:
manifest: Path to the appmanifest file.
game_name: Human-readable game name for logging.
app_id: Steam application ID.
"""
_assert_not_real_steam(manifest)
try:
if manifest.exists():
manifest.unlink()
logger.info(
"Removed manifest for %s (AppID=%d)", game_name or app_id, app_id
)
except OSError:
logger.exception("Failed to remove manifest for AppID=%d", app_id)
return False
return True
def _remove_game_dirs(install_dir: Path | None, app_id: int) -> bool:
"""Remove game installation directory and cache directories.
Args:
install_dir: Path to the game's install directory, or None.
app_id: Steam application ID.
"""
success = True
if install_dir and install_dir.is_dir():
_assert_not_real_steam(install_dir)
try:
shutil.rmtree(install_dir)
logger.info("Removed game files: %s", install_dir)
except OSError:
logger.exception("Failed to remove game dir %s", install_dir)
success = False
for subdir in ("shadercache", "compatdata"):
cache_path = STEAMAPPS_PATH / subdir / str(app_id)
if cache_path.is_dir():
_assert_not_real_steam(cache_path)
with contextlib.suppress(OSError):
shutil.rmtree(cache_path)
logger.debug("Removed %s/%d", subdir, app_id)
return success
def uninstall_game(app_id: int, game_name: str = "") -> bool:
"""Uninstall a single game by removing its manifest and game files.
Uses direct file removal instead of ``steam://uninstall`` URI to avoid
GUI popups and to work when Steam is not running.
"""
manifest = STEAMAPPS_PATH / f"appmanifest_{app_id}.acf"
install_dir = _read_install_dir(manifest)
success = _remove_manifest(manifest, game_name, app_id)
if not _remove_game_dirs(install_dir, app_id):
success = False
return success
def uninstall_other_games(allowed_app_id: int | None) -> int:
"""Uninstall all installed games except the assigned one and protected IDs.
Returns: number of games uninstalled.
"""
installed = get_installed_games()
count = 0
for app_id, name in installed:
if app_id == allowed_app_id:
logger.info("KEEPING assigned game: %s (AppID=%d)", name, app_id)
continue
if is_protected_app(app_id):
logger.debug("Skipping protected: %s (AppID=%d)", name, app_id)
continue
logger.info("UNINSTALLING: %s (AppID=%d)", name, app_id)
if uninstall_game(app_id, name):
count += 1
return count
def is_protected_app(app_id: int) -> bool:
"""Return True if *app_id* must never be uninstalled.
Combines the hardcoded Steam infrastructure set with any app IDs that
have been approved via the time-locked exception mechanism.
Args:
app_id: Steam application ID to check.
Returns:
True if the app should be left alone by the enforcer.
"""
return app_id in PROTECTED_APP_IDS or app_id in get_approved_exception_ids()

View File

@ -1,359 +0,0 @@
"""HowLongToBeat integration for estimating game completion times.
Fetches leisure completionist hour estimates from howlongtobeat.com with:
- direct API calls (bypassing the slow howlongtobeatpy per-request setup)
- single shared aiohttp session for all requests
- concurrent requests with configurable concurrency
- live progress reporting via callback
- incremental disk-cache saves so crashes don't lose work
- leisure time (upper-bound play time) from individual game pages
- DLC time aggregation (base game + all DLC leisure times combined)
"""
from __future__ import annotations
import asyncio
import logging
import time
import aiohttp
from python_pkg.steam_backlog_enforcer._hltb_search import (
_fetch_batch,
_get_auth_info,
_get_hltb_search_url,
_search_one,
_SearchCtx,
)
from python_pkg.steam_backlog_enforcer._hltb_types import (
HLTB_BASE_URL,
MAX_CONCURRENT,
HLTBResult,
ProgressCb,
_HLTBExtras,
load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_game_id_cache,
load_hltb_leisure_100h_cache,
load_hltb_polls_cache,
load_hltb_rush_cache,
save_hltb_cache,
)
logger = logging.getLogger(__name__)
# ──────────────────────────────────────────────────────────────
# Confidence-only batch fetch (no leisure/DLC detail pages)
# ──────────────────────────────────────────────────────────────
async def _fetch_batch_confidence_only(
games: list[tuple[int, str]],
cache: dict[int, float],
polls: dict[int, int],
progress_cb: ProgressCb | None,
count_comp: dict[int, int] | None = None,
) -> list[HLTBResult]:
"""Fetch only search-level HLTB data (hours + confidence), no detail pages."""
# 1. Discover the search URL (sync, one-time).
search_url = _get_hltb_search_url()
logger.info("HLTB search URL: %s", search_url)
timeout = aiohttp.ClientTimeout(total=20, sock_read=15)
# 2. Get auth info (separate session — avoids reuse issues).
async with aiohttp.ClientSession(timeout=timeout) as init_session:
auth = await _get_auth_info(search_url, init_session)
if auth is None:
logger.warning("Could not get HLTB auth info, aborting fetch.")
return []
logger.info("HLTB auth token acquired.")
# 3. Build shared headers for all search requests.
headers: dict[str, str] = {
"content-type": "application/json",
"accept": "*/*",
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64; rv:136.0) Gecko/20100101 Firefox/136.0"
),
"referer": "https://howlongtobeat.com/",
"x-auth-token": auth.token,
}
if auth.hp_key:
headers["x-hp-key"] = auth.hp_key
headers["x-hp-val"] = auth.hp_val
# 4. Fire all searches through a single persistent session.
sem = asyncio.Semaphore(MAX_CONCURRENT)
counter = {"done": 0, "found": 0}
total = len(games)
if count_comp is None:
count_comp = {}
connector = aiohttp.TCPConnector(
limit=MAX_CONCURRENT,
keepalive_timeout=30,
)
async with aiohttp.ClientSession(
timeout=timeout,
connector=connector,
) as session:
ctx = _SearchCtx(
session=session,
search_url=search_url,
headers=headers,
cache=cache,
polls=polls,
count_comp=count_comp,
auth=auth,
counter=counter,
total=total,
progress_cb=progress_cb,
)
tasks = [
_search_one(
sem,
ctx,
app_id,
name,
)
for app_id, name in games
]
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]
def fetch_hltb_times(
games: list[tuple[int, str]],
cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: ProgressCb | None = None,
extras: _HLTBExtras | None = None,
) -> list[HLTBResult]:
"""Synchronous wrapper: fetch HLTB times for games."""
if not games:
return []
if cache is None:
cache = {}
if polls is None:
polls = {}
return asyncio.run(
_fetch_batch(
games,
cache,
polls,
progress_cb,
extras=extras,
)
)
def fetch_hltb_confidence(
games: list[tuple[int, str]],
cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: ProgressCb | None = None,
count_comp: dict[int, int] | None = None,
) -> list[HLTBResult]:
"""Fetch only HLTB search-level data (hours + confidence metrics)."""
if not games:
return []
if cache is None:
cache = {}
if polls is None:
polls = {}
if count_comp is None:
count_comp = {}
return asyncio.run(
_fetch_batch_confidence_only(
games,
cache,
polls,
progress_cb,
count_comp=count_comp,
)
)
def fetch_hltb_times_cached(
games: list[tuple[int, str]],
progress_cb: ProgressCb | None = None,
) -> dict[int, float]:
"""Fetch HLTB times, using disk cache for already-known games.
Args:
games: list of (app_id, name) tuples to look up.
progress_cb: optional callback(done, total, found, game_name).
Returns: dict mapping app_id -> completionist_hours.
"""
cache = load_hltb_cache()
polls = load_hltb_polls_cache()
extras = _HLTBExtras(
count_comp=load_hltb_count_comp_cache(),
rush=load_hltb_rush_cache(),
leisure_100h=load_hltb_leisure_100h_cache(),
)
uncached = [(app_id, name) for app_id, name in games if app_id not in cache]
if uncached:
logger.info(
"Fetching HLTB data for %d uncached games (%d cached)...",
len(uncached),
len(games) - len(uncached),
)
t0 = time.monotonic()
fetch_hltb_times(
uncached,
cache=cache,
polls=polls,
progress_cb=progress_cb,
extras=extras,
)
elapsed = time.monotonic() - t0
# Final save.
save_hltb_cache(cache, polls, extras)
found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0)
rate = len(uncached) / elapsed if elapsed > 0 else 0
logger.info(
"HLTB fetch done: %d/%d found in %.1fs (%.0f games/s)",
found,
len(uncached),
elapsed,
rate,
)
else:
logger.info("All %d games found in HLTB cache.", len(games))
return cache
def fetch_hltb_confidence_cached(
games: list[tuple[int, str]],
progress_cb: ProgressCb | None = None,
) -> dict[int, float]:
"""Fetch HLTB search-level confidence data, using disk cache for known IDs."""
cache = load_hltb_cache()
polls = load_hltb_polls_cache()
count_comp = load_hltb_count_comp_cache()
uncached = [(app_id, name) for app_id, name in games if app_id not in cache]
if uncached:
logger.info(
"Fetching HLTB confidence for %d uncached games (%d cached)...",
len(uncached),
len(games) - len(uncached),
)
t0 = time.monotonic()
fetch_hltb_confidence(
uncached,
cache=cache,
polls=polls,
progress_cb=progress_cb,
count_comp=count_comp,
)
elapsed = time.monotonic() - t0
save_hltb_cache(cache, polls, _HLTBExtras(count_comp=count_comp))
found = sum(1 for aid, _ in uncached if cache.get(aid, -1) > 0)
rate = len(uncached) / elapsed if elapsed > 0 else 0
logger.info(
"HLTB confidence fetch done: %d/%d found in %.1fs (%.0f games/s)",
found,
len(uncached),
elapsed,
rate,
)
else:
logger.info("All %d games found in HLTB cache.", len(games))
return cache
def fetch_hltb_detail_missing(
games: list[tuple[int, str]],
progress_cb: ProgressCb | None = None,
) -> int:
"""Fetch HLTB detail (rush + leisure) for games that are missing it.
Games already in the rush cache are skipped. For the rest, temporarily
removes them from the hours cache so ``fetch_hltb_times`` will visit their
detail pages. Restores prior hours for any game the re-fetch doesn't find.
Args:
games: list of (app_id, name) tuples to check.
progress_cb: optional progress callback.
Returns:
Number of games that now have rush-hour data after the fetch.
"""
rush = load_hltb_rush_cache()
missing = [(app_id, name) for app_id, name in games if rush.get(app_id, -1) <= 0]
if not missing:
return 0
cache = load_hltb_cache()
polls = load_hltb_polls_cache()
extras = _HLTBExtras(
count_comp=load_hltb_count_comp_cache(),
rush=rush,
leisure_100h=load_hltb_leisure_100h_cache(),
hltb_game_id=load_hltb_game_id_cache(),
)
# Remove from hours cache so fetch_hltb_times will visit the detail page.
prior_hours: dict[int, float] = {}
for app_id, _ in missing:
prior_hours[app_id] = cache.pop(app_id, -1.0)
logger.info(
"Fetching HLTB detail for %d games missing rush/leisure data...",
len(missing),
)
t0 = time.monotonic()
fetch_hltb_times(
missing,
cache=cache,
polls=polls,
progress_cb=progress_cb,
extras=extras,
)
elapsed = time.monotonic() - t0
# Restore prior hours for games the detail fetch didn't re-find.
for app_id, old_hours in prior_hours.items():
if old_hours > 0 and cache.get(app_id, -1.0) <= 0:
cache[app_id] = old_hours
save_hltb_cache(cache, polls, extras)
fetched = sum(1 for app_id, _ in missing if extras.rush.get(app_id, -1) > 0)
rate = len(missing) / elapsed if elapsed > 0 else 0
logger.info(
"HLTB detail fetch done: %d/%d got rush data in %.1fs (%.0f games/s)",
fetched,
len(missing),
elapsed,
rate,
)
return fetched
def get_hltb_submit_url(game_name: str) -> str | None:
"""Look up a game on HLTB and return its submit page URL.
Args:
game_name: Name of the game to search for.
Returns:
URL like ``https://howlongtobeat.com/submit/game/12345``,
or ``None`` if the game wasn't found.
"""
results = fetch_hltb_times([(0, game_name)])
if results and results[0].hltb_game_id:
return f"{HLTB_BASE_URL}/submit/game/{results[0].hltb_game_id}"
return None

View File

@ -1,41 +0,0 @@
#!/usr/bin/env bash
# Install script for Steam Backlog Enforcer.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd "$SCRIPT_DIR/../../.." && pwd)"
echo "=== Steam Backlog Enforcer Installer ==="
echo
# Install Python deps.
echo "Installing Python dependencies..."
pip3 install --break-system-packages requests howlongtobeatpy 2>/dev/null \
|| pip3 install requests howlongtobeatpy
# Install systemd service (system-level, runs as root).
read -rp "Install systemd enforce service? [y/N] " ans
if [[ "${ans,,}" == "y" ]]; then
if [[ $EUID -ne 0 ]]; then
echo "Error: systemd service install needs root. Re-run with sudo."
exit 1
fi
SERVICE_SRC="$SCRIPT_DIR/steam-backlog-enforcer.service"
SERVICE_DST="/etc/systemd/system/steam-backlog-enforcer.service"
# Set the correct working directory in the service file.
sed "s|WorkingDirectory=.*|WorkingDirectory=$REPO_ROOT|" "$SERVICE_SRC" \
> "$SERVICE_DST"
systemctl daemon-reload
systemctl enable steam-backlog-enforcer
echo "Service installed and enabled."
echo " Start now: sudo systemctl start steam-backlog-enforcer"
echo " Check: sudo systemctl status steam-backlog-enforcer"
echo " Logs: sudo journalctl -u steam-backlog-enforcer -f"
fi
echo
echo "Done! Run manually with:"
echo " sudo python3 -m python_pkg.steam_backlog_enforcer.main enforce"

View File

@ -1,387 +0,0 @@
"""Hide / unhide games in the Steam library via Chrome DevTools Protocol.
Modern Steam clients (2023+) use an internal ``collectionStore`` JS
object running inside the CEF (Chromium Embedded Framework) browser.
Game collections (including "hidden") are synced to Steam Cloud and
can only be reliably modified through this API.
This module connects to Steam's ``SharedJSContext`` page over CDP
(Chrome DevTools Protocol) on a local debug port and evaluates
JavaScript to call ``collectionStore.SetAppsAsHidden()``.
Steam must be running with ``-cef-enable-debugging`` and
``-devtools-port=<PORT>`` for this to work. If it isn't, the module
will shut Steam down and relaunch it with the required flags.
"""
from __future__ import annotations
import asyncio
import json
import logging
import os
import pwd
import shutil
import subprocess
import time
import requests
import websockets
logger = logging.getLogger(__name__)
_CDP_PORT = 8080
_CDP_TIMEOUT = 120
_STEAM_STARTUP_WAIT = 45
# ──────────────────────────────────────────────────────────────
# CDP (Chrome DevTools Protocol) helpers
# ──────────────────────────────────────────────────────────────
def _get_shared_js_ws_url() -> str | None:
"""Query the CDP HTTP endpoint and return the SharedJSContext WS URL."""
try:
resp = requests.get(f"http://127.0.0.1:{_CDP_PORT}/json", timeout=5)
targets = resp.json()
except (OSError, ValueError):
return None
for target in targets:
if target.get("title") == "SharedJSContext":
ws_url: str = target["webSocketDebuggerUrl"]
return ws_url
return None
async def _evaluate_js_async(ws_url: str, expression: str) -> dict:
"""Connect to a CDP WebSocket target and evaluate *expression*."""
async with websockets.connect(ws_url) as ws:
msg = json.dumps(
{
"id": 1,
"method": "Runtime.evaluate",
"params": {
"expression": expression,
"returnByValue": True,
"awaitPromise": True,
},
}
)
await ws.send(msg)
resp = await asyncio.wait_for(ws.recv(), timeout=_CDP_TIMEOUT)
return json.loads(resp)
def _evaluate_js(expression: str) -> dict:
"""Synchronous wrapper around :func:`_evaluate_js_async`."""
ws_url = _get_shared_js_ws_url()
if ws_url is None:
msg = "SharedJSContext not found on CDP port"
raise RuntimeError(msg)
return asyncio.run(_evaluate_js_async(ws_url, expression))
def _cdp_result_value(result: dict) -> str:
"""Extract the return value from a CDP Runtime.evaluate response."""
outer = result.get("result", {})
inner = outer.get("result", {})
if "exceptionDetails" in outer:
exc_details = outer["exceptionDetails"]
exc = exc_details.get("exception", {})
desc = (
inner.get("description")
or exc.get("description")
or exc_details.get("text")
or repr(exc_details)
)
logger.debug("CDP exception details: %s", exc_details)
msg = f"JS evaluation error: {desc}"
raise RuntimeError(msg)
value: str = inner.get("value", "")
return value
# ──────────────────────────────────────────────────────────────
# Ensure Steam is running with devtools port
# ──────────────────────────────────────────────────────────────
def _is_steam_running() -> bool:
"""Check whether any Steam process is alive."""
pgrep = shutil.which("pgrep") or "/usr/bin/pgrep"
result = subprocess.run(
[pgrep, "-x", "steam"],
capture_output=True,
check=False,
)
return result.returncode == 0
def _steam_has_debug_port() -> bool:
"""Check whether steamwebhelper is listening on the CDP port."""
return _get_shared_js_ws_url() is not None
def _wait_for_cdp_ready() -> bool:
"""Wait up to *_STEAM_STARTUP_WAIT* seconds for CDP to become ready."""
for _ in range(_STEAM_STARTUP_WAIT):
if _get_shared_js_ws_url() is not None:
return True
time.sleep(1)
return False
def _wait_for_collections_ready() -> bool:
"""Wait until ``collectionStore`` is fully initialised.
Right after Steam starts, the CDP port may be open but the
internal collection data hasn't loaded yet. Poll a lightweight
JS check until ``GetCollection`` stops throwing.
"""
js = (
"(() => { try { collectionStore.GetCollection('hidden');"
" return 'ok'; } catch(e) { return 'not_ready'; } })()"
)
for _ in range(_STEAM_STARTUP_WAIT):
try:
result = _evaluate_js(js)
if _cdp_result_value(result) == "ok":
return True
except RuntimeError:
pass
time.sleep(1)
return False
def _shutdown_steam() -> None:
"""Send ``steam -shutdown`` and wait for the process to exit."""
real_user = os.environ.get("SUDO_USER") or os.environ.get("USER")
try:
_run_as_user(["steam", "-shutdown"], real_user)
except FileNotFoundError:
return
pgrep = shutil.which("pgrep") or "/usr/bin/pgrep"
for _ in range(30):
result = subprocess.run(
[pgrep, "-x", "steam"],
capture_output=True,
check=False,
)
if result.returncode != 0:
return
time.sleep(1)
def _launch_steam_with_debug() -> None:
"""Launch Steam with CEF debugging enabled."""
real_user = os.environ.get("SUDO_USER") or os.environ.get("USER")
_run_as_user(
[
"steam",
"-cef-enable-debugging",
f"-devtools-port={_CDP_PORT}",
"-silent",
],
real_user,
)
def ensure_steam_debug_port() -> None:
"""Make sure Steam is running with the CDP debug port open.
If Steam is running without the port, it is restarted.
If Steam is not running, it is launched.
"""
if _steam_has_debug_port():
logger.debug("Steam CDP port already available.")
return
logger.info("Steam CDP port not available — (re)starting Steam...")
if _is_steam_running():
_shutdown_steam()
_launch_steam_with_debug()
if not _wait_for_cdp_ready():
msg = "Timed out waiting for Steam CDP port to become ready"
raise RuntimeError(msg)
logger.info("Steam CDP port ready.")
if not _wait_for_collections_ready():
msg = "Timed out waiting for Steam collections to initialise"
raise RuntimeError(msg)
logger.info("Steam collection store ready.")
# ──────────────────────────────────────────────────────────────
# Hide / unhide logic
# ──────────────────────────────────────────────────────────────
_HIDE_BATCH_SIZE = 50
_MAX_HIDE_PASSES = 30
_SETTLE_DELAY_MS = 200
def hide_other_games(
owned_app_ids: list[int],
allowed_app_id: int | None,
) -> int:
"""Hide every game except *allowed_app_id* in the Steam library.
Uses the Chrome DevTools Protocol to call
``collectionStore.SetAppsAsHidden()`` in Steam's JS context.
The entire retry loop runs inside a single JS evaluation to avoid
WebSocket round-trip overhead. ``SetAppsAsHidden`` is unreliable
in a single pass for large libraries, so the JS loop retries until
``visibleApps`` converges to only the allowed game.
On the first pass, caller-provided *owned_app_ids* are included to
cover games that might not yet appear in ``visibleApps`` due to
stale MobX state.
Returns the total number of games hidden across all passes.
"""
ensure_steam_debug_port()
allowed_js = str(allowed_app_id) if allowed_app_id is not None else "null"
extra_ids = sorted(aid for aid in owned_app_ids if aid != allowed_app_id)
extra_json = json.dumps(extra_ids)
js = f"""
(async () => {{
const allowed = {allowed_js};
const coll = collectionStore.allGamesCollection;
const extraIds = {extra_json};
let totalHidden = 0;
const maxPasses = {_MAX_HIDE_PASSES};
const batchSize = {_HIDE_BATCH_SIZE};
async function safeHide(ids) {{
if (ids.length === 0) return 0;
try {{
await collectionStore.SetAppsAsHidden(ids, true);
return ids.length;
}} catch(e) {{
if (ids.length === 1) return 0;
const mid = Math.floor(ids.length / 2);
return (await safeHide(ids.slice(0, mid))) +
(await safeHide(ids.slice(mid)));
}}
}}
for (let pass = 0; pass < maxPasses; pass++) {{
let visible = coll && coll.visibleApps
? coll.visibleApps.map(a => a.appid).filter(id => id !== allowed)
: [];
if (pass === 0) {{
const visSet = new Set(visible);
for (const id of extraIds) {{
if (!visSet.has(id)) visible.push(id);
}}
}}
if (visible.length === 0) break;
for (let i = 0; i < visible.length; i += batchSize) {{
const batch = visible.slice(i, i + batchSize);
totalHidden += await safeHide(batch);
}}
await new Promise(r => setTimeout(r, {_SETTLE_DELAY_MS}));
}}
if (allowed !== null) {{
await collectionStore.SetAppsAsHidden([allowed], false);
}}
return JSON.stringify({{ totalHidden }});
}})()
"""
result = _evaluate_js(js)
value = _cdp_result_value(result)
parsed = json.loads(value)
count: int = parsed["totalHidden"]
logger.info("Hid %d games via CDP.", count)
return count
def unhide_all_games(owned_app_ids: list[int]) -> int:
"""Remove all games from the hidden collection.
Returns the number of games that were unhidden.
"""
ensure_steam_debug_port()
json.dumps(sorted(owned_app_ids))
js = """
(async () => {
const hidden = collectionStore.GetCollection('hidden');
if (!hidden || !hidden.allApps) return JSON.stringify({ count: 0 });
const hiddenIds = hidden.allApps.map(a => a.appid);
if (hiddenIds.length === 0) return JSON.stringify({ count: 0 });
await collectionStore.SetAppsAsHidden(hiddenIds, false);
return JSON.stringify({ count: hiddenIds.length });
})()
"""
result = _evaluate_js(js)
value = _cdp_result_value(result)
parsed = json.loads(value)
count: int = parsed["count"]
logger.info("Unhidden %d games via CDP.", count)
return count
# ──────────────────────────────────────────────────────────────
# Steam restart helper
# ──────────────────────────────────────────────────────────────
def restart_steam() -> None:
"""Gracefully restart the Steam client with CEF debugging enabled."""
logger.info("Restarting Steam client with debug port...")
_shutdown_steam()
_launch_steam_with_debug()
if not _wait_for_cdp_ready():
logger.warning("Steam restarted but CDP port not ready.")
else:
logger.info("Steam restarted with CDP port ready.")
def _run_as_user(cmd: list[str], user: str | None) -> None:
"""Run a command, dropping to *user* if currently root."""
if os.geteuid() == 0 and user and user != "root":
try:
pw = pwd.getpwnam(user)
uid = pw.pw_uid
except KeyError:
uid = 1000
dbus_default = f"unix:path=/run/user/{uid}/bus"
dbus_addr = os.environ.get("DBUS_SESSION_BUS_ADDRESS", dbus_default)
xauth = os.environ.get("XAUTHORITY", f"/home/{user}/.Xauthority")
full_cmd = [
"sudo",
"-u",
user,
"env",
f"DISPLAY={os.environ.get('DISPLAY', ':0')}",
f"XAUTHORITY={xauth}",
f"DBUS_SESSION_BUS_ADDRESS={dbus_addr}",
*cmd,
]
else:
full_cmd = cmd
subprocess.Popen(
full_cmd,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)

View File

@ -1,443 +0,0 @@
"""Main CLI for Steam Backlog Enforcer."""
from __future__ import annotations
import logging
import sys
import time
from typing import TYPE_CHECKING
from python_pkg.steam_backlog_enforcer._cmd_done import cmd_done
from python_pkg.steam_backlog_enforcer._enforce_loop import (
do_enforce,
get_all_owned_app_ids,
)
from python_pkg.steam_backlog_enforcer._hltb_types import load_hltb_cache
from python_pkg.steam_backlog_enforcer._stats import cmd_stats
from python_pkg.steam_backlog_enforcer._whitelist import (
WHITELIST_COOLDOWN_SECONDS,
add_pending_exception,
list_pending_exceptions,
validate_reason,
)
from python_pkg.steam_backlog_enforcer.config import (
Config,
State,
interactive_setup,
load_snapshot,
)
from python_pkg.steam_backlog_enforcer.game_install import (
_echo,
get_installed_games,
install_game,
is_game_installed,
is_protected_app,
uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.library_hider import (
hide_other_games,
restart_steam,
unhide_all_games,
)
from python_pkg.steam_backlog_enforcer.scanning import (
do_check,
do_scan,
pick_next_game,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
from python_pkg.steam_backlog_enforcer.store_blocker import (
block_store,
is_store_blocked,
unblock_store,
)
if TYPE_CHECKING:
from collections.abc import Callable
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s",
datefmt="%Y-%m-%d %H:%M:%S",
)
logger = logging.getLogger(__name__)
_LIST_DISPLAY_LIMIT = 50
_MIN_CLI_ARGS = 2
# ──────────────────────────────────────────────────────────────
# CLI commands
# ──────────────────────────────────────────────────────────────
def cmd_status(_config: Config, state: State) -> None:
"""Show current status."""
_echo("=== Steam Backlog Enforcer ===\n")
if state.current_app_id:
_echo(
f"Assigned game: {state.current_game_name} (AppID={state.current_app_id})"
)
else:
_echo("No game currently assigned.")
_echo(f"Finished games: {len(state.finished_app_ids)}")
_echo(f"Store blocked: {is_store_blocked()}")
# Show installed games.
installed = get_installed_games()
real_games = [(aid, n) for aid, n in installed if not is_protected_app(aid)]
_echo(f"Installed games: {len(real_games)}")
if state.current_app_id:
is_assigned_installed = any(aid == state.current_app_id for aid, _ in installed)
_echo(f"Assigned game installed: {is_assigned_installed}")
def cmd_list(_config: Config, state: State) -> None:
"""List games from the last snapshot."""
snapshot = load_snapshot()
if snapshot is None:
_echo("No snapshot found. Run 'scan' first.")
return
games = [GameInfo.from_snapshot(d) for d in snapshot]
incomplete = [g for g in games if not g.is_complete]
complete = [g for g in games if g.is_complete]
# Sort incomplete by completionist hours.
def sort_key(g: GameInfo) -> tuple[int, float]:
if g.completionist_hours > 0:
return (0, g.completionist_hours)
return (1, 0.0)
incomplete.sort(key=sort_key)
_echo(f"\n{'' * 70}")
_echo(f" INCOMPLETE ({len(incomplete)} games)")
_echo(f"{'' * 70}")
for i, g in enumerate(incomplete[:_LIST_DISPLAY_LIMIT], 1):
marker = " <<< ASSIGNED" if g.app_id == state.current_app_id else ""
hrs = f" [{g.completionist_hours:.0f}h]" if g.completionist_hours > 0 else ""
pct = f"{g.completion_pct:.0f}%"
_echo(f" {i:3d}. {g.name[:40]:<40s} {pct:>5s}{hrs}{marker}")
if len(incomplete) > _LIST_DISPLAY_LIMIT:
_echo(f" ... and {len(incomplete) - _LIST_DISPLAY_LIMIT} more")
_echo(f"\n COMPLETE: {len(complete)} games")
def cmd_unblock(_config: Config, _state: State) -> None:
"""Remove store blocking."""
if unblock_store():
_echo("Steam store unblocked.")
else:
_echo("Failed to unblock. Run with sudo.")
def cmd_buy_dlc(config: Config, state: State) -> None:
"""Temporarily unblock the store so the user can buy DLC."""
if state.current_app_id is None:
_echo("No game currently assigned.")
return
_echo(f"Current game: {state.current_game_name} (AppID={state.current_app_id})")
_echo("Unblocking Steam store for DLC purchase...")
if not unblock_store():
_echo("Failed to unblock store. Run with sudo.")
return
_echo("\nStore UNBLOCKED — buy your DLC now.")
_echo("Press Enter when you're done to re-block the store...")
input()
if config.block_store:
if block_store():
_echo("Store re-blocked. Restarting Steam to clear DNS cache...")
restart_steam()
_echo("Done.")
else:
_echo("Warning: failed to re-block store.")
def cmd_reset(config: Config, state: State) -> None:
"""Reset all state (unblock, unhide, clear assignment)."""
unblock_store()
# Unhide all games in the library.
try:
owned = get_all_owned_app_ids(config)
if owned:
count = unhide_all_games(owned)
if count:
_echo(f"Unhidden {count} games.")
except (OSError, RuntimeError, ValueError) as exc:
_echo(f"Warning: could not unhide games: {exc}")
state.current_app_id = None
state.current_game_name = ""
state.finished_app_ids = []
state.save()
_echo("State reset. Store unblocked.")
def cmd_installed(_config: Config, state: State) -> None:
"""Show installed games."""
installed = get_installed_games()
_echo(f"\nInstalled games ({len(installed)}):\n")
for app_id, name in installed:
protected = " [PROTECTED]" if is_protected_app(app_id) else ""
assigned = " <<< ASSIGNED" if app_id == state.current_app_id else ""
_echo(f" {app_id:>8d} {name}{protected}{assigned}")
def cmd_uninstall(_config: Config, state: State) -> None:
"""Uninstall all games except the assigned one."""
if state.current_app_id is None:
_echo("No game assigned. Run 'scan' first.")
return
installed = get_installed_games()
to_remove = [
(aid, n)
for aid, n in installed
if aid != state.current_app_id and not is_protected_app(aid)
]
if not to_remove:
_echo("No games to uninstall (only assigned game and runtimes installed).")
return
_echo(f"\nWill uninstall {len(to_remove)} games, keeping:")
_echo(f" - {state.current_game_name} (AppID={state.current_app_id})")
_echo(" - Steam runtimes and Proton versions\n")
_echo("Games to remove:")
for aid, name in to_remove:
_echo(f" - {name} (AppID={aid})")
_echo()
confirm = input("Type YES to confirm: ").strip()
if confirm != "YES":
_echo("Aborted.")
return
count = uninstall_other_games(state.current_app_id)
_echo(f"\nUninstalled {count} games.")
def cmd_setup(_config: Config, _state: State) -> None:
"""Run interactive setup."""
interactive_setup()
_MIN_ADD_EXCEPTION_ARGS = 3
_ADD_EXCEPTION_USAGE = (
'Usage: add-exception <app_id> --reason "<justification>"\n'
" app_id : numeric Steam application ID\n"
" --reason : genuine justification (>= 5 words)\n\n"
"Example:\n"
" add-exception 440 --reason "
'"TF2 is needed for a community event this weekend"\n\n'
f"Exceptions become active after a {WHITELIST_COOLDOWN_SECONDS // 3600}h "
"cooldown."
)
def cmd_add_exception(args: list[str]) -> None:
"""Request a time-locked whitelist exception.
Usage: add-exception <app_id> --reason "<text>"
The exception becomes active after a 24-hour cooldown. The reason must be
a genuine justification of at least 5 words with sufficient entropy.
Args:
args: CLI argument list after the command name.
"""
if len(args) < _MIN_ADD_EXCEPTION_ARGS or "--reason" not in args:
_echo(_ADD_EXCEPTION_USAGE)
sys.exit(1)
try:
app_id = int(args[0])
except ValueError:
_echo(f"Error: app_id must be a number, got '{args[0]}'.")
sys.exit(1)
reason_idx = args.index("--reason")
reason_parts = args[reason_idx + 1 :]
if not reason_parts:
_echo("Error: --reason requires a value.")
sys.exit(1)
reason = " ".join(reason_parts)
# Show validation feedback before attempting to add.
err = validate_reason(reason)
if err is not None:
_echo(f"Invalid reason: {err}")
sys.exit(1)
try:
msg = add_pending_exception(app_id, reason)
except ValueError as exc:
_echo(f"Error: {exc}")
sys.exit(1)
_echo(msg)
# Show current pending list.
pending = list_pending_exceptions()
if pending:
_echo(f"\nPending exceptions ({len(pending)}):")
now = time.time()
for entry in pending:
aid = int(entry["app_id"])
elapsed = now - float(entry["requested_at"])
remaining = max(0.0, WHITELIST_COOLDOWN_SECONDS - elapsed)
hrs = int(remaining // 3600)
mins = int((remaining % 3600) // 60)
status = "ready" if remaining == 0.0 else f"approves in {hrs}h {mins}m"
_echo(f" AppID={aid} [{status}]")
def cmd_install(config: Config, state: State) -> None:
"""Manually trigger install of the assigned game."""
if state.current_app_id is None:
_echo("No game currently assigned. Run 'scan' first.")
return
if is_game_installed(state.current_app_id):
_echo(f"{state.current_game_name} is already installed.")
return
_echo(f"Installing {state.current_game_name} (AppID={state.current_app_id})...")
if install_game(
state.current_app_id,
state.current_game_name,
config.steam_id,
use_steam_protocol=True,
):
_echo("Done!")
else:
_echo("Failed to create install manifest.")
def cmd_hide(config: Config, state: State) -> None:
"""Hide all non-assigned games in the Steam library."""
if state.current_app_id is None:
_echo("No game assigned. Run 'scan' first.")
return
owned_ids = get_all_owned_app_ids(config)
if not owned_ids:
_echo("No owned game list available. Run 'scan' first.")
return
_echo(f"Hiding all games except {state.current_game_name}...")
hidden = hide_other_games(owned_ids, state.current_app_id)
_echo(f"Hidden {hidden} games.")
if hidden > 0:
_echo("Done! Only the assigned game should be visible in your library.")
def cmd_unhide(config: Config, _state: State) -> None:
"""Unhide all games in the Steam library."""
owned_ids = get_all_owned_app_ids(config)
if not owned_ids:
_echo("No owned game list available. Run 'scan' first.")
return
_echo("Unhiding all games...")
count = unhide_all_games(owned_ids)
_echo(f"Unhidden {count} games.")
if count > 0:
_echo("Done!")
def cmd_pick(config: Config, state: State) -> None:
"""Manually pick a new game from the shortest-first candidate list."""
snapshot_data = load_snapshot()
if not snapshot_data:
_echo("No snapshot found. Run 'scan' first.")
return
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
hltb_cache = load_hltb_cache()
for game in games:
if game.app_id in hltb_cache:
game.completionist_hours = hltb_cache[game.app_id]
pick_next_game(games, state, config)
if state.current_app_id is not None:
owned_ids = get_all_owned_app_ids(config)
if owned_ids:
hidden = hide_other_games(owned_ids, state.current_app_id)
if hidden > 0:
_echo(f"\n Library: hid {hidden} games")
COMMANDS: dict[str, tuple[str, Callable[[Config, State], object]]] = {
"scan": ("Scan library & assign a game", do_scan),
"check": ("Check assigned game completion", do_check),
"status": ("Show current status", cmd_status),
"list": ("List games from snapshot", cmd_list),
"enforce": ("Run enforcer: block, uninstall, kill, hide", do_enforce),
"install": ("Install the assigned game", cmd_install),
"hide": ("Hide all non-assigned games in library", cmd_hide),
"unhide": ("Unhide all games in library", cmd_unhide),
"unblock": ("Remove store blocking", cmd_unblock),
"buy-dlc": ("Temporarily unblock store to buy DLC", cmd_buy_dlc),
"reset": ("Reset all state", cmd_reset),
"installed": ("List installed games", cmd_installed),
"uninstall": ("Uninstall all non-assigned games", cmd_uninstall),
"setup": ("Run first-time setup", cmd_setup),
"done": ("Finish game, open HLTB, pick next", cmd_done),
"pick": ("Manually pick your next game from candidates", cmd_pick),
"stats": ("Show backlog completion-time estimates", cmd_stats),
}
# Extra commands with non-standard arg handling (shown in help but not in COMMANDS).
_EXTRA_COMMAND_DESCRIPTIONS: dict[str, str] = {
"add-exception": "Request 24h-locked whitelist exception (use --reason)",
}
_ALL_COMMANDS: dict[str, str] = {
name: desc for name, (desc, _) in COMMANDS.items()
} | _EXTRA_COMMAND_DESCRIPTIONS
def main() -> None:
"""CLI entry point."""
if len(sys.argv) < _MIN_CLI_ARGS or sys.argv[1] not in _ALL_COMMANDS:
_echo("Steam Backlog Enforcer\n")
_echo("Usage: python -m python_pkg.steam_backlog_enforcer.main <command>\n")
_echo("Commands:")
for name, desc in _ALL_COMMANDS.items():
_echo(f" {name:<14s} {desc}")
sys.exit(1)
command = sys.argv[1]
# add-exception has its own argument structure; handle before config load.
if command == "add-exception":
cmd_add_exception(sys.argv[2:])
return
config = Config.load()
if command != "setup" and not config.steam_api_key:
_echo("Not configured. Run 'setup' first.")
sys.exit(1)
state = State.load()
_, func = COMMANDS[command]
func(config, state)
if __name__ == "__main__":
main()

View File

@ -1,221 +0,0 @@
"""ProtonDB integration for Linux compatibility ratings.
Fetches game compatibility tiers from ProtonDB's public API to filter out
games that don't work well on Linux. Ratings are cached locally so repeated
lookups are free.
Tier hierarchy (best worst): native, platinum, gold, silver, bronze, borked.
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass
import json
import logging
from typing import Any
import aiohttp
from python_pkg.steam_backlog_enforcer.config import CONFIG_DIR, _atomic_write
logger = logging.getLogger(__name__)
PROTONDB_CACHE_FILE = CONFIG_DIR / "protondb_cache.json"
_PROTONDB_API = "https://www.protondb.com/api/v1/reports/summaries/{app_id}.json"
MAX_CONCURRENT = 30 # parallel requests - be polite to the CDN
HTTP_NOT_FOUND = 404
# Tier ordering from best to worst.
TIER_ORDER: dict[str, int] = {
"native": 0,
"platinum": 1,
"gold": 2,
"silver": 3,
"bronze": 4,
"borked": 5,
"pending": 6,
}
# Games at or below this tier are skipped.
MIN_PLAYABLE_TIER = "gold"
@dataclass
class ProtonDBRating:
"""ProtonDB compatibility rating for a game."""
app_id: int
tier: str = ""
trending_tier: str = ""
score: float = 0.0
confidence: str = ""
total_reports: int = 0
@property
def is_playable(self) -> bool:
"""True if the game has at least gold-tier compatibility.
A game is considered unplayable when:
- Its tier is silver, bronze, or borked.
- Both reported ratings are available, but one is below silver.
- Both reported ratings are available, but neither reaches gold.
If both ``tier`` and ``trending_tier`` exist, the acceptance rule is:
at least one rating must be gold-or-better and the other must be
silver-or-better.
"""
if not self.tier or self.tier == "pending":
return True # No data / pending → don't block; user can skip manually.
tier_rank = TIER_ORDER.get(self.tier, 99)
min_rank = TIER_ORDER[MIN_PLAYABLE_TIER]
silver_rank = TIER_ORDER["silver"]
if not self.trending_tier:
return tier_rank <= min_rank
trend_rank = TIER_ORDER.get(self.trending_tier, 99)
if tier_rank > silver_rank or trend_rank > silver_rank:
# Bronze, borked, unknown tier in either field → skip.
return False
# At least one rating must still be gold-or-better.
return not (tier_rank > min_rank and trend_rank > min_rank)
@property
def unplayable_reason(self) -> str:
"""Return a human-readable reason when ``is_playable`` is false."""
if self.is_playable:
return ""
tier_rank = TIER_ORDER.get(self.tier, 99)
TIER_ORDER[MIN_PLAYABLE_TIER]
silver_rank = TIER_ORDER["silver"]
if not self.trending_tier:
return f"tier<{MIN_PLAYABLE_TIER} ({self.tier})"
trend_rank = TIER_ORDER.get(self.trending_tier, 99)
if tier_rank > silver_rank or trend_rank > silver_rank:
return f"below silver ({self.tier}/{self.trending_tier})"
return f"no gold tier ({self.tier}/{self.trending_tier})"
def _load_cache() -> dict[str, Any]:
"""Load the on-disk ProtonDB cache."""
if PROTONDB_CACHE_FILE.exists():
data: dict[str, Any] = json.loads(
PROTONDB_CACHE_FILE.read_text(encoding="utf-8"),
)
return data
return {}
def _save_cache(cache: dict[str, Any]) -> None:
"""Persist the ProtonDB cache."""
_atomic_write(
PROTONDB_CACHE_FILE,
json.dumps(cache, indent=2) + "\n",
)
async def _fetch_one(
session: aiohttp.ClientSession,
sem: asyncio.Semaphore,
app_id: int,
) -> ProtonDBRating | None:
"""Fetch a single game's ProtonDB rating.
Returns None on network/server errors (not cached, will retry next run).
Returns ProtonDBRating with empty tier on HTTP 404 (no ProtonDB data).
"""
url = _PROTONDB_API.format(app_id=app_id)
async with sem:
try:
async with session.get(url, timeout=aiohttp.ClientTimeout(total=15)) as r:
if r.status == HTTP_NOT_FOUND:
return ProtonDBRating(app_id=app_id)
r.raise_for_status()
data = await r.json(content_type=None)
return ProtonDBRating(
app_id=app_id,
tier=data.get("tier", ""),
trending_tier=data.get("trendingTier", ""),
score=data.get("score", 0.0),
confidence=data.get("confidence", ""),
total_reports=data.get("total", 0),
)
except (aiohttp.ClientError, asyncio.TimeoutError, OSError) as e:
logger.warning("ProtonDB fetch failed for AppID=%d: %s", app_id, e)
return None # Don't cache transient failures — retry next run.
async def _fetch_batch(app_ids: list[int]) -> list[ProtonDBRating]:
"""Fetch ProtonDB ratings for a batch of app IDs concurrently."""
sem = asyncio.Semaphore(MAX_CONCURRENT)
async with aiohttp.ClientSession() as session:
tasks = [_fetch_one(session, sem, aid) for aid in app_ids]
results = await asyncio.gather(*tasks)
return [r for r in results if r is not None]
def _rating_to_dict(r: ProtonDBRating) -> dict[str, Any]:
"""Serialize a rating to a cache-friendly dict."""
return {
"tier": r.tier,
"trending_tier": r.trending_tier,
"score": r.score,
"confidence": r.confidence,
"total_reports": r.total_reports,
}
def _rating_from_cache(app_id: int, data: dict[str, Any]) -> ProtonDBRating:
"""Deserialize a rating from cached data."""
return ProtonDBRating(
app_id=app_id,
tier=data.get("tier", ""),
trending_tier=data.get("trending_tier", ""),
score=data.get("score", 0.0),
confidence=data.get("confidence", ""),
total_reports=data.get("total_reports", 0),
)
def fetch_protondb_ratings(
app_ids: list[int],
) -> dict[int, ProtonDBRating]:
"""Fetch ProtonDB ratings with local caching.
Returns a dict mapping app_id ProtonDBRating for every requested ID.
Cached results are reused; only missing IDs are fetched from the network.
"""
cache = _load_cache()
# Separate cached vs. uncached.
results: dict[int, ProtonDBRating] = {}
to_fetch: list[int] = []
for aid in app_ids:
key = str(aid)
if key in cache:
results[aid] = _rating_from_cache(aid, cache[key])
else:
to_fetch.append(aid)
if to_fetch:
logger.info(
"Fetching ProtonDB ratings for %d games (%d cached)...",
len(to_fetch),
len(results),
)
fetched = asyncio.run(_fetch_batch(to_fetch))
for r in fetched:
results[r.app_id] = r
cache[str(r.app_id)] = _rating_to_dict(r)
_save_cache(cache)
logger.info("ProtonDB: fetched %d, total cached %d", len(fetched), len(cache))
else:
logger.info("All %d ProtonDB ratings found in cache.", len(results))
return results

View File

@ -1,7 +0,0 @@
#!/usr/bin/env bash
# Launcher for the Steam Backlog Enforcer.
# Usage: ./run.sh [command] (defaults to "done" if no command given)
set -euo pipefail
cd "$(dirname "$0")/../.."
exec python -m python_pkg.steam_backlog_enforcer.main "${1:-done}"

View File

@ -1,521 +0,0 @@
"""Game scanning, selection, checking, and enforcement daemon."""
from __future__ import annotations
from datetime import datetime, timezone
import logging
import time
from typing import TYPE_CHECKING, Any
from python_pkg.steam_backlog_enforcer._hltb_types import (
load_hltb_count_comp_cache,
load_hltb_polls_cache,
)
from python_pkg.steam_backlog_enforcer._scanning_confidence import (
_apply_cached_confidence_to_candidates,
_candidate_passes_hltb_confidence,
_report_poll_confidence,
)
from python_pkg.steam_backlog_enforcer.config import (
Config,
State,
load_snapshot,
save_snapshot,
)
from python_pkg.steam_backlog_enforcer.enforcer import (
send_notification,
)
from python_pkg.steam_backlog_enforcer.game_install import (
_echo,
install_game,
is_game_installed,
uninstall_other_games,
)
from python_pkg.steam_backlog_enforcer.hltb import (
fetch_hltb_times_cached,
)
from python_pkg.steam_backlog_enforcer.protondb import (
ProtonDBRating,
fetch_protondb_ratings,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo, SteamAPIClient
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
_TAMPER_CHECK_LIMIT = 3
# ──────────────────────────────────────────────────────────────
# Scanning & game selection
# ──────────────────────────────────────────────────────────────
def do_scan(config: Config, state: State) -> list[GameInfo]:
"""Full library scan: Steam API + HLTB times."""
client = SteamAPIClient(config.steam_api_key, config.steam_id)
start = time.time()
done_count = 0
def progress(current: int, total: int) -> None:
nonlocal done_count
done_count = current
if current % 50 == 0 or current == total:
_echo(f"\r Scanning achievements: {current}/{total}", end="", flush=True)
_echo("Scanning Steam library...")
games = client.build_game_list(
progress_callback=progress,
)
elapsed = time.time() - start
_echo(f"\n Scanned {len(games)} games with achievements in {elapsed:.1f}s")
# Fetch HLTB times (cached).
incomplete = [(g.app_id, g.name) for g in games if not g.is_complete]
if incomplete:
_echo(f"Fetching HLTB completion times for {len(incomplete)} games...")
def hltb_progress(done: int, total: int, found: int, name: str) -> None:
pct = done * 100 // total
bar_w = 30
filled = bar_w * done // total
bar = "" * filled + "" * (bar_w - filled)
_echo(
f"\r HLTB [{bar}] {done}/{total} ({pct}%) "
f"| {found} found | {name[:30]:<30s}",
end="",
flush=True,
)
hltb_cache = fetch_hltb_times_cached(incomplete, progress_cb=hltb_progress)
_echo("") # newline after progress bar
polls_cache = load_hltb_polls_cache()
count_comp_cache = load_hltb_count_comp_cache()
for g in games:
hours = hltb_cache.get(g.app_id, -1)
g.completionist_hours = hours
g.comp_100_count = polls_cache.get(g.app_id, 0)
g.count_comp = count_comp_cache.get(g.app_id, 0)
found = sum(1 for h in hltb_cache.values() if h > 0)
_echo(f" HLTB data: {found} games have completion estimates")
# Save snapshot.
save_snapshot([g.to_snapshot() for g in games])
complete = [g for g in games if g.is_complete]
incomplete_games = [g for g in games if not g.is_complete]
_echo(f"\nResults: {len(complete)} complete, {len(incomplete_games)} incomplete")
# Auto-pick a game if none assigned.
if state.current_app_id is None:
pick_next_game(games, state, config)
else:
# Show confidence info for the already-assigned game too.
current = next(
(g for g in games if g.app_id == state.current_app_id),
None,
)
if current is not None:
_echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})")
_report_poll_confidence(current, games, state)
return games
# How many candidates to check per ProtonDB batch.
_PROTONDB_BATCH_SIZE = 20
def _pick_playable_candidate(
candidates: list[GameInfo],
) -> GameInfo | None:
"""Return the first candidate with an acceptable ProtonDB rating.
Checks candidates in batches (sorted by HLTB hours, shortest first).
Games rated silver-or-worse, or gold-trending-down, are skipped.
"""
offset = 0
while offset < len(candidates):
batch = candidates[offset : offset + _PROTONDB_BATCH_SIZE]
app_ids = [g.app_id for g in batch]
ratings = fetch_protondb_ratings(app_ids)
for game in batch:
rating = ratings.get(game.app_id, ProtonDBRating(app_id=game.app_id))
if rating.is_playable:
if offset > 0 or game is not batch[0]:
_echo(
f" Skipped {offset + batch.index(game)} game(s) "
f"with poor Linux compatibility"
)
return game
logger.info(
"Skipping %s (AppID=%d): ProtonDB %s (trending %s)",
game.name,
game.app_id,
rating.tier,
rating.trending_tier,
)
offset += _PROTONDB_BATCH_SIZE
return None
_PICK_LIST_SIZE = 10
_NO_CONF_MSG = (
"\nNo assignable games found "
"(HLTB confidence thresholds: comp_100 polls>=3, "
"count_comp>=15, sum>=18)."
)
def _sort_key(g: GameInfo) -> tuple[int, float]:
"""Sort by known HLTB time (shortest first), then unknown games."""
if g.completionist_hours > 0:
return (0, g.completionist_hours)
return (1, g.name.lower().encode().hex().__hash__())
def _collect_qualified_candidates(
candidates: list[GameInfo],
) -> tuple[list[GameInfo], int, int]:
"""Collect up to _PICK_LIST_SIZE playable, HLTB-confident candidates."""
qualified: list[GameInfo] = []
confidence_skipped = 0
linux_skipped = 0
for game in candidates:
if len(qualified) >= _PICK_LIST_SIZE:
break
if not _candidate_passes_hltb_confidence(game):
confidence_skipped += 1
continue
playable = _pick_playable_candidate([game])
if playable is not None:
qualified.append(playable)
else:
linux_skipped += 1
return qualified, confidence_skipped, linux_skipped
def _prompt_user_pick(qualified: list[GameInfo]) -> int:
"""Present numbered list, return 0-based index of user's choice."""
for i, g in enumerate(qualified, 1):
hours_str = (
f" (~{g.completionist_hours:.1f}h)" if g.completionist_hours > 0 else ""
)
_echo(f" {i}. {g.name} (AppID={g.app_id}){hours_str}")
while True:
raw = input("Select game number: ")
try:
idx = int(raw)
except ValueError:
_echo(f"Invalid input: {raw!r}")
continue
if idx < 1 or idx > len(qualified):
_echo(f"Out of range: {idx}")
continue
return idx - 1
def _assign_chosen_game(
chosen: GameInfo,
games: list[GameInfo],
state: State,
config: Config,
) -> None:
"""Save assignment, announce it, and handle install/uninstall."""
state.current_app_id = chosen.app_id
state.current_game_name = chosen.name
if not state.enforcement_started_at:
state.enforcement_started_at = datetime.now(timezone.utc).isoformat()
state.save()
hours_str = (
f" (~{chosen.completionist_hours:.1f}h leisure+dlc)"
if chosen.completionist_hours > 0
else ""
)
_echo(f"\n>>> ASSIGNED: {chosen.name} (AppID={chosen.app_id}){hours_str}")
_echo(
f" Progress: {chosen.unlocked_achievements}/{chosen.total_achievements}"
f" ({chosen.completion_pct:.1f}%)"
)
_report_poll_confidence(chosen, games, state)
if config.uninstall_other_games:
count = uninstall_other_games(chosen.app_id)
if count:
_echo(f"\n Uninstalled {count} non-assigned games")
if not is_game_installed(chosen.app_id):
_echo(f"\n Auto-installing {chosen.name}...")
install_game(
chosen.app_id, chosen.name, config.steam_id, use_steam_protocol=True
)
def _pick_next_game_sequential(
games: list[GameInfo],
state: State,
config: Config,
on_select: Callable[[GameInfo], bool],
) -> None:
"""Pick the next-shortest playable game, asking the user per candidate.
``on_select`` is called with each prospective pick. Returning ``True``
accepts the assignment; returning ``False`` records a 7-day skip on
``state`` for that game and the next candidate is evaluated.
"""
while True:
skip = set(state.finished_app_ids) | state.active_skipped_ids()
candidates = [g for g in games if not g.is_complete and g.app_id not in skip]
if not candidates:
_echo(_NO_CONF_MSG)
state.current_app_id = None
state.current_game_name = ""
state.save()
return
candidates.sort(key=_sort_key)
_apply_cached_confidence_to_candidates(candidates)
chosen, confidence_skipped, linux_skipped = _pick_next_shortest_candidate(
candidates
)
if chosen is None:
_echo(
_NO_CONF_MSG
if confidence_skipped > 0 and linux_skipped == 0
else "\nNo playable games left (all have poor ProtonDB ratings)!"
)
state.current_app_id = None
state.current_game_name = ""
state.save()
return
if not on_select(chosen):
state.skip_for_days(chosen.app_id, 7)
state.save()
_echo(f"\n Skipped {chosen.name} for 7 days; picking next...")
continue
_assign_chosen_game(chosen, games, state, config)
return
def pick_next_game(
games: list[GameInfo],
state: State,
config: Config,
*,
on_select: Callable[[GameInfo], bool] | None = None,
) -> None:
"""Present a ranked list of eligible games and let the user pick one.
Games are ranked by shortest completionist time first. Games with
silver-or-worse ProtonDB ratings (or gold trending downward) are
excluded as unplayable on Linux.
If ``on_select`` is provided, the legacy 10-candidate picker is
bypassed: the function instead presents the shortest playable
candidate to ``on_select`` (typically a yes/no prompt) and, if the
callback rejects it, records a 7-day skip and re-evaluates.
"""
if on_select is not None:
_pick_next_game_sequential(games, state, config, on_select)
return
skip = set(state.finished_app_ids) | state.active_skipped_ids()
candidates = [g for g in games if not g.is_complete and g.app_id not in skip]
if not candidates:
_echo(_NO_CONF_MSG)
state.current_app_id = None
state.current_game_name = ""
state.save()
return
candidates.sort(key=_sort_key)
_apply_cached_confidence_to_candidates(candidates)
qualified, confidence_skipped, linux_skipped = _collect_qualified_candidates(
candidates
)
if not qualified:
_echo(
_NO_CONF_MSG
if confidence_skipped > 0 and linux_skipped == 0
else "\nNo playable games left (all have poor ProtonDB ratings)!"
)
state.current_app_id = None
state.current_game_name = ""
state.save()
return
idx = _prompt_user_pick(qualified)
_assign_chosen_game(qualified[idx], games, state, config)
def _pick_next_shortest_candidate(
candidates: list[GameInfo],
) -> tuple[GameInfo | None, int, int]:
"""Pick next game by checking confidence one candidate at a time.
The list must be pre-sorted by desired priority (shortest first).
"""
confidence_skipped = 0
linux_skipped = 0
for game in candidates:
if not _candidate_passes_hltb_confidence(game):
confidence_skipped += 1
continue
# Reuse existing ProtonDB compatibility gate for one candidate.
playable = _pick_playable_candidate([game])
if playable is not None:
if linux_skipped > 0:
_echo(
f" Skipped {linux_skipped} game(s) with poor Linux compatibility"
)
return playable, confidence_skipped, linux_skipped
linux_skipped += 1
if linux_skipped > 0:
_echo(f" Skipped {linux_skipped} game(s) with poor Linux compatibility")
return None, confidence_skipped, linux_skipped
def _collect_top_candidates(
candidates: list[GameInfo],
n: int = 3,
) -> tuple[list[GameInfo], int, int]:
"""Collect up to n candidates that pass the Linux compatibility gate.
Args:
candidates: Pre-sorted list of candidate games.
n: Maximum number of qualified games to collect.
Returns:
Tuple of (qualified_list, conf_skipped, linux_skipped).
"""
qualified: list[GameInfo] = []
linux_skipped = 0
for game in candidates:
if len(qualified) >= n:
break
playable = _pick_playable_candidate([game])
if playable is not None:
qualified.append(playable)
else:
linux_skipped += 1
if linux_skipped > 0:
_echo(f" Skipped {linux_skipped} game(s) with poor Linux compatibility")
return qualified, 0, linux_skipped
# ──────────────────────────────────────────────────────────────
# Checking & tampering detection
# ──────────────────────────────────────────────────────────────
def do_check(config: Config, state: State) -> None:
"""Check assigned game completion status; detect tampering."""
if state.current_app_id is None:
_echo("No game currently assigned. Run 'scan' first.")
return
client = SteamAPIClient(config.steam_api_key, config.steam_id)
_echo(f"Checking {state.current_game_name} (AppID={state.current_app_id})...")
game = client.refresh_single_game(state.current_app_id, state.current_game_name)
if game is None:
_echo(" Could not fetch achievement data.")
return
_echo(
f" Progress: {game.unlocked_achievements}/{game.total_achievements}"
f" ({game.completion_pct:.1f}%)"
)
if game.is_complete:
_echo(f"\n COMPLETED: {state.current_game_name}!")
state.finished_app_ids.append(state.current_app_id)
send_notification(
"Game Complete!",
f"You finished {state.current_game_name}! Picking next game...",
)
# Load snapshot and pick next.
snapshot_data = load_snapshot()
if snapshot_data:
games = [GameInfo.from_snapshot(d) for d in snapshot_data]
pick_next_game(games, state, config)
else:
state.current_app_id = None
state.current_game_name = ""
state.save()
_echo(" Run 'scan' to pick the next game.")
else:
remaining = game.total_achievements - game.unlocked_achievements
_echo(f" {remaining} achievements remaining. Keep going!")
# Tampering detection on snapshot.
detect_tampering(config, state)
def _check_game_tampering(
client: SteamAPIClient,
entry: dict[str, Any],
state: State,
) -> tuple[str, int, int] | None:
"""Check if a single game has unexpected achievement progress.
Args:
client: Steam API client.
entry: Snapshot entry for the game.
state: Current enforcer state.
Returns:
Tuple of (name, app_id, diff) if tampering detected, else None.
"""
app_id = entry["app_id"]
if app_id == state.current_app_id:
return None
if entry["unlocked_achievements"] >= entry["total_achievements"]:
return None
if entry.get("playtime_minutes", 0) <= 0:
return None
game = client.refresh_single_game(
app_id, entry["name"], entry.get("playtime_minutes", 0)
)
if game and game.unlocked_achievements > entry["unlocked_achievements"]:
diff = game.unlocked_achievements - entry["unlocked_achievements"]
return (entry["name"], app_id, diff)
return None
def detect_tampering(config: Config, state: State) -> None:
"""Check if achievements were unlocked on non-assigned games."""
old_snapshot = load_snapshot()
if old_snapshot is None:
return
client = SteamAPIClient(config.steam_api_key, config.steam_id)
# Quick check: only re-fetch a few random non-assigned games.
suspicious: list[tuple[str, int, int]] = []
for entry in old_snapshot:
result = _check_game_tampering(client, entry, state)
if result:
suspicious.append(result)
if len(suspicious) >= _TAMPER_CHECK_LIMIT:
break
if suspicious:
_echo("\n TAMPERING DETECTED:")
for name, app_id, diff in suspicious:
_echo(f" {name} (AppID={app_id}): +{diff} new achievements!")
send_notification(
"Tampering Detected!",
f"Achievements unlocked on {len(suspicious)} non-assigned games!",
)

View File

@ -1,19 +0,0 @@
[Unit]
Description=Steam Backlog Enforcer
After=network-online.target
Wants=network-online.target
[Service]
Type=simple
WorkingDirectory=/home/kuhy/testsAndMisc
ExecStart=/usr/bin/python3 -m python_pkg.steam_backlog_enforcer.main enforce
Restart=always
RestartSec=5
Environment=PYTHONUNBUFFERED=1
Environment=PYTHONPATH=/home/kuhy/testsAndMisc:/home/kuhy/.local/lib/python3.14/site-packages
Environment=HOME=/home/kuhy
# Hardening: enforcer must not be easily killed.
OOMScoreAdjust=-900
[Install]
WantedBy=multi-user.target

View File

@ -1,281 +0,0 @@
"""Steam Web API client for fetching games and achievement data."""
from __future__ import annotations
from concurrent.futures import ThreadPoolExecutor, as_completed
from dataclasses import dataclass, field
import logging
import threading
import time
from typing import TYPE_CHECKING, Any
import requests
if TYPE_CHECKING:
from collections.abc import Callable
logger = logging.getLogger(__name__)
STEAM_API_BASE = "https://api.steampowered.com"
MAX_WORKERS = 20
@dataclass
class AchievementInfo:
"""Single achievement state."""
api_name: str
display_name: str
achieved: bool
unlock_time: int
@dataclass
class GameInfo:
"""Info about an owned Steam game."""
app_id: int
name: str
total_achievements: int
unlocked_achievements: int
playtime_minutes: int
achievements: list[AchievementInfo] = field(default_factory=list)
completionist_hours: float = -1
comp_100_count: int = 0
count_comp: int = 0
@property
def completion_pct(self) -> float:
"""Achievement completion percentage."""
if self.total_achievements == 0:
return 100.0
return (self.unlocked_achievements / self.total_achievements) * 100.0
@property
def is_complete(self) -> bool:
"""True if all achievements are unlocked."""
return (
self.total_achievements > 0
and self.unlocked_achievements >= self.total_achievements
)
def to_snapshot(self) -> dict[str, Any]:
"""Serialize to JSON-safe dict."""
return {
"app_id": self.app_id,
"name": self.name,
"total_achievements": self.total_achievements,
"unlocked_achievements": self.unlocked_achievements,
"playtime_minutes": self.playtime_minutes,
"completionist_hours": self.completionist_hours,
"comp_100_count": self.comp_100_count,
"count_comp": self.count_comp,
"achievements": [
{
"api_name": a.api_name,
"display_name": a.display_name,
"achieved": a.achieved,
"unlock_time": a.unlock_time,
}
for a in self.achievements
],
}
@classmethod
def from_snapshot(cls, data: dict[str, Any]) -> GameInfo:
"""Deserialize from a cached snapshot dict."""
achievements = [
AchievementInfo(
api_name=a["api_name"],
display_name=a.get("display_name", a["api_name"]),
achieved=a["achieved"],
unlock_time=a.get("unlock_time", 0),
)
for a in data.get("achievements", [])
]
return cls(
app_id=data["app_id"],
name=data["name"],
total_achievements=data["total_achievements"],
unlocked_achievements=data["unlocked_achievements"],
playtime_minutes=data.get("playtime_minutes", 0),
completionist_hours=data.get("completionist_hours", -1),
comp_100_count=data.get("comp_100_count", 0),
count_comp=data.get("count_comp", 0),
achievements=achievements,
)
class SteamAPIError(Exception):
"""Raised when the Steam API returns an error."""
class SteamAPIClient:
"""Client for interacting with the Steam Web API."""
def __init__(self, api_key: str, steam_id: str) -> None:
"""Initialize the Steam API client.
Args:
api_key: Steam Web API key.
steam_id: Steam64 ID of the user.
"""
self.api_key = api_key
self.steam_id = steam_id
self.session = requests.Session()
adapter = requests.adapters.HTTPAdapter(
pool_maxsize=MAX_WORKERS,
pool_connections=MAX_WORKERS,
)
self.session.mount("https://", adapter)
self.session.headers["Accept"] = "application/json"
self._rate_lock = threading.Lock()
self._request_times: list[float] = []
self._max_rps = 18
def _rate_limit(self) -> None:
"""Enforce rate limit across threads."""
while True:
with self._rate_lock:
now = time.time()
self._request_times = [t for t in self._request_times if now - t < 1.0]
if len(self._request_times) < self._max_rps:
self._request_times.append(now)
return
time.sleep(0.06)
def _get(self, url: str, params: dict[str, Any] | None = None) -> dict[str, Any]:
"""Rate-limited GET request."""
self._rate_limit()
if params is None:
params = {}
params["key"] = self.api_key
try:
resp = self.session.get(url, params=params, timeout=30)
resp.raise_for_status()
result: dict[str, Any] = resp.json()
except requests.RequestException as e:
msg = f"Steam API request failed: {e}"
raise SteamAPIError(msg) from e
else:
return result
def get_owned_games(self) -> list[dict[str, Any]]:
"""Fetch all games owned by the user."""
url = f"{STEAM_API_BASE}/IPlayerService/GetOwnedGames/v1/"
data = self._get(
url,
{
"steamid": self.steam_id,
"include_appinfo": "true",
"include_played_free_games": "true",
"format": "json",
},
)
games: list[dict[str, Any]] = data.get("response", {}).get("games", [])
logger.info("Found %d owned games.", len(games))
return games
def get_achievement_details(self, app_id: int) -> list[AchievementInfo]:
"""Fetch per-achievement detail for a game."""
url = f"{STEAM_API_BASE}/ISteamUserStats/GetPlayerAchievements/v1/"
try:
data = self._get(
url,
{
"steamid": self.steam_id,
"appid": str(app_id),
"l": "english",
"format": "json",
},
)
except SteamAPIError:
return []
stats = data.get("playerstats", {})
if not stats.get("success", False):
return []
raw: list[dict[str, Any]] = stats.get("achievements", [])
return [
AchievementInfo(
api_name=a.get("apiname", ""),
display_name=a.get("name", a.get("apiname", "")),
achieved=bool(a.get("achieved", 0)),
unlock_time=a.get("unlocktime", 0),
)
for a in raw
]
def _fetch_one_game(self, game_dict: dict[str, Any]) -> GameInfo | None:
"""Fetch achievement data for one game. Thread-safe."""
app_id = game_dict["appid"]
achievements = self.get_achievement_details(app_id)
if not achievements:
return None
name = game_dict.get("name", f"Unknown ({app_id})")
total = len(achievements)
unlocked = sum(1 for a in achievements if a.achieved)
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=game_dict.get("playtime_forever", 0),
achievements=achievements,
)
def build_game_list(
self,
progress_callback: Callable[[int, int], None] | None = None,
) -> list[GameInfo]:
"""Build full game list with achievement data (parallel)."""
owned = self.get_owned_games()
games: list[GameInfo] = []
done_count = 0
total = len(owned)
lock = threading.Lock()
with ThreadPoolExecutor(max_workers=MAX_WORKERS) as pool:
futures = {pool.submit(self._fetch_one_game, g): g for g in owned}
for future in as_completed(futures):
try:
result = future.result()
except (
KeyError,
TypeError,
ValueError,
SteamAPIError,
requests.RequestException,
):
result = None
with lock:
done_count += 1
if progress_callback:
progress_callback(done_count, total)
if result is not None:
games.append(result)
games.sort(key=lambda g: g.name.lower())
return games
def refresh_single_game(
self, app_id: int, name: str, playtime: int = 0
) -> GameInfo | None:
"""Re-fetch achievement data for one game."""
achievements = self.get_achievement_details(app_id)
if not achievements:
return None
total = len(achievements)
unlocked = sum(1 for a in achievements if a.achieved)
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=playtime,
achievements=achievements,
)

View File

@ -1,431 +0,0 @@
"""Block Steam Store access via /etc/hosts (hosts install script) and iptables.
The system uses a dedicated hosts install script at
linux_configuration/hosts/install.sh that manages /etc/hosts with:
- chattr +ia (immutable + append-only)
- read-only bind mount
- protection against removing entries (only adding is easy)
This module checks if the Steam Store domains are already blocked in
/etc/hosts. If not, it runs the hosts install.sh (which must already
contain the Steam Store entries in its heredoc). As a belt-and-suspenders
fallback, it also blocks via iptables.
"""
from __future__ import annotations
import contextlib
import logging
from pathlib import Path
import shutil
import socket
import subprocess
from python_pkg.steam_backlog_enforcer.config import (
BLOCKED_DOMAINS,
HOSTS_FILE,
)
logger = logging.getLogger(__name__)
# Path to the hosts install script (relative to repo root).
_REPO_ROOT = Path(__file__).resolve().parents[2]
HOSTS_INSTALL_SCRIPT = _REPO_ROOT / "linux_configuration" / "hosts" / "install.sh"
# iptables chain name for our blocking rules.
IPTABLES_CHAIN = "STEAM_ENFORCER"
# Resolved absolute paths for executables (avoids S607 partial-path warnings).
_SUDO = shutil.which("sudo") or "/usr/bin/sudo"
_IPTABLES = shutil.which("iptables") or "/usr/sbin/iptables"
_BASH = shutil.which("bash") or "/usr/bin/bash"
_CHATTR = shutil.which("chattr") or "/usr/bin/chattr"
_SYSTEMCTL = shutil.which("systemctl") or "/usr/bin/systemctl"
_UMOUNT = shutil.which("umount") or "/usr/bin/umount"
_MOUNT = shutil.which("mount") or "/usr/bin/mount"
_FINDMNT = shutil.which("findmnt") or "/usr/bin/findmnt"
_CP = shutil.which("cp") or "/usr/bin/cp"
_CHMOD = shutil.which("chmod") or "/usr/bin/chmod"
_TEE = shutil.which("tee") or "/usr/bin/tee"
# IP address used in /etc/hosts for blocking domains.
_HOSTS_REDIRECT_IP = ".".join(["0"] * 4)
def _sudo_write_hosts(content: str) -> None:
"""Write *content* to /etc/hosts via ``sudo tee``."""
subprocess.run(
[_SUDO, _TEE, str(HOSTS_FILE)],
input=content.encode(),
stdout=subprocess.DEVNULL,
timeout=10,
check=True,
)
def is_store_blocked() -> bool:
"""Check if Steam Store domains are blocked in /etc/hosts."""
try:
content = HOSTS_FILE.read_text(encoding="utf-8")
# Check for at least the primary store domain.
if "store.steampowered.com" in content:
# Verify it's actually blocked (not commented out).
for line in content.splitlines():
stripped = line.strip()
if (
not stripped.startswith("#")
and "store.steampowered.com" in stripped
and stripped.startswith(_HOSTS_REDIRECT_IP)
):
return True
except OSError:
pass
return _is_iptables_blocked()
def block_store() -> bool:
"""Block Steam Store: uncomment hosts entries, or run install script.
Returns True if at least one blocking method succeeded.
"""
if is_store_blocked():
logger.info("Steam Store already blocked in /etc/hosts.")
return True
# Try quick re-block (uncomment lines) first.
if _reblock_hosts() and is_store_blocked():
_block_store_iptables()
flush_dns_cache()
return True
# Fall back to the full hosts install script.
hosts_ok = _block_via_hosts_install()
ipt_ok = _block_store_iptables()
if hosts_ok or ipt_ok:
flush_dns_cache()
return True
logger.error("All store-blocking methods failed.")
return False
def _block_via_hosts_install() -> bool:
"""Run the hosts install.sh to apply /etc/hosts with Steam Store entries.
The install script handles: immutable flag removal, bind mount remounting,
writing the file, re-applying protections, and DoH disabling.
"""
if is_store_blocked():
logger.info("Steam Store already blocked in /etc/hosts.")
return True
if not HOSTS_INSTALL_SCRIPT.exists():
logger.error("hosts install script not found at %s", HOSTS_INSTALL_SCRIPT)
return False
try:
logger.info("Running hosts install script to block Steam Store...")
result = subprocess.run(
[_SUDO, _BASH, str(HOSTS_INSTALL_SCRIPT), "--no-flush-dns"],
capture_output=True,
text=True,
timeout=120,
check=False,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to run hosts install script")
return False
else:
if result.returncode == 0:
logger.info("hosts install script succeeded.")
return True
logger.error(
"hosts install script failed (rc=%d): %s",
result.returncode,
result.stderr[-500:] if result.stderr else result.stdout[-500:],
)
return False
def _is_iptables_blocked() -> bool:
"""Check if our iptables chain exists and has rules."""
try:
result = subprocess.run(
[_SUDO, _IPTABLES, "-L", IPTABLES_CHAIN, "-n"],
capture_output=True,
text=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
return False
else:
return result.returncode == 0 and "DROP" in result.stdout
def _block_store_iptables() -> bool:
"""Block Steam Store domains using iptables (IP-based)."""
try:
# Create chain if it doesn't exist.
subprocess.run(
[_SUDO, _IPTABLES, "-N", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
# Flush existing rules in our chain.
subprocess.run(
[_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=True,
)
# Resolve domains and block their IPs.
blocked_ips: set[str] = set()
for domain in BLOCKED_DOMAINS:
with contextlib.suppress(socket.gaierror):
ips = socket.getaddrinfo(domain, 443, socket.AF_INET)
for _, _, _, _, addr in ips:
blocked_ips.add(addr[0])
for ip in blocked_ips:
subprocess.run(
[
_SUDO,
_IPTABLES,
"-A",
IPTABLES_CHAIN,
"-d",
ip,
"-j",
"DROP",
],
capture_output=True,
timeout=5,
check=True,
)
# Hook our chain into OUTPUT if not already there.
result = subprocess.run(
[_SUDO, _IPTABLES, "-C", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
if result.returncode != 0:
subprocess.run(
[_SUDO, _IPTABLES, "-I", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=True,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to block store via iptables")
return False
else:
logger.info("Steam Store blocked via iptables (%d IPs).", len(blocked_ips))
return True
def unblock_store() -> bool:
"""Remove Steam Store blocks from both iptables and /etc/hosts."""
ipt_ok = _unblock_store_iptables()
hosts_ok = _unblock_hosts()
flush_dns_cache()
if not ipt_ok:
logger.warning("Failed to remove iptables rules.")
if not hosts_ok:
logger.warning("Failed to remove /etc/hosts entries.")
return ipt_ok or hosts_ok
def _unblock_store_iptables() -> bool:
"""Remove iptables-based block."""
try:
subprocess.run(
[_SUDO, _IPTABLES, "-D", "OUTPUT", "-j", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-F", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _IPTABLES, "-X", IPTABLES_CHAIN],
capture_output=True,
timeout=5,
check=False,
)
except (OSError, subprocess.SubprocessError):
logger.exception("Failed to unblock iptables")
return False
else:
logger.info("Steam Store unblocked from iptables.")
return True
def flush_dns_cache() -> None:
"""Flush the system DNS cache."""
commands = [
["systemd-resolve", "--flush-caches"],
["resolvectl", "flush-caches"],
["nscd", "--invalidate=hosts"],
]
for cmd in commands:
with contextlib.suppress(FileNotFoundError, OSError):
subprocess.run(
cmd,
capture_output=True,
timeout=5,
check=False,
)
# ──────────────────────────────────────────────────────────────
# /etc/hosts protection helpers
# ──────────────────────────────────────────────────────────────
_GUARD_SERVICES = ("hosts-bind-mount.service", "hosts-guard.path")
_LOCKED_HOSTS_COPY = Path("/usr/local/share/locked-hosts")
def _disable_hosts_protection() -> None:
"""Stop guard services, unmount bind mount, remove chattr flags."""
for svc in _GUARD_SERVICES:
subprocess.run(
[_SUDO, _SYSTEMCTL, "stop", svc],
capture_output=True,
timeout=10,
check=False,
)
# Unmount bind mount if active.
result = subprocess.run(
[_FINDMNT, str(HOSTS_FILE)],
capture_output=True,
timeout=5,
check=False,
)
if result.returncode == 0:
subprocess.run(
[_SUDO, _UMOUNT, str(HOSTS_FILE)],
capture_output=True,
timeout=5,
check=False,
)
# Remove immutable + append-only attributes.
subprocess.run(
[_SUDO, _CHATTR, "-i", "-a", str(HOSTS_FILE)],
capture_output=True,
timeout=5,
check=False,
)
def _enable_hosts_protection() -> None:
"""Re-apply chattr flags and restart guard services."""
subprocess.run(
[_SUDO, _CHMOD, "644", str(HOSTS_FILE)],
capture_output=True,
timeout=5,
check=False,
)
subprocess.run(
[_SUDO, _CHATTR, "+ia", str(HOSTS_FILE)],
capture_output=True,
timeout=5,
check=False,
)
# Update the canonical copy so the guard doesn't revert changes.
if _LOCKED_HOSTS_COPY.exists():
subprocess.run(
[_SUDO, _CP, str(HOSTS_FILE), str(_LOCKED_HOSTS_COPY)],
capture_output=True,
timeout=5,
check=False,
)
for svc in _GUARD_SERVICES:
subprocess.run(
[_SUDO, _SYSTEMCTL, "start", svc],
capture_output=True,
timeout=10,
check=False,
)
def _unblock_hosts() -> bool:
"""Comment out Steam Store entries in /etc/hosts."""
if not is_store_blocked():
logger.info("Steam Store not blocked in /etc/hosts, nothing to do.")
return True
try:
_disable_hosts_protection()
content = HOSTS_FILE.read_text(encoding="utf-8")
new_lines = []
changed = False
for line in content.splitlines(keepends=True):
stripped = line.strip()
if (
not stripped.startswith("#")
and stripped.startswith(_HOSTS_REDIRECT_IP)
and any(d in stripped for d in BLOCKED_DOMAINS)
):
new_lines.append(f"# {line}" if line.endswith("\n") else f"# {line}\n")
changed = True
else:
new_lines.append(line)
if changed:
_sudo_write_hosts("".join(new_lines))
logger.info("Commented out Steam Store entries in /etc/hosts.")
_enable_hosts_protection()
except OSError:
logger.exception("Failed to modify /etc/hosts")
return False
else:
return True
def _reblock_hosts() -> bool:
"""Uncomment Steam Store entries in /etc/hosts."""
try:
_disable_hosts_protection()
content = HOSTS_FILE.read_text(encoding="utf-8")
new_lines = []
changed = False
for line in content.splitlines(keepends=True):
stripped = line.strip()
if stripped.startswith("# ") and any(
d in stripped for d in BLOCKED_DOMAINS
):
# Remove the '# ' prefix.
uncommented = line.replace("# ", "", 1)
new_lines.append(uncommented)
changed = True
else:
new_lines.append(line)
if changed:
_sudo_write_hosts("".join(new_lines))
logger.info("Re-enabled Steam Store entries in /etc/hosts.")
_enable_hosts_protection()
except OSError:
logger.exception("Failed to modify /etc/hosts")
return False
else:
return True

View File

@ -1,155 +0,0 @@
"""Safety conftest: prevent tests from touching real Steam/config files.
Redirects all filesystem paths used by the steam_backlog_enforcer package
to temporary directories. This stops tests from accidentally:
- Deleting real game files via uninstall_other_games / uninstall_game
- Overwriting ~/.config/steam_backlog_enforcer/state.json (losing the
user's current assignment)
- Reading real appmanifest files from ~/.local/share/Steam/steamapps
- Modifying /etc/hosts via the store blocker
- Corrupting the HLTB cache on disk
- Launching real Steam or calling real subprocess commands
"""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
if TYPE_CHECKING:
from collections.abc import Iterator
from pathlib import Path
@pytest.fixture(autouse=True)
def _isolate_filesystem(tmp_path: Path) -> Iterator[None]:
"""Redirect all real filesystem paths to a temporary directory.
Individual tests that also patch these paths will simply override
this fixture's patches for the duration of their own ``with`` block.
"""
fake_config = tmp_path / "config"
fake_config.mkdir()
fake_steamapps = tmp_path / "steamapps"
fake_steamapps.mkdir()
fake_hosts = tmp_path / "hosts"
with (
# Config / state / snapshot paths (used by State.save, Config.save, etc.)
patch(
"python_pkg.steam_backlog_enforcer.config.CONFIG_DIR",
fake_config,
),
patch(
"python_pkg.steam_backlog_enforcer.config.CONFIG_FILE",
fake_config / "config.json",
),
patch(
"python_pkg.steam_backlog_enforcer.config.STATE_FILE",
fake_config / "state.json",
),
patch(
"python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE",
fake_config / "snapshot.json",
),
# Steam game manifests / install dirs
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
fake_steamapps,
),
# HLTB cache file (computed at import time from CONFIG_DIR, so
# patching CONFIG_DIR alone does not redirect it)
patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
fake_config / "hltb_cache.json",
),
# /etc/hosts (store blocker)
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
fake_hosts,
),
patch(
"python_pkg.steam_backlog_enforcer.config.HOSTS_FILE",
fake_hosts,
),
# Whitelist exception files (_whitelist module-level constants)
patch(
"python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE",
fake_config / "pending_exceptions.json",
),
patch(
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
fake_config / "approved_exceptions.json",
),
patch(
"python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG",
fake_config / "exception_audit.log",
),
# _enforce_loop imports CONFIG_FILE directly; patch the local binding so
# lock_enforcement_files() uses the tmp path instead of the real one.
patch(
"python_pkg.steam_backlog_enforcer._enforce_loop.CONFIG_FILE",
fake_config / "config.json",
),
):
yield
@pytest.fixture(autouse=True)
def _block_real_subprocesses() -> Iterator[None]:
"""Block subprocess calls that could launch real Steam or modify system.
Individual tests that need to test subprocess behaviour should
patch the specific module's ``subprocess.run`` / ``subprocess.Popen``
themselves their local patch will override this one.
"""
noop_run = MagicMock(return_value=MagicMock(returncode=1))
noop_popen = MagicMock()
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen",
noop_popen,
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
noop_run,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.Popen",
noop_popen,
),
):
yield
@pytest.fixture(autouse=True)
def _no_real_sleep() -> Iterator[None]:
"""No-op every ``time.sleep`` used by the package.
Several modules call ``time.sleep`` for Steam-launch / install-retry /
rate-limit pacing. Individual tests that need to observe sleep
behaviour can override these patches inside their own ``with`` block.
"""
noop = MagicMock()
with (
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep", noop),
patch("python_pkg.steam_backlog_enforcer.library_hider.time.sleep", noop),
patch("python_pkg.steam_backlog_enforcer.steam_api.time.sleep", noop),
patch("python_pkg.steam_backlog_enforcer._enforce_loop.time.sleep", noop),
):
yield

View File

@ -1,87 +0,0 @@
"""Tests for _cmd_done module (part 2): _prompt_keep_or_skip."""
from __future__ import annotations
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer._cmd_done import _prompt_keep_or_skip
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
class TestPromptKeepOrSkip:
"""Tests for _prompt_keep_or_skip."""
def _game(self, hours: float = 5.0) -> GameInfo:
return GameInfo(
app_id=42,
name="Test",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
completionist_hours=hours,
)
def test_non_tty_accepts_silently(self) -> None:
with patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin:
mock_stdin.isatty.return_value = False
assert _prompt_keep_or_skip(self._game()) is True
def test_yes_answers_accept(self) -> None:
for answer in ("y", "Y", "yes", "YES", ""):
with (
patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin,
patch(f"{CMD_DONE_PKG}._echo"),
patch("builtins.input", return_value=answer),
):
mock_stdin.isatty.return_value = True
assert _prompt_keep_or_skip(self._game()) is True, answer
def test_no_answers_reject(self) -> None:
for answer in ("n", "N", "no", "NO"):
with (
patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin,
patch(f"{CMD_DONE_PKG}._echo"),
patch("builtins.input", return_value=answer),
):
mock_stdin.isatty.return_value = True
assert _prompt_keep_or_skip(self._game()) is False, answer
def test_invalid_then_yes(self) -> None:
echoed: list[str] = []
with (
patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin,
patch(
f"{CMD_DONE_PKG}._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch("builtins.input", side_effect=["maybe", "y"]),
):
mock_stdin.isatty.return_value = True
assert _prompt_keep_or_skip(self._game()) is True
assert any("answer 'y' or 'n'" in line for line in echoed)
def test_eof_accepts(self) -> None:
with (
patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin,
patch(f"{CMD_DONE_PKG}._echo"),
patch("builtins.input", side_effect=EOFError),
):
mock_stdin.isatty.return_value = True
assert _prompt_keep_or_skip(self._game()) is True
def test_zero_hours_omits_hours_string(self) -> None:
echoed: list[str] = []
with (
patch(f"{CMD_DONE_PKG}.sys.stdin") as mock_stdin,
patch(
f"{CMD_DONE_PKG}._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch("builtins.input", return_value="y"),
):
mock_stdin.isatty.return_value = True
_prompt_keep_or_skip(self._game(hours=0.0))
# Without hours, the printed line should not contain "~"
assert not any("~" in line for line in echoed if "Next pick" in line)

View File

@ -1,264 +0,0 @@
"""Tests for config module."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import pytest
from python_pkg.steam_backlog_enforcer.config import (
Config,
State,
_atomic_write,
interactive_setup,
load_snapshot,
save_snapshot,
)
if TYPE_CHECKING:
from pathlib import Path
class TestAtomicWrite:
"""Tests for _atomic_write."""
def test_writes_file(self, tmp_path: Path) -> None:
target = tmp_path / "out.json"
_atomic_write(target, '{"key": "value"}\n')
assert target.read_text(encoding="utf-8") == '{"key": "value"}\n'
def test_creates_parent_dirs(self, tmp_path: Path) -> None:
target = tmp_path / "sub" / "deep" / "out.json"
_atomic_write(target, "data")
assert target.read_text(encoding="utf-8") == "data"
def test_cleanup_on_write_error(self, tmp_path: Path) -> None:
target = tmp_path / "out.json"
with (
patch(
"python_pkg.steam_backlog_enforcer.config.os.write",
side_effect=OSError("disk full"),
),
pytest.raises(OSError, match="disk full"),
):
_atomic_write(target, "data")
assert not target.exists()
tmp_files = list(tmp_path.glob("*.tmp"))
assert tmp_files == []
def test_cleanup_on_replace_error(self, tmp_path: Path) -> None:
target = tmp_path / "out.json"
with (
patch.object(
type(target),
"replace",
side_effect=OSError("no perm"),
),
pytest.raises(OSError, match="no perm"),
):
_atomic_write(target, "data")
assert not target.exists()
tmp_files = list(tmp_path.glob("*.tmp"))
assert tmp_files == []
class TestConfig:
"""Tests for Config dataclass."""
def test_defaults(self) -> None:
cfg = Config()
assert cfg.steam_api_key == ""
assert cfg.steam_id == ""
assert cfg.block_store is True
assert cfg.kill_unauthorized_games is True
assert cfg.uninstall_other_games is True
assert cfg.desktop_notifications is True
def test_save(self, tmp_path: Path) -> None:
cfg = Config(steam_api_key="abc", steam_id="123")
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file),
):
cfg.save()
data = json.loads(config_file.read_text(encoding="utf-8"))
assert data["steam_api_key"] == "abc"
assert data["steam_id"] == "123"
def test_load_existing(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "key1", "steam_id": "id1"}) + "\n",
encoding="utf-8",
)
with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == "key1"
assert cfg.steam_id == "id1"
def test_load_missing(self, tmp_path: Path) -> None:
config_file = tmp_path / "nonexistent.json"
with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == ""
def test_load_extra_fields_ignored(self, tmp_path: Path) -> None:
config_file = tmp_path / "config.json"
config_file.write_text(
json.dumps({"steam_api_key": "k", "unknown_field": 42}) + "\n",
encoding="utf-8",
)
with patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file):
cfg = Config.load()
assert cfg.steam_api_key == "k"
class TestState:
"""Tests for State dataclass."""
def test_defaults(self) -> None:
state = State()
assert state.current_app_id is None
assert state.current_game_name == ""
assert state.finished_app_ids == []
def test_save(self, tmp_path: Path) -> None:
state = State(current_app_id=100, current_game_name="TestGame")
config_dir = tmp_path / "cfg"
state_file = config_dir / "state.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file),
):
state.save()
data = json.loads(state_file.read_text(encoding="utf-8"))
assert data["current_app_id"] == 100
assert data["current_game_name"] == "TestGame"
def test_load_existing(self, tmp_path: Path) -> None:
state_file = tmp_path / "state.json"
state_file.write_text(
json.dumps(
{
"current_app_id": 50,
"current_game_name": "G",
"finished_app_ids": [1, 2],
}
)
+ "\n",
encoding="utf-8",
)
with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id == 50
assert st.finished_app_ids == [1, 2]
def test_load_missing(self, tmp_path: Path) -> None:
state_file = tmp_path / "nonexistent.json"
with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id is None
def test_load_corrupt(self, tmp_path: Path) -> None:
state_file = tmp_path / "state.json"
state_file.write_text("not valid json{{", encoding="utf-8")
with patch("python_pkg.steam_backlog_enforcer.config.STATE_FILE", state_file):
st = State.load()
assert st.current_app_id is None
assert st.current_game_name == ""
def test_skip_for_days_records_iso_timestamp(self) -> None:
state = State()
state.skip_for_days(42, 7)
assert "42" in state.skipped_until
# Round-trip parse and check ~7 days in the future.
from datetime import datetime, timezone
expiry = datetime.fromisoformat(state.skipped_until["42"])
delta = (expiry - datetime.now(timezone.utc)).total_seconds()
assert 6 * 86400 < delta <= 7 * 86400 + 1
def test_active_skipped_ids_returns_active(self) -> None:
from datetime import datetime, timedelta, timezone
state = State()
future = datetime.now(timezone.utc) + timedelta(days=3)
state.skipped_until["100"] = future.isoformat()
assert state.active_skipped_ids() == {100}
# Active entry retained.
assert "100" in state.skipped_until
def test_active_skipped_ids_prunes_expired(self) -> None:
from datetime import datetime, timedelta, timezone
state = State()
past = datetime.now(timezone.utc) - timedelta(days=1)
state.skipped_until["50"] = past.isoformat()
assert state.active_skipped_ids() == set()
assert "50" not in state.skipped_until
def test_active_skipped_ids_prunes_malformed(self) -> None:
state = State()
state.skipped_until["77"] = "not-a-date"
assert state.active_skipped_ids() == set()
assert "77" not in state.skipped_until
class TestSnapshot:
"""Tests for snapshot save/load."""
def test_save_and_load(self, tmp_path: Path) -> None:
config_dir = tmp_path / "cfg"
snap_file = config_dir / "snapshot.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file),
):
data: list[dict[str, Any]] = [{"app_id": 1, "name": "G1"}]
save_snapshot(data)
loaded = load_snapshot()
assert loaded == data
def test_load_none(self, tmp_path: Path) -> None:
snap_file = tmp_path / "nonexistent.json"
with patch("python_pkg.steam_backlog_enforcer.config.SNAPSHOT_FILE", snap_file):
assert load_snapshot() is None
class TestInteractiveSetup:
"""Tests for interactive_setup."""
def test_success(self, tmp_path: Path) -> None:
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file),
patch("builtins.input", side_effect=["mykey", "myid"]),
):
cfg = interactive_setup()
assert cfg.steam_api_key == "mykey"
assert cfg.steam_id == "myid"
assert config_file.exists()
def test_empty_api_key_exits(self) -> None:
with (
patch("builtins.input", return_value=""),
pytest.raises(SystemExit),
):
interactive_setup()
def test_empty_steam_id_exits(self, tmp_path: Path) -> None:
config_dir = tmp_path / "cfg"
config_file = config_dir / "config.json"
with (
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_DIR", config_dir),
patch("python_pkg.steam_backlog_enforcer.config.CONFIG_FILE", config_file),
patch("builtins.input", side_effect=["key", ""]),
pytest.raises(SystemExit),
):
interactive_setup()

View File

@ -1,373 +0,0 @@
"""Tests for _enforce_loop module."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer._enforce_loop import (
_enforce_auto_install,
_enforce_hide_games,
_enforce_setup,
_guard_installed_games,
_load_owned_app_ids_cache,
_save_owned_app_ids_cache,
get_all_owned_app_ids,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer._enforce_loop"
class TestGetAllOwnedAppIds:
"""Tests for get_all_owned_app_ids."""
def test_snapshot_used_when_api_fails(self) -> None:
snap = [{"app_id": 1}, {"app_id": 2}]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", side_effect=OSError("boom")),
):
assert get_all_owned_app_ids(Config()) == [1, 2]
def test_no_snapshot_falls_back_to_api(self) -> None:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [
{"appid": 10},
{"appid": 20},
]
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
):
result = get_all_owned_app_ids(
Config(steam_api_key="k", steam_id="i"),
)
assert result == [10, 20]
def test_api_fails(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(
f"{PKG}.SteamAPIClient",
side_effect=OSError("fail"),
),
):
assert get_all_owned_app_ids(Config()) == []
def test_empty_snapshot_falls_through_to_api(self) -> None:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [{"appid": 5}]
with (
patch(f"{PKG}.load_snapshot", return_value=[]),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
):
assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [5]
def test_merges_snapshot_with_api_results(self) -> None:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}]
with (
patch(
f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}]
),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
):
assert get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i")) == [
10,
20,
30,
]
def test_uses_owned_ids_cache_without_api_call(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=[{"app_id": 30}]),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20]),
patch(f"{PKG}.SteamAPIClient") as mock_client,
):
result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i"))
assert result == [10, 20, 30]
mock_client.assert_not_called()
def test_cached_ids_merge_deduplicates_entries(self) -> None:
with (
patch(
f"{PKG}.load_snapshot", return_value=[{"app_id": 20}, {"app_id": 30}]
),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=[10, 20, 20]),
patch(f"{PKG}.SteamAPIClient") as mock_client,
):
result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i"))
assert result == [10, 20, 30]
mock_client.assert_not_called()
def test_api_success_saves_owned_ids_cache(self) -> None:
mock_client = MagicMock()
mock_client.get_owned_games.return_value = [{"appid": 10}, {"appid": 20}]
with (
patch(f"{PKG}.load_snapshot", return_value=[]),
patch(f"{PKG}._load_owned_app_ids_cache", return_value=None),
patch(f"{PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{PKG}._save_owned_app_ids_cache") as mock_save,
):
result = get_all_owned_app_ids(Config(steam_api_key="k", steam_id="i"))
assert result == [10, 20]
mock_save.assert_called_once_with("i", [10, 20])
class TestOwnedIdsCacheHelpers:
"""Tests for owned app IDs cache helper functions."""
def test_load_cache_no_steam_id(self, tmp_path: Path) -> None:
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"):
assert _load_owned_app_ids_cache("") is None
def test_load_cache_missing_file(self, tmp_path: Path) -> None:
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", tmp_path / "owned.json"):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_invalid_json(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
cache_file.write_text("{invalid", encoding="utf-8")
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_wrong_steam_id(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
cache_file.write_text(
json.dumps({"steam_id": "other", "fetched_at": 1e12, "app_ids": [1]}),
encoding="utf-8",
)
with patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_stale(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
cache_file.write_text(
json.dumps({"steam_id": "sid", "fetched_at": 0, "app_ids": [1]}),
encoding="utf-8",
)
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=10_000.0),
patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_non_list_ids(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
cache_file.write_text(
json.dumps({"steam_id": "sid", "fetched_at": 10_000.0, "app_ids": 1}),
encoding="utf-8",
)
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=10_010.0),
patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
):
assert _load_owned_app_ids_cache("sid") is None
def test_load_cache_valid(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
cache_file.write_text(
json.dumps(
{"steam_id": "sid", "fetched_at": 10_000.0, "app_ids": ["1", 2]}
),
encoding="utf-8",
)
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=10_010.0),
patch(f"{PKG}._OWNED_IDS_CACHE_TTL_SECONDS", 60),
):
assert _load_owned_app_ids_cache("sid") == [1, 2]
def test_save_cache_writes_atomic_payload(self, tmp_path: Path) -> None:
cache_file = tmp_path / "owned.json"
with (
patch(f"{PKG}._OWNED_IDS_CACHE_FILE", cache_file),
patch(f"{PKG}.time.time", return_value=123.0),
patch(f"{PKG}._atomic_write") as mock_atomic,
):
_save_owned_app_ids_cache("sid", [10, 20])
mock_atomic.assert_called_once()
path_arg = mock_atomic.call_args.args[0]
payload_arg = mock_atomic.call_args.args[1]
assert path_arg == cache_file
assert '"steam_id": "sid"' in payload_arg
assert '"app_ids": [\n 10,\n 20\n ]' in payload_arg
class TestGuardInstalledGames:
"""Tests for _guard_installed_games."""
def test_removes_unauthorized(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad Game")],
),
patch(f"{PKG}.uninstall_game", return_value=True),
patch(f"{PKG}.send_notification"),
):
assert _guard_installed_games(440) == 1
def test_skips_allowed(self) -> None:
with patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2")],
):
assert _guard_installed_games(440) == 0
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(228980, "Runtime")],
),
patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980),
):
assert _guard_installed_games(440) == 0
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "Bad")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
):
assert _guard_installed_games(440) == 0
def test_allowed_none_skips(self) -> None:
assert _guard_installed_games(None) == 0
class TestEnforceSetup:
"""Tests for _enforce_setup."""
def test_block_store_success(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.block_store", return_value=True),
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
def test_block_store_fail(self) -> None:
config = Config(block_store=True, uninstall_other_games=False)
state = State()
with (
patch(f"{PKG}.block_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
assert any("FAILED" in str(c) for c in mock_echo.call_args_list)
def test_no_block_store(self) -> None:
config = Config(block_store=False, uninstall_other_games=False)
state = State()
with (
patch(f"{PKG}.block_store") as mock_block,
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
mock_block.assert_not_called()
def test_uninstall_other_games(self) -> None:
config = Config(uninstall_other_games=True, block_store=False)
state = State(current_app_id=1)
with (
patch(f"{PKG}.uninstall_other_games", return_value=3),
patch(f"{PKG}._echo"),
patch(f"{PKG}._enforce_auto_install"),
patch(f"{PKG}._enforce_hide_games"),
):
_enforce_setup(config, state)
class TestEnforceAutoInstall:
"""Tests for _enforce_auto_install."""
def test_no_app_id(self) -> None:
_enforce_auto_install(Config(), State())
def test_already_installed(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{PKG}._echo"),
):
_enforce_auto_install(Config(), state)
def test_installs_successfully(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True),
patch(f"{PKG}.send_notification"),
patch(f"{PKG}._echo"),
):
_enforce_auto_install(Config(steam_id="i"), state)
def test_install_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_auto_install(Config(steam_id="i"), state)
assert any("manually" in str(c) for c in mock_echo.call_args_list)
class TestEnforceHideGames:
"""Tests for _enforce_hide_games."""
def test_hides_some(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{PKG}.hide_other_games", return_value=2),
patch(f"{PKG}._echo"),
):
_enforce_hide_games(Config(), state)
def test_already_hidden(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.hide_other_games", return_value=0),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("already" in str(c) for c in mock_echo.call_args_list)
def test_no_owned_ids(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo") as mock_echo,
):
_enforce_hide_games(Config(), state)
assert any("skipped" in str(c) for c in mock_echo.call_args_list)

View File

@ -1,195 +0,0 @@
"""Tests for _enforce_loop module (part 2)."""
from __future__ import annotations
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer._enforce_loop import (
_enforce_loop_iteration,
do_enforce,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
PKG = "python_pkg.steam_backlog_enforcer._enforce_loop"
class TestEnforceLoopIteration:
"""Tests for _enforce_loop_iteration."""
def test_kills_unauthorized(self) -> None:
config = Config(
kill_unauthorized_games=True,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(
f"{PKG}.enforce_allowed_game",
return_value=[(1234, 999)],
),
patch(f"{PKG}.send_notification"),
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
def test_no_kill(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
mock_enforce.assert_not_called()
def test_guards_installed(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._guard_installed_games", return_value=1),
patch(f"{PKG}._echo"),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
def test_guard_removes_zero(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._guard_installed_games", return_value=0),
patch(f"{PKG}.is_game_installed", return_value=True),
):
_enforce_loop_iteration(config, state)
def test_reinstalls_missing(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game") as mock_install,
):
_enforce_loop_iteration(config, state)
mock_install.assert_called_once()
def test_no_app_id_skip_reinstall(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=None)
with (
patch(f"{PKG}.enforce_allowed_game") as mock_enforce,
patch(f"{PKG}._guard_installed_games") as mock_guard,
patch(f"{PKG}.is_game_installed") as mock_installed,
):
_enforce_loop_iteration(config, state)
mock_enforce.assert_not_called()
mock_guard.assert_not_called()
mock_installed.assert_not_called()
def test_promotes_newly_approved_exceptions(self) -> None:
"""Loop body at line 286 executes when promote returns non-empty list."""
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=True),
patch(
f"{PKG}.promote_pending_exceptions",
return_value=[440],
),
):
_enforce_loop_iteration(config, state)
class TestDoEnforce:
"""Tests for do_enforce."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
do_enforce(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_keyboard_interrupt(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=KeyboardInterrupt,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
def test_runs_iterations(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config()
fresh = State(current_app_id=1, current_game_name="G")
call_count = 0
def side_effect(*_args: object, **_kwargs: object) -> None:
nonlocal call_count
call_count += 1
if call_count >= 2:
raise KeyboardInterrupt
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", return_value=fresh),
patch(
f"{PKG}._enforce_loop_iteration",
side_effect=side_effect,
),
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
assert call_count == 2
def test_state_load_failure_continues(self) -> None:
"""Corrupt state file should not crash the daemon."""
import json as json_mod
state = State(current_app_id=1, current_game_name="G")
config = Config()
call_count = 0
def load_side_effect() -> State:
nonlocal call_count
call_count += 1
if call_count == 1:
msg = "bad"
raise json_mod.JSONDecodeError(msg, "", 0)
if call_count == 2:
raise KeyboardInterrupt
return State(current_app_id=1) # pragma: no cover
with (
patch(f"{PKG}._enforce_setup"),
patch(f"{PKG}._echo"),
patch.object(State, "load", side_effect=load_side_effect),
patch(f"{PKG}._enforce_loop_iteration") as mock_iter,
patch(f"{PKG}.time.sleep"),
):
do_enforce(config, state)
mock_iter.assert_not_called()

View File

@ -1,201 +0,0 @@
"""Tests for enforcer module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer.enforcer import (
enforce_allowed_game,
get_running_steam_game_pids,
kill_process,
send_notification,
)
if TYPE_CHECKING:
from pathlib import Path
class TestGetRunningPids:
"""Tests for get_running_steam_game_pids."""
def test_finds_steam_pid(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "12345"
pid_dir.mkdir(parents=True)
environ = b"HOME=/home/user\x00SteamAppId=440\x00PATH=/usr/bin"
(pid_dir / "environ").write_bytes(environ)
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {12345: 440}
def test_skips_non_digit_entries(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
proc_dir.mkdir(parents=True)
(proc_dir / "self").mkdir()
(proc_dir / "cpuinfo").touch()
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
def test_handles_permission_error(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "99"
pid_dir.mkdir(parents=True)
# No environ file -> OSError when reading
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
def test_skips_non_digit_steam_app_id(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "100"
pid_dir.mkdir(parents=True)
environ = b"SteamAppId=notanumber\x00"
(pid_dir / "environ").write_bytes(environ)
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
def test_no_steam_env(self, tmp_path: Path) -> None:
proc_dir = tmp_path / "proc"
pid_dir = proc_dir / "200"
pid_dir.mkdir(parents=True)
environ = b"HOME=/home/user\x00PATH=/usr/bin\x00"
(pid_dir / "environ").write_bytes(environ)
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.Path",
return_value=proc_dir,
):
result = get_running_steam_game_pids()
assert result == {}
class TestEnforceAllowedGame:
"""Tests for enforce_allowed_game."""
def test_no_violations(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 440},
):
result = enforce_allowed_game(440)
assert result == []
def test_kills_unauthorized(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570, 200: 440},
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.kill_process"
) as mock_kill,
):
result = enforce_allowed_game(440, kill_unauthorized=True)
assert result == [(100, 570)]
mock_kill.assert_called_once_with(100, 570)
def test_skips_app_id_zero(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 0},
):
result = enforce_allowed_game(440)
assert result == []
def test_detects_without_killing(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 570},
):
result = enforce_allowed_game(440, kill_unauthorized=False)
assert result == [(100, 570)]
def test_allowed_none(self) -> None:
result = enforce_allowed_game(None, kill_unauthorized=True)
assert result == []
def test_skips_protected_app_id(self) -> None:
"""Protected IDs must never be killed even if not the assigned game."""
with (
patch(
"python_pkg.steam_backlog_enforcer.enforcer.get_running_steam_game_pids",
return_value={100: 1331550, 200: 440},
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.is_protected_app",
side_effect=lambda aid: aid == 1331550,
),
patch(
"python_pkg.steam_backlog_enforcer.enforcer.kill_process"
) as mock_kill,
):
result = enforce_allowed_game(440, kill_unauthorized=True)
assert result == []
mock_kill.assert_not_called()
class TestKillProcess:
"""Tests for kill_process."""
def test_kill_success(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.enforcer.os.kill") as mock_kill:
kill_process(123, 440)
mock_kill.assert_called_once()
def test_process_already_gone(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.os.kill",
side_effect=ProcessLookupError,
):
kill_process(123, 440) # Should not raise
def test_permission_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.os.kill",
side_effect=PermissionError,
):
kill_process(123, 440) # Should not raise
class TestSendNotification:
"""Tests for send_notification."""
def test_sends(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run"
) as mock_run:
send_notification("Title", "Body")
mock_run.assert_called_once()
def test_handles_missing_notify_send(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
side_effect=FileNotFoundError,
):
send_notification("Title", "Body") # Should not raise
def test_handles_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.enforcer.subprocess.run",
side_effect=OSError,
):
send_notification("Title", "Body") # Should not raise

View File

@ -1,280 +0,0 @@
"""Tests for game_install module."""
from __future__ import annotations
import os
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.steam_backlog_enforcer.game_install import (
_assert_not_real_steam,
_echo,
_ensure_steam_running,
_get_real_user,
_get_uid_gid_for_user,
_trigger_steam_install,
is_game_installed,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer.game_install"
class TestAssertNotRealSteam:
"""Tests for the _assert_not_real_steam safety guard."""
def test_allows_tmp_path(self, tmp_path: Path) -> None:
"""Non-Steam paths pass through without raising."""
_assert_not_real_steam(tmp_path / "appmanifest_440.acf")
def test_raises_when_real_steam_not_redirected(self, tmp_path: Path) -> None:
"""Raises when path is under real Steam and STEAMAPPS_PATH is real."""
real = tmp_path / "real_steam"
real.mkdir()
fake_manifest = real / "appmanifest_440.acf"
fake_manifest.touch()
with (
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", real),
pytest.raises(RuntimeError, match="SAFETY"),
):
_assert_not_real_steam(fake_manifest)
def test_allows_when_steamapps_redirected(self, tmp_path: Path) -> None:
"""No raise when STEAMAPPS_PATH differs from _REAL_STEAMAPPS."""
real = tmp_path / "real_steam"
real.mkdir()
fake_manifest = real / "appmanifest_440.acf"
fake_manifest.touch()
redirected = tmp_path / "fake_steam"
redirected.mkdir()
with (
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", redirected),
):
_assert_not_real_steam(fake_manifest)
def test_noop_outside_pytest(self, tmp_path: Path) -> None:
"""In production (no PYTEST_CURRENT_TEST) the guard is a no-op."""
real = tmp_path / "real_steam"
real.mkdir()
fake_manifest = real / "appmanifest_440.acf"
fake_manifest.touch()
env = {k: v for k, v in os.environ.items() if k != "PYTEST_CURRENT_TEST"}
with (
patch.dict(os.environ, env, clear=True),
patch(f"{PKG}._REAL_STEAMAPPS", real),
patch(f"{PKG}.STEAMAPPS_PATH", real),
):
_assert_not_real_steam(fake_manifest)
class TestEcho:
"""Tests for _echo."""
def test_default(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo("hello")
assert capsys.readouterr().out == "hello\n"
def test_custom_end(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo("hi", end="")
assert capsys.readouterr().out == "hi"
def test_empty(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo()
assert capsys.readouterr().out == "\n"
def test_flush(self, capsys: pytest.CaptureFixture[str]) -> None:
_echo("x", flush=True)
assert capsys.readouterr().out == "x\n"
class TestTriggerSteamInstall:
"""Tests for _trigger_steam_install."""
def test_success(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run"
) as mock_run:
result = _trigger_steam_install(440, "TF2")
assert result is True
mock_run.assert_called_once()
def test_file_not_found(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=FileNotFoundError,
):
result = _trigger_steam_install(440, "TF2")
assert result is False
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=OSError,
):
result = _trigger_steam_install(440, "TF2")
assert result is False
def test_timeout(self) -> None:
import subprocess
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=subprocess.TimeoutExpired("cmd", 15),
):
result = _trigger_steam_install(440, "TF2")
assert result is False
class TestGetRealUser:
"""Tests for _get_real_user."""
def test_sudo_user(self) -> None:
with patch.dict(os.environ, {"SUDO_USER": "alice", "USER": "root"}):
assert _get_real_user() == "alice"
def test_regular_user(self) -> None:
with patch.dict(os.environ, {"USER": "bob"}, clear=False):
env = os.environ.copy()
env.pop("SUDO_USER", None)
with patch.dict(os.environ, env, clear=True):
assert _get_real_user() == "bob"
class TestGetUidGid:
"""Tests for _get_uid_gid_for_user."""
def test_known_user(self) -> None:
mock_pw = MagicMock()
mock_pw.pw_uid = 1001
mock_pw.pw_gid = 1001
with patch(
"python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam",
return_value=mock_pw,
):
assert _get_uid_gid_for_user("alice") == (1001, 1001)
def test_unknown_user(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.pwd.getpwnam",
side_effect=KeyError,
):
assert _get_uid_gid_for_user("nobody") == (1000, 1000)
class TestIsGameInstalled:
"""Tests for is_game_installed."""
def test_installed(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert is_game_installed(440) is True
def test_not_installed(self, tmp_path: Path) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert is_game_installed(440) is False
class TestEnsureSteamRunning:
"""Tests for _ensure_steam_running."""
def test_already_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
):
_ensure_steam_running()
def test_not_running_starts_as_non_root(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"
) as mock_popen,
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
mock_popen.assert_called_once()
def test_not_running_starts_as_root(self) -> None:
mock_result = MagicMock(returncode=1)
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
mock_pw.pw_gid = 1000
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"
) as mock_popen,
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_real_user",
return_value="alice",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user",
return_value=(1000, 1000),
),
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
mock_popen.assert_called_once()
def test_pgrep_not_found(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
side_effect=FileNotFoundError,
),
patch("python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen"),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch("python_pkg.steam_backlog_enforcer.game_install.time.sleep"),
):
_ensure_steam_running()
def test_steam_executable_not_found(self) -> None:
mock_result = MagicMock(returncode=1)
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.subprocess.Popen",
side_effect=FileNotFoundError,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
_ensure_steam_running()

View File

@ -1,163 +0,0 @@
"""Tests for game_install module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.game_install import (
_remove_game_dirs,
uninstall_game,
uninstall_other_games,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer.game_install"
class TestRemoveGameDirs:
"""Tests for _remove_game_dirs."""
def test_removes_install_dir(self, tmp_path: Path) -> None:
install_dir = tmp_path / "common" / "MyGame"
install_dir.mkdir(parents=True)
(install_dir / "game.exe").touch()
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(install_dir, 440)
assert result is True
assert not install_dir.exists()
def test_install_dir_none(self, tmp_path: Path) -> None:
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(None, 440)
assert result is True
def test_install_dir_not_exists(self, tmp_path: Path) -> None:
missing = tmp_path / "common" / "Missing"
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(missing, 440)
assert result is True
def test_install_dir_remove_fails(self, tmp_path: Path) -> None:
install_dir = tmp_path / "common" / "MyGame"
install_dir.mkdir(parents=True)
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.shutil.rmtree", side_effect=OSError("perm")),
):
result = _remove_game_dirs(install_dir, 440)
assert result is False
def test_removes_cache_dirs(self, tmp_path: Path) -> None:
for subdir in ("shadercache", "compatdata"):
(tmp_path / subdir / "440").mkdir(parents=True)
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = _remove_game_dirs(None, 440)
assert result is True
assert not (tmp_path / "shadercache" / "440").exists()
assert not (tmp_path / "compatdata" / "440").exists()
def test_cache_dir_remove_oserror_suppressed(self, tmp_path: Path) -> None:
(tmp_path / "shadercache" / "440").mkdir(parents=True)
call_count = 0
def fake_rmtree(_path: object, **_kw: object) -> None:
nonlocal call_count
call_count += 1
msg = "perm"
raise OSError(msg)
with (
patch(f"{PKG}.STEAMAPPS_PATH", tmp_path),
patch(f"{PKG}.shutil.rmtree", side_effect=fake_rmtree),
):
result = _remove_game_dirs(None, 440)
assert result is True
class TestUninstallGame:
"""Tests for uninstall_game."""
def test_success(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"installdir"\t\t"TF2"\n', encoding="utf-8")
install_dir = tmp_path / "common" / "TF2"
install_dir.mkdir(parents=True)
with patch(f"{PKG}.STEAMAPPS_PATH", tmp_path):
result = uninstall_game(440, "TF2")
assert result is True
def test_manifest_removal_fails(self) -> None:
mock_manifest = MagicMock()
mock_manifest.exists.return_value = True
mock_manifest.unlink.side_effect = OSError
with (
patch(f"{PKG}.STEAMAPPS_PATH", MagicMock()),
patch(f"{PKG}._read_install_dir", return_value=None),
patch(f"{PKG}._remove_manifest", return_value=False),
patch(f"{PKG}._remove_game_dirs", return_value=True),
):
result = uninstall_game(440, "TF2")
assert result is False
def test_game_dirs_removal_fails(self) -> None:
with (
patch(f"{PKG}._read_install_dir", return_value=None),
patch(f"{PKG}._remove_manifest", return_value=True),
patch(f"{PKG}._remove_game_dirs", return_value=False),
):
result = uninstall_game(440, "TF2")
assert result is False
class TestUninstallOtherGames:
"""Tests for uninstall_other_games."""
def test_keeps_allowed(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch(f"{PKG}.uninstall_game", return_value=True) as mock_uninstall,
):
count = uninstall_other_games(440)
assert count == 1
mock_uninstall.assert_called_once_with(730, "CS")
def test_skips_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(228980, "Redist")],
),
patch(f"{PKG}.uninstall_game") as mock_uninstall,
):
count = uninstall_other_games(None)
assert count == 0
mock_uninstall.assert_not_called()
def test_uninstall_fails(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(999, "GameX")],
),
patch(f"{PKG}.uninstall_game", return_value=False),
):
count = uninstall_other_games(None)
assert count == 0
def test_all_allowed_or_protected(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (228980, "Redist")],
),
patch(f"{PKG}.uninstall_game") as mock_uninstall,
):
count = uninstall_other_games(440)
assert count == 0
mock_uninstall.assert_not_called()

View File

@ -1,263 +0,0 @@
"""Tests for game_install module (part 3 — install, get, read, remove)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.game_install import (
_read_install_dir,
_remove_manifest,
get_installed_games,
install_game,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer.game_install"
class TestInstallGame:
"""Tests for install_game."""
def test_already_installed(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert install_game(440, "TF2", "steam123") is True
def test_use_steam_protocol_success(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install",
return_value=True,
),
):
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
def test_use_steam_protocol_fallback(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._trigger_steam_install",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
assert install_game(440, "TF2", "s1", use_steam_protocol=True) is True
assert (tmp_path / "appmanifest_440.acf").exists()
def test_manifest_write_as_root(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_real_user",
return_value="alice",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_uid_gid_for_user",
return_value=(1001, 1001),
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.chown"
) as mock_chown,
):
assert install_game(440, "TF2", "s1") is True
mock_chown.assert_called_once()
def test_manifest_write_failure(self, tmp_path: Path) -> None:
# Make steamapps path not writable
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path / "nonexistent" / "deep",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
assert install_game(440, "TF2", "s1") is False
def test_empty_game_name(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
):
assert install_game(440, "", "s1") is True
def test_manifest_not_root_no_chown(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=1000,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.chown"
) as mock_chown,
):
assert install_game(440, "TF2", "s1") is True
mock_chown.assert_not_called()
def test_root_user_is_root(self, tmp_path: Path) -> None:
"""When real user IS root, don't chown."""
with (
patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH",
tmp_path,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._ensure_steam_running"
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.geteuid",
return_value=0,
),
patch(
"python_pkg.steam_backlog_enforcer.game_install._get_real_user",
return_value="root",
),
patch(
"python_pkg.steam_backlog_enforcer.game_install.os.chown"
) as mock_chown,
):
assert install_game(440, "TF2", "s1") is True
mock_chown.assert_not_called()
class TestGetInstalledGames:
"""Tests for get_installed_games."""
def test_parses_manifests(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n"name"\t\t"Team Fortress 2"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == [(440, "Team Fortress 2")]
def test_no_name(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == [(440, "Unknown (440)")]
def test_empty_dir(self, tmp_path: Path) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == []
def test_no_appid_match(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"name"\t\t"NoAppId"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = get_installed_games()
assert result == []
class TestReadInstallDir:
"""Tests for _read_install_dir."""
def test_reads_dir(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"installdir"\t\t"Team Fortress 2"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
result = _read_install_dir(manifest)
assert result == tmp_path / "common" / "Team Fortress 2"
def test_no_match(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.write_text('"appid"\t\t"440"\n')
with patch(
"python_pkg.steam_backlog_enforcer.game_install.STEAMAPPS_PATH", tmp_path
):
assert _read_install_dir(manifest) is None
def test_missing_file(self, tmp_path: Path) -> None:
manifest = tmp_path / "nonexistent.acf"
assert _read_install_dir(manifest) is None
def test_os_error(self, tmp_path: Path) -> None:
manifest = MagicMock()
manifest.exists.return_value = True
manifest.read_text.side_effect = OSError
assert _read_install_dir(manifest) is None
class TestRemoveManifest:
"""Tests for _remove_manifest."""
def test_removes(self, tmp_path: Path) -> None:
manifest = tmp_path / "appmanifest_440.acf"
manifest.touch()
assert _remove_manifest(manifest, "TF2", 440) is True
assert not manifest.exists()
def test_already_gone(self, tmp_path: Path) -> None:
manifest = tmp_path / "nonexistent.acf"
assert _remove_manifest(manifest, "TF2", 440) is True
def test_os_error(self) -> None:
manifest = MagicMock()
manifest.exists.return_value = True
manifest.unlink.side_effect = OSError
assert _remove_manifest(manifest, "TF2", 440) is False

View File

@ -1,443 +0,0 @@
"""Tests for hltb module."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from python_pkg.steam_backlog_enforcer._hltb_search import (
_AuthInfo,
_build_search_payload,
_get_hltb_search_url,
_pick_best_hltb_entry,
_similarity,
)
from python_pkg.steam_backlog_enforcer.hltb import (
_get_auth_info,
load_hltb_cache,
save_hltb_cache,
)
if TYPE_CHECKING:
from pathlib import Path
class TestHltbCache:
"""Tests for HLTB cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
):
result = load_hltb_cache()
assert result == {440: 10.5}
def test_load_cache_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "nonexistent.json"
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
):
assert load_hltb_cache() == {}
def test_load_cache_corrupt(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text("not json", encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE", cache_file
):
assert load_hltb_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_types.HLTB_CACHE_FILE",
cache_file,
),
patch("python_pkg.steam_backlog_enforcer._hltb_types.CONFIG_DIR", tmp_path),
):
save_hltb_cache({440: 10.5})
assert cache_file.exists()
def test_save_cache_os_error(self, tmp_path: Path) -> None:
with patch(
"python_pkg.steam_backlog_enforcer._hltb_types._atomic_write",
side_effect=OSError("disk full"),
):
save_hltb_cache({440: 10.5}) # Should not raise
class TestGetHltbSearchUrl:
"""Tests for _get_hltb_search_url."""
def test_discovers_url(self) -> None:
mock_info = MagicMock()
mock_info.search_url = "/api/search/abc"
with patch(
"python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests"
) as mock_html:
mock_html.send_website_request_getcode.return_value = mock_info
mock_html.BASE_URL = "https://howlongtobeat.com"
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/search/abc"
def test_fallback_url(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests"
) as mock_html:
mock_html.send_website_request_getcode.return_value = None
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/finder"
def test_first_returns_none_second_returns_info(self) -> None:
mock_info = MagicMock()
mock_info.search_url = "/api/search/xyz"
with patch(
"python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests"
) as mock_html:
mock_html.send_website_request_getcode.side_effect = [None, mock_info]
mock_html.BASE_URL = "https://howlongtobeat.com"
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/search/xyz"
def test_exception_fallback(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer._hltb_search.HTMLRequests"
) as mock_html:
mock_html.send_website_request_getcode.side_effect = RuntimeError
url = _get_hltb_search_url()
assert url == "https://howlongtobeat.com/api/finder"
class TestGetAuthInfo:
"""Tests for _get_auth_info."""
def test_success(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(
return_value={"token": "abc123", "hpKey": "ign_x", "hpVal": "ff"}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result == _AuthInfo("abc123", "ign_x", "ff")
def test_success_no_hp(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"token": "abc123"})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result == _AuthInfo("abc123")
def test_no_token_key(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.json = AsyncMock(return_value={"notoken": True})
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
def test_non_200(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 500
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
def test_client_error(self) -> None:
mock_session = MagicMock()
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
ctx.__aexit__ = AsyncMock(return_value=False)
mock_session.get = MagicMock(return_value=ctx)
result = asyncio.run(
_get_auth_info("https://howlongtobeat.com/api/finder", mock_session)
)
assert result is None
class TestSimilarity:
"""Tests for _similarity."""
def test_identical(self) -> None:
assert _similarity("hello", "hello") == 1.0
def test_different(self) -> None:
assert _similarity("abc", "xyz") < 0.5
def test_case_insensitive(self) -> None:
assert _similarity("Hello", "hello") == 1.0
class TestBuildSearchPayload:
"""Tests for _build_search_payload."""
def test_returns_json(self) -> None:
payload = _build_search_payload("Half-Life 2")
data = json.loads(payload)
assert data["searchType"] == "games"
assert data["searchTerms"] == ["Half-Life", "2"]
def test_with_auth(self) -> None:
auth = _AuthInfo("t", "ign_x", "ff")
payload = _build_search_payload("TF2", auth=auth)
data = json.loads(payload)
assert data["ign_x"] == "ff"
def test_with_auth_no_hp_key(self) -> None:
auth = _AuthInfo("t")
payload = _build_search_payload("TF2", auth=auth)
data = json.loads(payload)
assert "" not in data
class TestPickBestHltbEntry:
"""Tests for _pick_best_hltb_entry."""
def test_empty(self) -> None:
assert _pick_best_hltb_entry("game", []) is None
def test_single(self) -> None:
entry: dict[str, Any] = {"game_name": "Game", "comp_100": 3600}
result = _pick_best_hltb_entry("Game", [(entry, 1.0)])
assert result is not None
assert result[0]["game_name"] == "Game"
def test_prefers_full_edition_colon(self) -> None:
demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800}
full: dict[str, Any] = {
"game_name": "FAITH: The Unholy Trinity",
"comp_100": 7200,
}
result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FAITH: The Unholy Trinity"
def test_prefers_full_edition_dash(self) -> None:
demo: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800}
full: dict[str, Any] = {"game_name": "FAITH - Complete", "comp_100": 7200}
result = _pick_best_hltb_entry("FAITH", [(demo, 1.0), (full, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FAITH - Complete"
def test_falls_back_to_highest_similarity(self) -> None:
a: dict[str, Any] = {"game_name": "ABC", "comp_100": 3600}
b: dict[str, Any] = {"game_name": "DEF", "comp_100": 7200}
result = _pick_best_hltb_entry("ABC", [(a, 0.9), (b, 0.7)])
assert result is not None
assert result[1] == 0.9
def test_prefers_non_dlc_when_available(self) -> None:
base: dict[str, Any] = {
"game_name": "Helltaker",
"game_type": "game",
"comp_100": 6846,
}
dlc: dict[str, Any] = {
"game_name": "Helltaker - Bonus Chapter: Examtaker",
"game_type": "dlc",
"comp_100": 4075,
}
result = _pick_best_hltb_entry("Helltaker", [(dlc, 0.95), (base, 0.8)])
assert result is not None
assert result[0]["game_type"] == "game"
def test_skips_prologue_subset(self) -> None:
"""A '- Prologue' entry should not beat the full game."""
full: dict[str, Any] = {
"game_name": "A Space For The Unbound",
"comp_100": 45000,
}
prologue: dict[str, Any] = {
"game_name": "A Space for the Unbound - Prologue",
"comp_100": 1680,
}
result = _pick_best_hltb_entry(
"A Space for the Unbound",
[(prologue, 0.9), (full, 0.95)],
)
assert result is not None
assert result[0]["game_name"] == "A Space For The Unbound"
def test_skips_demo_subset(self) -> None:
"""A ': Demo' entry should not beat the full game."""
full: dict[str, Any] = {"game_name": "MyGame", "comp_100": 36000}
demo: dict[str, Any] = {"game_name": "MyGame: Demo", "comp_100": 1800}
result = _pick_best_hltb_entry("MyGame", [(demo, 0.9), (full, 1.0)])
assert result is not None
assert result[0]["game_name"] == "MyGame"
def test_still_prefers_full_edition_over_demo(self) -> None:
"""A ': Full Edition' entry should still be preferred (not a subset)."""
short: dict[str, Any] = {"game_name": "FAITH", "comp_100": 1800}
full: dict[str, Any] = {
"game_name": "FAITH: The Unholy Trinity",
"comp_100": 7200,
}
result = _pick_best_hltb_entry("FAITH", [(short, 1.0), (full, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FAITH: The Unholy Trinity"
def test_exact_match_beats_unrelated_subtitle(self) -> None:
"""Exact name with more hours wins over an unrelated subtitle entry.
'Killing Floor: Toy Master' (1.2 h) must NOT beat 'Killing Floor'
(296 h) just because it starts with 'Killing Floor:'.
"""
base: dict[str, Any] = {
"game_name": "Killing Floor",
"comp_100": 1065600, # 296 h
}
spinoff: dict[str, Any] = {
"game_name": "Killing Floor: Toy Master",
"comp_100": 4320, # 1.2 h
}
result = _pick_best_hltb_entry("Killing Floor", [(spinoff, 0.7), (base, 1.0)])
assert result is not None
assert result[0]["game_name"] == "Killing Floor"
def test_alias_exact_match_beats_spinoff(self) -> None:
"""Base game found via game_alias beats a spinoff with matching prefix.
When HLTB renames a game (e.g. 'Needy Streamer Overload' ->
'NEEDY GIRL OVERDOSE'), the old name lives in game_alias. A spinoff
like 'Needy Streamer Overload: Typing of The Net' must NOT be
preferred just because its game_name starts with the search term.
"""
base: dict[str, Any] = {
"game_name": "NEEDY GIRL OVERDOSE",
"game_alias": "Needy Streamer Overload",
"comp_100": 43200, # 12 h
}
spinoff: dict[str, Any] = {
"game_name": "Needy Streamer Overload: Typing of The Net",
"comp_100": 3600, # 1 h
}
result = _pick_best_hltb_entry(
"NEEDY STREAMER OVERLOAD",
[(spinoff, 0.7), (base, 0.9)],
)
assert result is not None
assert result[0]["game_name"] == "NEEDY GIRL OVERDOSE"
def test_exact_match_beats_different_subtitled_game(self) -> None:
"""Exact 'Timberman' (26.5 h) must beat 'Timberman: The Big Adventure' (2 h).
Unlike FAITH where the short name is a demo, here the short name
is the real full game and the subtitled entry is a different, shorter
game. The exact match should win because it has more hours.
"""
base: dict[str, Any] = {
"game_name": "Timberman",
"comp_100": 95400, # 26.5 h
}
other: dict[str, Any] = {
"game_name": "Timberman: The Big Adventure",
"comp_100": 7200, # 2 h
}
timberman_vs: dict[str, Any] = {
"game_name": "Timberman VS",
"comp_100": 23400, # 6.5 h
}
result = _pick_best_hltb_entry(
"Timberman",
[(other, 0.49), (timberman_vs, 0.86), (base, 1.0)],
)
assert result is not None
assert result[0]["game_name"] == "Timberman"
def test_exact_match_wins_even_when_extended_appears_first(self) -> None:
"""Exact match wins regardless of candidate ordering."""
base: dict[str, Any] = {
"game_name": "Timberman",
"comp_100": 95400, # 26.5 h
}
other: dict[str, Any] = {
"game_name": "Timberman: The Big Adventure",
"comp_100": 7200, # 2 h
}
# Extended entry appears first in the list.
result = _pick_best_hltb_entry(
"Timberman",
[(other, 0.49), (base, 1.0)],
)
assert result is not None
assert result[0]["game_name"] == "Timberman"
def test_exact_only_no_extended(self) -> None:
"""Exact match returned when no extended entries exist at all."""
exact: dict[str, Any] = {
"game_name": "Celeste",
"comp_100": 180000, # 50 h
}
unrelated: dict[str, Any] = {
"game_name": "Unrelated Game",
"comp_100": 7200,
}
result = _pick_best_hltb_entry(
"Celeste",
[(exact, 1.0), (unrelated, 0.6)],
)
assert result is not None
assert result[0]["game_name"] == "Celeste"
def test_no_exact_no_extended_falls_back(self) -> None:
"""When no exact or extended match exists, fall to highest similarity."""
a: dict[str, Any] = {"game_name": "FooBar", "comp_100": 3600}
b: dict[str, Any] = {"game_name": "FooBaz", "comp_100": 7200}
result = _pick_best_hltb_entry("Foo", [(a, 0.7), (b, 0.8)])
assert result is not None
assert result[0]["game_name"] == "FooBaz"
def test_extended_only_no_exact(self) -> None:
"""Extended entry returned when no exact name match exists."""
extended: dict[str, Any] = {
"game_name": "Neon: Ultimate Edition",
"comp_100": 36000,
}
unrelated: dict[str, Any] = {
"game_name": "Neon Lights",
"comp_100": 3600,
}
result = _pick_best_hltb_entry(
"Neon",
[(extended, 0.6), (unrelated, 0.7)],
)
assert result is not None
assert result[0]["game_name"] == "Neon: Ultimate Edition"

View File

@ -1,569 +0,0 @@
"""Tests for HLTB internal helpers, detail fetching, and leisure times — part 3."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from typing_extensions import Self
from python_pkg.steam_backlog_enforcer._hltb_detail import (
_apply_dlc_leisure_overrides,
_as_positive_int,
_collect_dlc_relationships,
_extract_base_leisure_hours,
_extract_comp_100_avg_and_high,
_extract_dlc_relationships,
_fetch_detail_one,
_fetch_dlc_leisure_hours,
_fetch_leisure_times,
_process_game_detail,
)
from python_pkg.steam_backlog_enforcer._hltb_types import (
_SAVE_INTERVAL,
HLTBResult,
_HLTBExtras,
)
class TestInternalHelpers:
"""Tests for internal helper coverage."""
def test_as_positive_int_float(self) -> None:
assert _as_positive_int(1.9) == 1
def test_as_positive_int_invalid_type(self) -> None:
assert not _as_positive_int(object())
def test_extract_base_leisure_non_dict_game(self) -> None:
data: dict[str, Any] = {"game": [123]}
assert _extract_base_leisure_hours(data) == -1
def test_extract_base_leisure_platform_data_comp_high_is_max(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": [{"platform": "PC", "comp_high": 23760}],
}
assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2)
def test_extract_base_leisure_h_field_exceeds_platform_comp_high(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 25000}],
"platformData": [{"platform": "PC", "comp_high": 23760}],
}
assert _extract_base_leisure_hours(data) == round(25000 / 3600, 2)
def test_extract_base_leisure_max_of_multiple_platforms(self) -> None:
data: dict[str, Any] = {
"game": [{}],
"platformData": [
{"platform": "PC", "comp_high": 23760},
{"platform": "Switch", "comp_high": 18000},
],
}
assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2)
def test_extract_base_leisure_platform_data_not_list(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": "not_a_list",
}
assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2)
def test_extract_base_leisure_platform_non_dict_entry_skipped(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": ["bad", {"platform": "PC", "comp_high": 23760}],
}
assert _extract_base_leisure_hours(data) == round(23760 / 3600, 2)
def test_extract_base_leisure_platform_comp_high_zero_skipped(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 16063}],
"platformData": [{"platform": "PC", "comp_high": 0}],
}
assert _extract_base_leisure_hours(data) == round(16063 / 3600, 2)
def test_extract_base_leisure_max_of_h_fields(self) -> None:
data: dict[str, Any] = {
"game": [
{
"comp_main_h": 14951,
"comp_plus_h": 17957,
"comp_100_h": 16063,
"comp_all_h": 17959,
}
],
}
assert _extract_base_leisure_hours(data) == round(17959 / 3600, 2)
def test_extract_base_leisure_fallback_to_avg_comp_main(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_main": 10800, "comp_plus": 0, "comp_100": 0}],
}
assert _extract_base_leisure_hours(data) == round(10800 / 3600, 2)
def test_extract_dlc_relationships_skips_non_dict(self) -> None:
data: dict[str, Any] = {
"relationships": [
"bad",
{"game_type": "dlc", "game_id": 7, "comp_100": 3600},
],
}
assert _extract_dlc_relationships(data) == [(7, 1.0)]
def test_collect_dlc_relationships_ignores_non_positive_id(self) -> None:
valid = [
HLTBResult(
app_id=1,
game_name="Game",
completionist_hours=1.0,
similarity=1.0,
hltb_game_id=123,
)
]
details: list[dict[str, Any] | None] = [
{
"relationships": [
{"game_type": "dlc", "game_id": 0, "comp_100": 3600},
]
}
]
by_app, ids = _collect_dlc_relationships(valid, details)
assert by_app[1] == [(0, 1.0)]
assert ids == []
def test_apply_dlc_leisure_overrides(self) -> None:
adjusted = _apply_dlc_leisure_overrides(
base_hours=6.0,
dlc_rels=[(10, 1.0), (11, 2.0)],
dlc_hours_by_id={10: 3.0},
)
assert adjusted == 8.0
def test_fetch_dlc_leisure_hours_empty(self) -> None:
async def _run() -> dict[int, float]:
async with aiohttp.ClientSession() as session:
return await _fetch_dlc_leisure_hours(asyncio.Semaphore(1), session, [])
assert asyncio.run(_run()) == {}
def test_fetch_dlc_leisure_hours_skips_none_data(self) -> None:
async def _run() -> dict[int, float]:
async with aiohttp.ClientSession() as session:
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=None,
):
return await _fetch_dlc_leisure_hours(
asyncio.Semaphore(1),
session,
[1],
)
assert asyncio.run(_run()) == {}
def test_fetch_dlc_leisure_hours_skips_non_positive_leisure(self) -> None:
bad_dlc_data: dict[str, Any] = {
"game": [{"comp_100_h": 0, "comp_100": 0}],
"relationships": [],
}
async def _run() -> dict[int, float]:
async with aiohttp.ClientSession() as session:
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=bad_dlc_data,
):
return await _fetch_dlc_leisure_hours(
asyncio.Semaphore(1),
session,
[1],
)
assert asyncio.run(_run()) == {}
class TestExtractComp100AvgAndHigh:
"""Tests for _extract_comp_100_avg_and_high."""
def test_returns_minus_one_for_empty_game_list(self) -> None:
assert _extract_comp_100_avg_and_high({"game": []}) == (-1, -1)
def test_returns_minus_one_for_non_list_game(self) -> None:
assert _extract_comp_100_avg_and_high({"game": "bad"}) == (-1, -1)
def test_returns_minus_one_when_game0_not_dict(self) -> None:
assert _extract_comp_100_avg_and_high({"game": [42]}) == (-1, -1)
def test_returns_avg_and_high(self) -> None:
data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 10800}]}
avg_h, high_h = _extract_comp_100_avg_and_high(data)
assert avg_h == round(7200 / 3600, 2)
assert high_h == round(10800 / 3600, 2)
def test_high_falls_back_to_avg_when_zero(self) -> None:
data: dict[str, Any] = {"game": [{"comp_100": 7200, "comp_100_h": 0}]}
avg_h, high_h = _extract_comp_100_avg_and_high(data)
assert avg_h == round(7200 / 3600, 2)
assert high_h == avg_h
def test_avg_zero_returns_minus_one_avg(self) -> None:
data: dict[str, Any] = {"game": [{"comp_100": 0, "comp_100_h": 0}]}
avg_h, high_h = _extract_comp_100_avg_and_high(data)
assert avg_h == -1
assert high_h == -1
class TestProcessGameDetail:
"""Tests for _process_game_detail."""
def test_returns_leisure_rush_and_l100(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 10800, "comp_100": 7200}],
"relationships": [],
}
leisure, rush_h, l100 = _process_game_detail(data, [], {})
assert leisure == round(10800 / 3600, 2)
assert rush_h == round(7200 / 3600, 2)
assert l100 == round(10800 / 3600, 2)
def test_negative_leisure_when_no_data(self) -> None:
leisure, rush_h, l100 = _process_game_detail({"game": []}, [], {})
assert leisure == -1
assert rush_h == -1.0
assert l100 == -1.0
def test_rush_includes_dlc_fallback(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100": 7200, "comp_100_h": 0}],
"relationships": [],
}
dlc_rels = [(99, 1.5)]
_leisure, rush_h, _l100 = _process_game_detail(data, dlc_rels, {})
assert rush_h == round(7200 / 3600 + 1.5, 2)
def test_l100_uses_dlc_override(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 10800, "comp_100": 7200}],
"relationships": [],
}
dlc_rels = [(77, 2.0)]
dlc_hours_by_id = {77: 3.0}
_leisure, _rush_h, l100 = _process_game_detail(data, dlc_rels, dlc_hours_by_id)
assert l100 == round(10800 / 3600 + (3.0 - 2.0), 2)
class _FakeTextResponse:
"""Async context manager mimicking aiohttp response for text."""
def __init__(self, status: int, text: str = "") -> None:
self.status = status
self._text = text
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
pass
async def text(self) -> str:
return self._text
class TestFetchDetailOne:
"""Tests for _fetch_detail_one."""
def test_success(self) -> None:
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243}],
"relationships": [],
}
next_data = {"props": {"pageProps": {"game": {"data": game_data}}}}
html = (
'<script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(next_data)
+ "</script>"
)
resp = _FakeTextResponse(200, html)
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result == game_data
def test_non_200(self) -> None:
resp = _FakeTextResponse(404)
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None
def test_client_error(self) -> None:
ctx = AsyncMock()
ctx.__aenter__ = AsyncMock(side_effect=aiohttp.ClientError)
ctx.__aexit__ = AsyncMock(return_value=False)
session = MagicMock()
session.get = MagicMock(return_value=ctx)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None
def test_parse_failure(self) -> None:
resp = _FakeTextResponse(200, "<html>no script</html>")
session = MagicMock()
session.get = MagicMock(return_value=resp)
result = asyncio.run(_fetch_detail_one(asyncio.Semaphore(1), session, 12345))
assert result is None
class TestFetchLeisureTimes:
"""Tests for _fetch_leisure_times."""
def test_updates_cache(self) -> None:
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243}],
"relationships": [],
}
cache: dict[int, float] = {}
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=game_data,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert cache[440] == round(21243 / 3600, 2)
assert results[0].completionist_hours == round(21243 / 3600, 2)
def test_no_valid_results(self) -> None:
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=0,
),
]
cache: dict[int, float] = {}
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert not cache
def test_empty_results(self) -> None:
cache: dict[int, float] = {}
asyncio.run(_fetch_leisure_times([], cache, {}, None))
assert not cache
def test_detail_returns_none(self) -> None:
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
cache: dict[int, float] = {}
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=None,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert not cache
assert results[0].completionist_hours == 50.0
def test_negative_leisure(self) -> None:
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
game_data: dict[str, Any] = {"game": [], "relationships": []}
cache: dict[int, float] = {}
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=game_data,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
assert not cache
assert results[0].completionist_hours == 50.0
def test_with_progress_cb(self) -> None:
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [],
}
cache: dict[int, float] = {}
cb = MagicMock()
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=game_data,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, cb))
cb.assert_called_once()
def test_save_interval(self) -> None:
"""Trigger the _SAVE_INTERVAL branch in leisure fetching."""
results = [
HLTBResult(
app_id=i,
game_name=f"Game{i}",
completionist_hours=1.0,
similarity=1.0,
hltb_game_id=i + 1000,
)
for i in range(_SAVE_INTERVAL)
]
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [],
}
cache: dict[int, float] = {}
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=game_data,
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_detail.save_hltb_cache"
) as mock_save,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
mock_save.assert_called_once()
def test_dlc_detail_overrides_relationship_fallback(self) -> None:
results = [
HLTBResult(
app_id=1289310,
game_name="Helltaker",
completionist_hours=1.0,
similarity=1.0,
hltb_game_id=78118,
),
]
base_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6846}],
"relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}],
}
dlc_data: dict[str, Any] = {
"game": [{"comp_100_h": 12298, "comp_100": 4075}],
"relationships": [],
}
cache: dict[int, float] = {}
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
side_effect=[base_data, dlc_data],
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
expected = round((21243 + 12298) / 3600, 2)
assert cache[1289310] == expected
assert results[0].completionist_hours == expected
def test_missing_dlc_detail_keeps_relationship_fallback(self) -> None:
results = [
HLTBResult(
app_id=1289310,
game_name="Helltaker",
completionist_hours=1.0,
similarity=1.0,
hltb_game_id=78118,
),
]
base_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6846}],
"relationships": [{"game_type": "dlc", "game_id": 92236, "comp_100": 4075}],
}
cache: dict[int, float] = {}
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
side_effect=[base_data, None],
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None))
expected = round((21243 + 4075) / 3600, 2)
assert cache[1289310] == expected
assert results[0].completionist_hours == expected
def test_extras_populated_with_rush_and_l100(self) -> None:
"""rush_h and l100 are stored in extras when game has comp_100 data."""
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 10800, "comp_100": 7200}],
"relationships": [],
}
cache: dict[int, float] = {}
extras = _HLTBExtras(count_comp={440: 5})
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=game_data,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras))
assert extras.rush[440] == round(7200 / 3600, 2)
assert extras.leisure_100h[440] == round(10800 / 3600, 2)
def test_with_explicit_extras(self) -> None:
"""Pass a pre-populated _HLTBExtras to cover the non-None extras branch."""
results = [
HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
]
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [],
}
cache: dict[int, float] = {}
extras = _HLTBExtras(count_comp={440: 5})
with patch(
"python_pkg.steam_backlog_enforcer._hltb_detail._fetch_detail_one",
new_callable=AsyncMock,
return_value=game_data,
):
asyncio.run(_fetch_leisure_times(results, cache, {}, None, extras=extras))
assert cache[440] == 1.0

View File

@ -1,366 +0,0 @@
"""Tests for hltb module — part 2 (missing coverage)."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from typing_extensions import Self
from python_pkg.steam_backlog_enforcer._hltb_search import _AuthInfo
from python_pkg.steam_backlog_enforcer.hltb import (
HLTB_BASE_URL,
HLTBResult,
_fetch_batch_confidence_only,
fetch_hltb_confidence,
fetch_hltb_confidence_cached,
fetch_hltb_detail_missing,
fetch_hltb_times_cached,
get_hltb_submit_url,
)
if TYPE_CHECKING:
from python_pkg.steam_backlog_enforcer._hltb_types import _HLTBExtras
PKG = "python_pkg.steam_backlog_enforcer.hltb"
class TestFetchHltbTimesCached:
"""Tests for fetch_hltb_times_cached."""
def test_all_cached(self) -> None:
with (
patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}),
):
result = fetch_hltb_times_cached([(440, "TF2")])
assert result == {440: 50.0}
def test_uncached_games_fetched(self) -> None:
with (
patch(f"{PKG}.load_hltb_cache", return_value={440: 50.0}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache") as mock_save,
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]),
):
# fetch_hltb_times modifies cache in-place
def add_to_cache(
_games: object,
cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: object = None,
extras: _HLTBExtras | None = None,
) -> list[object]:
if cache is not None:
cache[730] = 20.0
if polls is not None:
polls[730] = 0
if extras is not None:
extras.count_comp[730] = 0
return []
mock_fetch.side_effect = add_to_cache
result = fetch_hltb_times_cached(
[(440, "TF2"), (730, "CS")],
)
assert result[440] == 50.0
assert result[730] == 20.0
mock_save.assert_called_once()
def test_uncached_with_progress_cb(self) -> None:
cb = MagicMock()
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]),
):
mock_fetch.return_value = []
result = fetch_hltb_times_cached(
[(440, "TF2")],
progress_cb=cb,
)
assert 440 not in result or result.get(440) == -1
def test_uncached_zero_elapsed(self) -> None:
"""Covers the elapsed == 0 branch for rate calculation."""
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]),
):
mock_fetch.return_value = []
fetch_hltb_times_cached([(440, "TF2")])
def test_found_count(self) -> None:
"""Covers the found count in logging."""
with (
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 3.0]),
):
def add_found(
_games: object,
cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: object = None,
extras: _HLTBExtras | None = None,
) -> list[object]:
if cache is not None:
cache[440] = 50.0
cache[730] = -1
if polls is not None:
polls[440] = 5
polls[730] = 0
if extras is not None:
extras.count_comp[440] = 15
extras.count_comp[730] = 0
return []
mock_fetch.side_effect = add_found
result = fetch_hltb_times_cached(
[(440, "TF2"), (730, "CS")],
)
assert result[440] == 50.0
assert result[730] == -1
class TestGetHltbSubmitUrl:
"""Tests for get_hltb_submit_url."""
def test_found(self) -> None:
mock_result = HLTBResult(
app_id=0,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
)
with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]):
url = get_hltb_submit_url("TF2")
assert url == f"{HLTB_BASE_URL}/submit/game/12345"
def test_not_found_empty(self) -> None:
with patch(f"{PKG}.fetch_hltb_times", return_value=[]):
url = get_hltb_submit_url("Unknown Game")
assert url is None
def test_not_found_no_id(self) -> None:
mock_result = HLTBResult(
app_id=0,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=0,
)
with patch(f"{PKG}.fetch_hltb_times", return_value=[mock_result]):
url = get_hltb_submit_url("TF2")
assert url is None
class _DummySession:
"""Minimal async context manager used to mock aiohttp ClientSession."""
async def __aenter__(self) -> Self:
"""Enter async context."""
return self
async def __aexit__(self, *_args: object) -> bool:
"""Exit async context."""
return False
class TestConfidenceHelpers:
"""Coverage tests for confidence-fetch helpers."""
def test_fetch_batch_confidence_only_returns_empty_without_auth(self) -> None:
with (
patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()),
patch(f"{PKG}.aiohttp.TCPConnector"),
patch(f"{PKG}._get_hltb_search_url", return_value="https://example"),
patch(f"{PKG}._get_auth_info", return_value=None),
):
result = asyncio.run(
_fetch_batch_confidence_only([(1, "Game")], {}, {}, None),
)
assert result == []
def test_fetch_batch_confidence_only_handles_empty_hp_and_default_counts(
self,
) -> None:
auth_token = str(1)
with (
patch(f"{PKG}.aiohttp.ClientSession", return_value=_DummySession()),
patch(f"{PKG}.aiohttp.TCPConnector"),
patch(f"{PKG}._get_hltb_search_url", return_value="https://example"),
patch(
f"{PKG}._get_auth_info",
return_value=_AuthInfo(token=auth_token, hp_key="", hp_val=""),
),
patch(f"{PKG}._search_one", side_effect=[None]) as mock_search,
):
result = asyncio.run(
_fetch_batch_confidence_only(
games=[(1, "Game")],
cache={},
polls={},
progress_cb=None,
count_comp=None,
),
)
assert result == []
mock_search.assert_called_once()
def test_fetch_hltb_confidence_initializes_optional_dicts(self) -> None:
with patch(f"{PKG}.asyncio.run", return_value=[]) as mock_run:
result = fetch_hltb_confidence([(1, "Game")])
assert result == []
mock_run.assert_called_once()
def test_fetch_hltb_confidence_empty_games_returns_empty(self) -> None:
with patch(f"{PKG}.asyncio.run") as mock_run:
result = fetch_hltb_confidence([])
assert result == []
mock_run.assert_not_called()
def test_fetch_hltb_confidence_cached_all_cached_skips_fetch(self) -> None:
with (
patch(f"{PKG}.load_hltb_cache", return_value={1: 12.0}),
patch(f"{PKG}.load_hltb_polls_cache", return_value={1: 30}),
patch(f"{PKG}.load_hltb_count_comp_cache", return_value={1: 200}),
patch(f"{PKG}.fetch_hltb_confidence") as mock_fetch,
patch(f"{PKG}.save_hltb_cache") as mock_save,
):
result = fetch_hltb_confidence_cached([(1, "Game")])
assert result == {1: 12.0}
mock_fetch.assert_not_called()
mock_save.assert_not_called()
class TestFetchHltbDetailMissing:
"""Tests for fetch_hltb_detail_missing."""
def test_no_missing_returns_zero(self) -> None:
"""All games in rush cache → early return without fetching."""
with (
patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}),
patch(f"{PKG}.fetch_hltb_times") as mock_fetch,
):
result = fetch_hltb_detail_missing([(440, "TF2")])
assert result == 0
mock_fetch.assert_not_called()
def test_fetches_missing_and_returns_count(self) -> None:
"""Games not in rush cache are fetched; returns count with rush data."""
def add_rush(
_games: object,
cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: object = None,
extras: _HLTBExtras | None = None,
) -> list[object]:
if extras is not None:
extras.rush[730] = 10.0
if cache is not None:
cache[730] = 25.0
return []
with (
patch(f"{PKG}.load_hltb_rush_cache", return_value={440: 15.0}),
patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}),
patch(f"{PKG}.load_hltb_polls_cache", return_value={}),
patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}),
patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times", side_effect=add_rush),
patch(f"{PKG}.save_hltb_cache") as mock_save,
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 2.0]),
):
result = fetch_hltb_detail_missing([(440, "TF2"), (730, "CS")])
assert result == 1
mock_save.assert_called_once()
def test_restores_prior_hours_when_not_refound(self) -> None:
"""Hours are restored when re-fetch finds nothing for the game."""
saved: dict[int, float] = {}
def capture_save(
cache: dict[int, float],
_polls: object,
_extras: object = None,
) -> None:
saved.update(cache)
with (
patch(f"{PKG}.load_hltb_rush_cache", return_value={}),
patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}),
patch(f"{PKG}.load_hltb_polls_cache", return_value={}),
patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}),
patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times"), # no-op, cache stays empty
patch(f"{PKG}.save_hltb_cache", side_effect=capture_save),
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]),
):
fetch_hltb_detail_missing([(730, "CS")])
assert saved[730] == 20.0
def test_does_not_restore_when_refound(self) -> None:
"""Prior hours are NOT restored when re-fetch successfully finds game."""
def add_hours_and_rush(
_games: object,
cache: dict[int, float] | None = None,
polls: dict[int, int] | None = None,
progress_cb: object = None,
extras: _HLTBExtras | None = None,
) -> list[object]:
if cache is not None:
cache[730] = 30.0
if extras is not None:
extras.rush[730] = 12.0
return []
saved: dict[int, float] = {}
def capture_save(
cache: dict[int, float],
_polls: object,
_extras: object = None,
) -> None:
saved.update(cache)
with (
patch(f"{PKG}.load_hltb_rush_cache", return_value={}),
patch(f"{PKG}.load_hltb_cache", return_value={730: 20.0}),
patch(f"{PKG}.load_hltb_polls_cache", return_value={}),
patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}),
patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times", side_effect=add_hours_and_rush),
patch(f"{PKG}.save_hltb_cache", side_effect=capture_save),
patch(f"{PKG}.time.monotonic", side_effect=[0.0, 1.0]),
):
result = fetch_hltb_detail_missing([(730, "CS")])
assert result == 1
assert saved[730] == 30.0
def test_zero_elapsed_rate(self) -> None:
"""Covers the elapsed == 0 branch in the rate calculation."""
with (
patch(f"{PKG}.load_hltb_rush_cache", return_value={}),
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.load_hltb_polls_cache", return_value={}),
patch(f"{PKG}.load_hltb_count_comp_cache", return_value={}),
patch(f"{PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{PKG}.fetch_hltb_times"),
patch(f"{PKG}.save_hltb_cache"),
patch(f"{PKG}.time.monotonic", side_effect=[5.0, 5.0]),
):
result = fetch_hltb_detail_missing([(730, "CS")])
assert result == 0

View File

@ -1,45 +0,0 @@
"""Tests for hltb module - part 3 (fetch_hltb_times)."""
from __future__ import annotations
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer.hltb import (
HLTBResult,
fetch_hltb_times,
)
class TestFetchHltbTimes:
"""Tests for fetch_hltb_times."""
def test_empty(self) -> None:
assert fetch_hltb_times([]) == []
def test_calls_batch(self) -> None:
mock_result = HLTBResult(
app_id=440, game_name="TF2", completionist_hours=50.0, similarity=1.0
)
with patch(
"python_pkg.steam_backlog_enforcer.hltb._fetch_batch",
return_value=[mock_result],
):
results = fetch_hltb_times([(440, "TF2")])
assert len(results) == 1
def test_none_cache(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.hltb._fetch_batch",
return_value=[],
):
results = fetch_hltb_times([(440, "TF2")])
assert results == []
def test_explicit_cache(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.hltb._fetch_batch",
return_value=[],
):
cache: dict[int, float] = {440: 10.0}
results = fetch_hltb_times([(440, "TF2")], cache=cache)
assert results == []

View File

@ -1,289 +0,0 @@
"""Tests for HLTB search, batch-fetch, and page parsing — part 2."""
from __future__ import annotations
import asyncio
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from typing_extensions import Self
from python_pkg.steam_backlog_enforcer._hltb_search import (
_fetch_batch,
_search_one,
_SearchCtx,
)
from python_pkg.steam_backlog_enforcer._hltb_types import (
_SAVE_INTERVAL,
)
if TYPE_CHECKING:
from collections.abc import Callable
class _FakeResponse:
"""Async context manager mimicking aiohttp response."""
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
self.status = status
self._json_data = json_data or {}
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
pass
async def json(self) -> dict[str, Any]:
return self._json_data
def _make_session(resp: _FakeResponse) -> MagicMock:
session = MagicMock()
session.post.return_value = resp
return session
def _make_ctx(
session: MagicMock,
*,
cache: dict[int, float] | None = None,
progress_cb: Callable[..., object] | None = None,
) -> _SearchCtx:
return _SearchCtx(
session=session,
search_url="https://example.com/search",
headers={},
cache=cache if cache is not None else {},
counter={"done": 0, "found": 0},
total=1,
progress_cb=progress_cb,
)
class TestSearchOne:
"""Tests for _search_one."""
def test_found(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2",
"game_alias": "",
"comp_100": 180000,
"game_id": 12345,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
assert result.app_id == 440
def test_found_without_game_id(self) -> None:
"""Found result with hltb_game_id=0 does not populate ctx.hltb_game_id."""
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2",
"game_alias": "",
"comp_100": 180000,
"game_id": 0,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
assert 440 not in ctx.hltb_game_id
def test_not_found(self) -> None:
resp = _FakeResponse(200, {"data": []})
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
assert ctx.cache[440] == -1
def test_error(self) -> None:
session = MagicMock()
session.post.side_effect = aiohttp.ClientError("fail")
ctx = _make_ctx(session)
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_non_200(self) -> None:
resp = _FakeResponse(500)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_fallback_name_without_year_suffix(self) -> None:
session = MagicMock()
session.post.side_effect = [
_FakeResponse(200, {"data": []}),
_FakeResponse(
200,
{
"data": [
{
"game_name": "Final Fantasy VII",
"game_alias": "",
"game_type": "game",
"comp_100": 141120,
"game_id": 435,
"comp_100_count": 746,
"count_comp": 10450,
}
]
},
),
]
ctx = _make_ctx(session)
result = asyncio.run(
_search_one(asyncio.Semaphore(1), ctx, 39140, "Final Fantasy VII (2013)")
)
assert result is not None
assert result.app_id == 39140
assert result.comp_100_count == 746
assert result.count_comp == 10450
assert session.post.call_count == 2
def test_with_progress_cb(self) -> None:
resp = _FakeResponse(200, {"data": []})
cb = MagicMock()
ctx = _make_ctx(_make_session(resp), progress_cb=cb)
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
cb.assert_called_once()
def test_low_similarity_skipped(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "Completely Different Name",
"game_alias": "",
"comp_100": 3600,
"game_id": 1,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_zero_comp_100_skipped(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2",
"game_alias": "",
"comp_100": 0,
"game_id": 1,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is None
def test_alias_match(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "Team Fortress 2",
"game_alias": "TF2",
"comp_100": 180000,
"game_id": 12345,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
def test_full_edition_colon(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2: Complete",
"game_alias": "",
"comp_100": 180000,
"game_id": 99,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
def test_full_edition_dash(self) -> None:
resp = _FakeResponse(
200,
{
"data": [
{
"game_name": "TF2 - Complete",
"game_alias": "",
"comp_100": 180000,
"game_id": 99,
}
],
},
)
ctx = _make_ctx(_make_session(resp))
result = asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
assert result is not None
def test_save_interval(self) -> None:
"""Trigger the _SAVE_INTERVAL branch."""
resp = _FakeResponse(200, {"data": []})
ctx = _make_ctx(_make_session(resp))
# Set done to one less than _SAVE_INTERVAL so it triggers save
ctx.counter["done"] = _SAVE_INTERVAL - 1
with patch(
"python_pkg.steam_backlog_enforcer._hltb_search.save_hltb_cache"
) as mock_save:
asyncio.run(_search_one(asyncio.Semaphore(1), ctx, 440, "TF2"))
mock_save.assert_called_once()
class TestFetchBatchHltb:
"""Tests for _fetch_batch (the hltb version)."""
def test_no_auth(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info",
new_callable=AsyncMock,
return_value=None,
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert results == []
class TestPickBestEntry:
"""Tests for exact-vs-extended entry choice logic."""

View File

@ -1,325 +0,0 @@
"""Tests for HLTB search entry picking, page parsing, and leisure extraction."""
from __future__ import annotations
import asyncio
import json
from typing import Any
from unittest.mock import AsyncMock, MagicMock, patch
from typing_extensions import Self
from python_pkg.steam_backlog_enforcer._hltb_detail import (
_extract_leisure_hours,
_parse_game_page,
)
from python_pkg.steam_backlog_enforcer._hltb_search import (
_build_search_variants,
_fetch_batch,
_pick_best_hltb_entry,
)
from python_pkg.steam_backlog_enforcer._hltb_types import (
HLTBResult,
_AuthInfo,
)
class _FakeResponse:
"""Async context manager mimicking aiohttp response."""
def __init__(self, status: int, json_data: dict[str, Any] | None = None) -> None:
self.status = status
self._json_data = json_data or {}
async def __aenter__(self) -> Self:
return self
async def __aexit__(self, *args: object) -> None:
pass
async def json(self) -> dict[str, Any]:
return self._json_data
def _make_session(resp: _FakeResponse) -> MagicMock:
session = MagicMock()
session.post.return_value = resp
return session
class TestPickBestEntry:
"""Tests for exact-vs-extended entry choice logic."""
def test_prefers_exact_over_low_confidence_modded_extended(self) -> None:
exact = (
{
"game_name": "Celeste",
"game_alias": "",
"game_type": "game",
"comp_100": 141105,
"comp_100_count": 899,
"count_comp": 14055,
},
1.0,
)
mod_extended = (
{
"game_name": "Celeste - Strawberry Jam",
"game_alias": "",
"game_type": "mod",
"comp_100": 952080,
"comp_100_count": 1,
"count_comp": 6,
},
0.9,
)
best = _pick_best_hltb_entry("Celeste", [exact, mod_extended])
assert best is not None
assert best[0]["game_name"] == "Celeste"
def test_prefers_extended_when_confident_and_longer(self) -> None:
exact_demo = (
{
"game_name": "FAITH",
"game_alias": "",
"game_type": "game",
"comp_100": 1800,
"comp_100_count": 1,
"count_comp": 1,
},
1.0,
)
full_extended = (
{
"game_name": "FAITH: The Unholy Trinity",
"game_alias": "",
"game_type": "game",
"comp_100": 25200,
"comp_100_count": 50,
"count_comp": 500,
},
0.9,
)
best = _pick_best_hltb_entry("FAITH", [exact_demo, full_extended])
assert best is not None
assert best[0]["game_name"] == "FAITH: The Unholy Trinity"
def test_with_auth(self) -> None:
auth = _AuthInfo("token123", "ign_x", "ff")
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info",
new_callable=AsyncMock,
return_value=auth,
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._search_one",
new_callable=AsyncMock,
return_value=HLTBResult(
app_id=440,
game_name="TF2",
completionist_hours=50.0,
similarity=1.0,
hltb_game_id=12345,
),
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._fetch_leisure_times",
new_callable=AsyncMock,
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert len(results) == 1
def test_with_auth_no_hp(self) -> None:
auth = _AuthInfo("tok123")
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info",
new_callable=AsyncMock,
return_value=auth,
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._search_one",
new_callable=AsyncMock,
return_value=None,
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._fetch_leisure_times",
new_callable=AsyncMock,
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert results == []
def test_filters_none_results(self) -> None:
auth = _AuthInfo("tok123")
with (
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_hltb_search_url",
return_value="https://example.com",
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._get_auth_info",
new_callable=AsyncMock,
return_value=auth,
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._search_one",
new_callable=AsyncMock,
return_value=None,
),
patch(
"python_pkg.steam_backlog_enforcer._hltb_search._fetch_leisure_times",
new_callable=AsyncMock,
),
):
results = asyncio.run(_fetch_batch([(440, "TF2")], {}, {}, None))
assert results == []
class TestParseGamePage:
"""Tests for _parse_game_page."""
def test_valid_html(self) -> None:
game_data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
"relationships": [],
}
next_data = {
"props": {"pageProps": {"game": {"data": game_data}}},
}
html = (
'<html><script id="__NEXT_DATA__" type="application/json">'
+ json.dumps(next_data)
+ "</script></html>"
)
assert _parse_game_page(html) == game_data
def test_no_script_tag(self) -> None:
assert _parse_game_page("<html></html>") is None
def test_bad_json(self) -> None:
html = '<script id="__NEXT_DATA__" type="application/json">{not json}</script>'
assert _parse_game_page(html) is None
def test_missing_keys(self) -> None:
html = (
'<script id="__NEXT_DATA__" type="application/json">{"props": {}}</script>'
)
assert _parse_game_page(html) is None
class TestExtractLeisureHours:
"""Tests for _extract_leisure_hours."""
def test_leisure_time_only(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
"relationships": [],
}
assert _extract_leisure_hours(data) == round(21243 / 3600, 2)
def test_leisure_with_dlc(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 21243, "comp_100": 6800}],
"relationships": [
{"game_type": "dlc", "comp_100": 12298},
{"game_type": "dlc", "comp_100": 3600},
],
}
assert _extract_leisure_hours(data) == round((21243 + 12298 + 3600) / 3600, 2)
def test_fallback_to_comp_100(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100": 7200}],
"relationships": [],
}
assert _extract_leisure_hours(data) == round(7200 / 3600, 2)
def test_no_game_data(self) -> None:
assert _extract_leisure_hours({"game": [], "relationships": []}) == -1
def test_zero_leisure(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 0, "comp_100": 0}],
"relationships": [],
}
assert _extract_leisure_hours(data) == -1
def test_no_game_key(self) -> None:
assert _extract_leisure_hours({"relationships": []}) == -1
def test_non_dlc_relationship_ignored(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [
{"game_type": "game", "comp_100": 9999},
{"game_type": "dlc", "comp_100": 1800},
],
}
assert _extract_leisure_hours(data) == round((3600 + 1800) / 3600, 2)
def test_dlc_zero_comp_100_skipped(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": [
{"game_type": "dlc", "comp_100": 0},
],
}
assert _extract_leisure_hours(data) == round(3600 / 3600, 2)
def test_negative_leisure(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": -1, "comp_100": -1}],
"relationships": [],
}
assert _extract_leisure_hours(data) == -1
def test_string_numeric_fields(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": "7200", "comp_100": "3600"}],
"relationships": [{"game_type": "dlc", "game_id": "1", "comp_100": "1800"}],
}
assert _extract_leisure_hours(data) == round((7200 + 1800) / 3600, 2)
def test_bad_string_falls_back_to_comp_100(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": "bad", "comp_100": "3600"}],
"relationships": [],
}
assert _extract_leisure_hours(data) == 1.0
def test_relationships_not_list(self) -> None:
data: dict[str, Any] = {
"game": [{"comp_100_h": 3600}],
"relationships": "not-a-list",
}
assert _extract_leisure_hours(data) == 1.0
class TestBuildSearchVariants:
"""Tests for _build_search_variants."""
def test_subtitle_with_edition_strips_edition_from_subtitle_part(self) -> None:
# "Rocksmith 2014 Edition - Remastered" → no_subtitle = "Rocksmith 2014 Edition"
# (which != base), so lines 201-202 also add "Rocksmith" and "Rocksmith 2014"
variants = _build_search_variants("Rocksmith 2014 Edition - Remastered")
assert "Rocksmith 2014 Edition" in variants
assert "Rocksmith 2014" in variants
assert "Rocksmith" in variants
def test_no_subtitle_skips_edition_strip(self) -> None:
# No " - " → no_subtitle == base → lines 201-202 are not executed
variants = _build_search_variants("Portal 2")
assert "Portal 2" in variants

View File

@ -1,425 +0,0 @@
"""Tests for library_hider module."""
from __future__ import annotations
import asyncio
import json
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from python_pkg.steam_backlog_enforcer.library_hider import (
_cdp_result_value,
_evaluate_js,
_evaluate_js_async,
_get_shared_js_ws_url,
_is_steam_running,
_launch_steam_with_debug,
_shutdown_steam,
_steam_has_debug_port,
_wait_for_cdp_ready,
_wait_for_collections_ready,
ensure_steam_debug_port,
)
class TestGetSharedJsWsUrl:
"""Tests for _get_shared_js_ws_url."""
def test_finds_url(self) -> None:
targets = [
{
"title": "SharedJSContext",
"webSocketDebuggerUrl": "ws://127.0.0.1:8080/x",
},
{"title": "Other", "webSocketDebuggerUrl": "ws://other"},
]
mock_resp = MagicMock()
mock_resp.json.return_value = targets
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.requests.get",
return_value=mock_resp,
):
result = _get_shared_js_ws_url()
assert result == "ws://127.0.0.1:8080/x"
def test_no_shared_context(self) -> None:
targets = [{"title": "Other", "webSocketDebuggerUrl": "ws://other"}]
mock_resp = MagicMock()
mock_resp.json.return_value = targets
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.requests.get",
return_value=mock_resp,
):
assert _get_shared_js_ws_url() is None
def test_connection_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.requests.get",
side_effect=OSError,
):
assert _get_shared_js_ws_url() is None
class TestEvaluateJsAsync:
"""Tests for _evaluate_js_async."""
def test_success(self) -> None:
mock_ws = AsyncMock()
mock_ws.send = AsyncMock()
mock_ws.recv = AsyncMock(
return_value=json.dumps({"result": {"result": {"value": "ok"}}})
)
mock_ws.__aenter__ = AsyncMock(return_value=mock_ws)
mock_ws.__aexit__ = AsyncMock(return_value=False)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.websockets.connect",
return_value=mock_ws,
):
result = asyncio.run(_evaluate_js_async("ws://test", "1+1"))
assert result["result"]["result"]["value"] == "ok"
class TestEvaluateJs:
"""Tests for _evaluate_js."""
def test_success(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.asyncio.run",
return_value={"result": {"result": {"value": "ok"}}},
),
):
result = _evaluate_js("1+1")
assert result["result"]["result"]["value"] == "ok"
def test_no_ws_url(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
),
pytest.raises(RuntimeError, match="SharedJSContext not found"),
):
_evaluate_js("1+1")
class TestCdpResultValue:
"""Tests for _cdp_result_value."""
def test_extracts_value(self) -> None:
result = {"result": {"result": {"value": "hello"}}}
assert _cdp_result_value(result) == "hello"
def test_exception(self) -> None:
result = {
"result": {
"result": {"description": "Error!"},
"exceptionDetails": {},
}
}
with pytest.raises(RuntimeError, match="JS evaluation error"):
_cdp_result_value(result)
def test_empty(self) -> None:
assert _cdp_result_value({}) == ""
class TestIsSteamRunning:
"""Tests for _is_steam_running."""
def test_running(self) -> None:
mock_result = MagicMock(returncode=0)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is True
def test_not_running(self) -> None:
mock_result = MagicMock(returncode=1)
with patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
):
assert _is_steam_running() is False
class TestSteamHasDebugPort:
"""Tests for _steam_has_debug_port."""
def test_has_port(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
):
assert _steam_has_debug_port() is True
def test_no_port(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
):
assert _steam_has_debug_port() is False
class TestWaitForCdpReady:
"""Tests for _wait_for_cdp_ready."""
def test_ready_immediately(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value="ws://test",
):
assert _wait_for_cdp_ready() is True
def test_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._get_shared_js_ws_url",
return_value=None,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_cdp_ready() is False
class TestWaitForCollectionsReady:
"""Tests for _wait_for_collections_ready."""
def test_ready(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": "ok"}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
return_value="ok",
),
):
assert _wait_for_collections_ready() is True
def test_not_ready_then_ready(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
return_value={"result": {"result": {"value": "not_ready"}}},
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._cdp_result_value",
side_effect=["not_ready", "ok"],
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is True
def test_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._evaluate_js",
side_effect=RuntimeError,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._STEAM_STARTUP_WAIT",
2,
),
):
assert _wait_for_collections_ready() is False
class TestShutdownSteam:
"""Tests for _shutdown_steam."""
def test_exits_immediately(self) -> None:
mock_result = MagicMock(returncode=1) # Not running
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
),
):
_shutdown_steam()
def test_waits_for_exit(self) -> None:
results = [MagicMock(returncode=0), MagicMock(returncode=1)]
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
side_effect=results,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
):
_shutdown_steam()
def test_file_not_found(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
side_effect=FileNotFoundError,
):
_shutdown_steam() # Should not raise
def test_timeout(self) -> None:
mock_result = MagicMock(returncode=0) # Still running
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider.time.sleep",
),
):
_shutdown_steam() # Should complete loop without raising
class TestLaunchSteamWithDebug:
"""Tests for _launch_steam_with_debug."""
def test_launches(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._run_as_user",
) as mock_run:
_launch_steam_with_debug()
mock_run.assert_called_once()
class TestEnsureSteamDebugPort:
"""Tests for ensure_steam_debug_port."""
def test_already_available(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=True,
):
ensure_steam_debug_port()
def test_starts_fresh(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_restarts_running_steam(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._shutdown_steam",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=True,
),
):
ensure_steam_debug_port()
def test_cdp_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=False,
),
pytest.raises(RuntimeError, match="Timed out waiting for Steam CDP"),
):
ensure_steam_debug_port()
def test_collections_timeout(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.library_hider._steam_has_debug_port",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._is_steam_running",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._launch_steam_with_debug",
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_cdp_ready",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.library_hider._wait_for_collections_ready",
return_value=False,
),
pytest.raises(
RuntimeError, match="Timed out waiting for Steam collections"
),
):
ensure_steam_debug_port()

View File

@ -1,194 +0,0 @@
"""Tests for library_hider module — part 2 (missing coverage)."""
from __future__ import annotations
import os
import tempfile
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.library_hider import (
_run_as_user,
hide_other_games,
restart_steam,
unhide_all_games,
)
PKG = "python_pkg.steam_backlog_enforcer.library_hider"
class TestRunAsUser:
"""Tests for _run_as_user."""
def test_non_root_runs_directly(self) -> None:
with (
patch(f"{PKG}.os.geteuid", return_value=1000),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam", "-shutdown"], "alice")
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam", "-shutdown"]
def test_root_drops_to_user(self) -> None:
mock_pw = MagicMock()
mock_pw.pw_uid = 1001
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch.dict(
os.environ,
{"DISPLAY": ":1", "XAUTHORITY": tempfile.gettempdir() + "/.X"},
),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam", "-shutdown"], "alice")
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
assert cmd[0] == "sudo"
assert "-u" in cmd
assert "alice" in cmd
def test_root_user_key_error(self) -> None:
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", side_effect=KeyError("no user")),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "unknownuser")
mock_popen.assert_called_once()
cmd = mock_popen.call_args[0][0]
# Falls back to uid 1000
assert "sudo" in cmd[0]
def test_root_user_none(self) -> None:
"""When user is None and euid is 0, runs directly."""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], None)
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam"]
def test_root_user_is_root(self) -> None:
"""When user is 'root', runs directly."""
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "root")
cmd = mock_popen.call_args[0][0]
assert cmd == ["steam"]
def test_root_uses_env_defaults(self) -> None:
"""When DBUS/XAUTHORITY/DISPLAY not in env, uses defaults."""
mock_pw = MagicMock()
mock_pw.pw_uid = 1000
env_copy = os.environ.copy()
env_copy.pop("DBUS_SESSION_BUS_ADDRESS", None)
env_copy.pop("XAUTHORITY", None)
env_copy.pop("DISPLAY", None)
with (
patch(f"{PKG}.os.geteuid", return_value=0),
patch(f"{PKG}.pwd.getpwnam", return_value=mock_pw),
patch.dict(os.environ, env_copy, clear=True),
patch(f"{PKG}.subprocess.Popen") as mock_popen,
):
_run_as_user(["steam"], "bob")
cmd = mock_popen.call_args[0][0]
assert any("DISPLAY=:0" in arg for arg in cmd)
assert any("/home/bob/.Xauthority" in arg for arg in cmd)
class TestRestartSteam:
"""Tests for restart_steam."""
def test_cdp_ready(self) -> None:
with (
patch(f"{PKG}._shutdown_steam"),
patch(f"{PKG}._launch_steam_with_debug"),
patch(f"{PKG}._wait_for_cdp_ready", return_value=True),
):
restart_steam()
def test_cdp_not_ready(self) -> None:
with (
patch(f"{PKG}._shutdown_steam"),
patch(f"{PKG}._launch_steam_with_debug"),
patch(f"{PKG}._wait_for_cdp_ready", return_value=False),
):
restart_steam()
class TestHideOtherGames:
"""Tests for hide_other_games."""
def test_hides(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 5}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 5}',
),
):
count = hide_other_games([1, 2, 3], 1)
assert count == 5
def test_empty_list(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 0}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 0}',
),
):
count = hide_other_games([1], 1)
assert count == 0
def test_no_allowed(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={
"result": {"result": {"value": '{"totalHidden": 2}'}},
},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"totalHidden": 2}',
),
):
count = hide_other_games([1, 2], None)
assert count == 2
class TestUnhideAllGames:
"""Tests for unhide_all_games."""
def test_unhides(self) -> None:
with (
patch(f"{PKG}.ensure_steam_debug_port"),
patch(
f"{PKG}._evaluate_js",
return_value={"result": {"result": {"value": '{"count": 10}'}}},
),
patch(
f"{PKG}._cdp_result_value",
return_value='{"count": 10}',
),
):
count = unhide_all_games([1, 2, 3])
assert count == 10

View File

@ -1,496 +0,0 @@
"""Tests for main CLI module."""
from __future__ import annotations
import sys
import time
from typing import Any
from unittest.mock import patch
import pytest
from python_pkg.steam_backlog_enforcer._whitelist import WHITELIST_COOLDOWN_SECONDS
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.main import (
cmd_add_exception,
cmd_buy_dlc,
cmd_hide,
cmd_install,
cmd_installed,
cmd_list,
cmd_reset,
cmd_setup,
cmd_status,
cmd_unblock,
cmd_unhide,
cmd_uninstall,
main,
)
PKG = "python_pkg.steam_backlog_enforcer.main"
def _snap(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": 60,
"completionist_hours": hours,
}
class TestCmdStatus:
"""Tests for cmd_status."""
def test_with_game(self) -> None:
state = State(current_app_id=440, current_game_name="TF2")
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
patch(f"{PKG}._echo"),
):
cmd_status(Config(), state)
def test_no_game(self) -> None:
with (
patch(f"{PKG}.is_store_blocked", return_value=False),
patch(f"{PKG}.get_installed_games", return_value=[]),
patch(f"{PKG}._echo"),
):
cmd_status(Config(), State())
class TestCmdList:
"""Tests for cmd_list."""
def test_no_snapshot(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=None),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_list(Config(), State())
assert any("No snapshot" in str(c) for c in mock_echo.call_args_list)
def test_with_games(self) -> None:
snap = [
_snap(1, "A", 10, 5, 20.0),
_snap(2, "B", 10, 10, 10.0),
_snap(3, "C", 10, 3, -1),
]
state = State(current_app_id=1)
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._echo"),
):
cmd_list(Config(), state)
def test_many_games(self) -> None:
snap = [_snap(i, f"Game{i}") for i in range(60)]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_list(Config(), State())
assert any("more" in str(c) for c in mock_echo.call_args_list)
class TestCmdUnblock:
"""Tests for cmd_unblock."""
def test_success(self) -> None:
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}._echo"),
):
cmd_unblock(Config(), State())
def test_fail(self) -> None:
with (
patch(f"{PKG}.unblock_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_unblock(Config(), State())
assert any("Failed" in str(c) for c in mock_echo.call_args_list)
class TestCmdBuyDlc:
"""Tests for cmd_buy_dlc."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_buy_dlc(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_unblock_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.unblock_store", return_value=False),
patch(f"{PKG}._echo"),
):
cmd_buy_dlc(Config(), state)
def test_success_reblock(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config(block_store=True)
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}.block_store", return_value=True),
patch(f"{PKG}.restart_steam"),
patch(f"{PKG}._echo"),
patch("builtins.input", return_value=""),
):
cmd_buy_dlc(config, state)
def test_reblock_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config(block_store=True)
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}.block_store", return_value=False),
patch(f"{PKG}._echo") as mock_echo,
patch("builtins.input", return_value=""),
):
cmd_buy_dlc(config, state)
assert any("Warning" in str(c) for c in mock_echo.call_args_list)
def test_no_reblock(self) -> None:
state = State(current_app_id=1, current_game_name="G")
config = Config(block_store=False)
with (
patch(f"{PKG}.unblock_store", return_value=True),
patch(f"{PKG}._echo"),
patch("builtins.input", return_value=""),
):
cmd_buy_dlc(config, state)
class TestCmdReset:
"""Tests for cmd_reset."""
def test_normal_reset(self) -> None:
state = State(current_app_id=1, current_game_name="G", finished_app_ids=[1])
with (
patch(f"{PKG}.unblock_store"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.unhide_all_games", return_value=2),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
assert state.current_app_id is None
assert state.finished_app_ids == []
def test_unhide_fails(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.unblock_store"),
patch(
f"{PKG}.get_all_owned_app_ids",
side_effect=OSError("fail"),
),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
def test_unhide_returns_zero(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.unblock_store"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.unhide_all_games", return_value=0),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
def test_no_owned_ids(self) -> None:
state = State(current_app_id=1)
with (
patch(f"{PKG}.unblock_store"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo"),
patch.object(State, "save"),
):
cmd_reset(Config(), state)
class TestCmdInstalled:
"""Tests for cmd_installed."""
def test_shows_games(self) -> None:
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (228980, "RT")],
),
patch(f"{PKG}.is_protected_app", side_effect=lambda aid: aid == 228980),
patch(f"{PKG}._echo"),
):
cmd_installed(Config(), State(current_app_id=440))
class TestCmdUninstall:
"""Tests for cmd_uninstall."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_uninstall(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_nothing_to_remove(self) -> None:
state = State(current_app_id=440)
with (
patch(f"{PKG}.get_installed_games", return_value=[(440, "TF2")]),
patch(f"{PKG}._echo"),
):
cmd_uninstall(Config(), state)
def test_confirms_yes(self) -> None:
state = State(current_app_id=440)
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch(f"{PKG}.uninstall_other_games", return_value=1),
patch("builtins.input", return_value="YES"),
patch(f"{PKG}._echo"),
):
cmd_uninstall(Config(), state)
def test_aborts(self) -> None:
state = State(current_app_id=440)
with (
patch(
f"{PKG}.get_installed_games",
return_value=[(440, "TF2"), (730, "CS")],
),
patch("builtins.input", return_value="no"),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_uninstall(Config(), state)
assert any("Aborted" in str(c) for c in mock_echo.call_args_list)
class TestCmdSetup:
"""Tests for cmd_setup."""
def test_calls_interactive(self) -> None:
with patch(f"{PKG}.interactive_setup") as mock_setup:
cmd_setup(Config(), State())
mock_setup.assert_called_once()
class TestCmdInstall:
"""Tests for cmd_install."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo") as mock_echo:
cmd_install(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_already_installed(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=True),
patch(f"{PKG}._echo"),
):
cmd_install(Config(), state)
def test_installs_ok(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=True),
patch(f"{PKG}._echo"),
):
cmd_install(Config(steam_id="i"), state)
def test_install_fails(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.is_game_installed", return_value=False),
patch(f"{PKG}.install_game", return_value=False),
patch(f"{PKG}._echo"),
):
cmd_install(Config(steam_id="i"), state)
class TestCmdHide:
"""Tests for cmd_hide."""
def test_no_game(self) -> None:
with patch(f"{PKG}._echo"):
cmd_hide(Config(), State())
def test_no_owned(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo"),
):
cmd_hide(Config(), state)
def test_hides(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{PKG}.hide_other_games", return_value=1),
patch(f"{PKG}._echo"),
):
cmd_hide(Config(), state)
def test_hides_zero(self) -> None:
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
patch(f"{PKG}.hide_other_games", return_value=0),
patch(f"{PKG}._echo"),
):
cmd_hide(Config(), state)
class TestCmdUnhide:
"""Tests for cmd_unhide."""
def test_no_owned(self) -> None:
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}._echo"),
):
cmd_unhide(Config(), State())
def test_unhides(self) -> None:
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
patch(f"{PKG}.unhide_all_games", return_value=1),
patch(f"{PKG}._echo"),
):
cmd_unhide(Config(), State())
def test_unhides_zero(self) -> None:
with (
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1]),
patch(f"{PKG}.unhide_all_games", return_value=0),
patch(f"{PKG}._echo"),
):
cmd_unhide(Config(), State())
# ──────────────────────────────────────────────────────────────
# cmd_add_exception
# ──────────────────────────────────────────────────────────────
_VALID_REASON = "I need this game installed for a work presentation this week."
class TestCmdAddException:
def test_no_args_prints_usage_and_exits(self) -> None:
with (
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception([])
def test_missing_reason_flag_exits(self) -> None:
with (
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception(["440", "no", "flag"])
def test_non_numeric_app_id_exits(self) -> None:
with (
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception(["notanumber", "--reason", _VALID_REASON])
def test_reason_flag_with_no_value_exits(self) -> None:
with (
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception(["440", "--reason"])
def test_reason_flag_last_position_with_no_value_exits(self) -> None:
# 3 args passes the len/flag guard but --reason is last so reason_parts=[]
with (
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception(["440", "extra", "--reason"])
def test_invalid_reason_exits(self) -> None:
with (
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception(["440", "--reason", "too short"])
def test_add_pending_exception_raises_value_error(self) -> None:
with (
patch(f"{PKG}._echo"),
patch(
f"{PKG}.add_pending_exception",
side_effect=ValueError("already approved"),
),
pytest.raises(SystemExit, match="1"),
):
cmd_add_exception(["440", "--reason", _VALID_REASON])
def test_happy_path_no_pending(self) -> None:
with (
patch(f"{PKG}._echo") as mock_echo,
patch(
f"{PKG}.add_pending_exception",
return_value="Exception requested for AppID 440.",
),
patch(f"{PKG}.list_pending_exceptions", return_value=[]),
):
cmd_add_exception(["440", "--reason", _VALID_REASON])
mock_echo.assert_called()
def test_happy_path_with_pending_list(self) -> None:
now = time.time()
pending = [
{"app_id": 440, "requested_at": now - WHITELIST_COOLDOWN_SECONDS - 1},
{"app_id": 730, "requested_at": now},
]
with (
patch(f"{PKG}._echo") as mock_echo,
patch(
f"{PKG}.add_pending_exception",
return_value="Exception requested for AppID 440.",
),
patch(f"{PKG}.list_pending_exceptions", return_value=pending),
):
cmd_add_exception(["440", "--reason", _VALID_REASON])
# At least the "Pending exceptions" line should be echoed
calls = [str(c) for c in mock_echo.call_args_list]
assert any("Pending" in s for s in calls)
# ──────────────────────────────────────────────────────────────
# main() dispatch to add-exception
# ──────────────────────────────────────────────────────────────
class TestMainDispatchAddException:
def test_dispatches_add_exception(self) -> None:
argv = ["prog", "add-exception", "440", "--reason", _VALID_REASON]
with (
patch.object(sys, "argv", argv),
patch(f"{PKG}.cmd_add_exception") as mock_cmd,
):
main()
mock_cmd.assert_called_once_with(["440", "--reason", _VALID_REASON])

View File

@ -1,424 +0,0 @@
"""Tests for main CLI module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer._cmd_done import (
_enforce_on_done,
_finalize_completion,
cmd_done,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
PKG = "python_pkg.steam_backlog_enforcer.main"
def _snap(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": 60,
"completionist_hours": hours,
}
class TestFinalizeCompletion:
"""Tests for _finalize_completion."""
def test_with_snapshot_and_hiding(self) -> None:
config = Config(steam_api_key="k", steam_id="i")
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=2),
patch(f"{CMD_DONE_PKG}.send_notification"),
patch.object(State, "save"),
):
def set_next(
_games: object,
s: State,
_c: object,
**_kwargs: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "NewGame"
mock_pick.side_effect = set_next
_finalize_completion(config, state, "G", 1)
assert 1 in state.finished_app_ids
def test_no_snapshot(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=None),
patch.object(State, "save"),
):
_finalize_completion(config, state, "G", 1)
assert state.current_app_id is None
def test_no_next_game(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(1, "G", 10, 10)]
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
patch.object(State, "save"),
):
def set_none(
_games: object,
s: State,
_c: object,
**_kwargs: object,
) -> None:
s.current_app_id = None
mock_pick.side_effect = set_none
_finalize_completion(config, state, "G", 1)
def test_no_owned_ids(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(2, "Next", 10, 0)]
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{CMD_DONE_PKG}.send_notification"),
patch.object(State, "save"),
):
def set_2(
_games: object,
s: State,
_c: object,
**_kwargs: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "Next"
mock_pick.side_effect = set_2
_finalize_completion(config, state, "G", 1)
def test_hide_returns_zero(self) -> None:
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [_snap(2, "Next", 10, 0)]
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.pick_next_game") as mock_pick,
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
patch(f"{CMD_DONE_PKG}.send_notification"),
patch.object(State, "save"),
):
def set_2(
_games: object,
s: State,
_c: object,
**_kwargs: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "Next"
mock_pick.side_effect = set_2
_finalize_completion(config, state, "G", 1)
def test_refreshes_snapshot_hours_before_pick(self) -> None:
"""Ensure stale snapshot hours are replaced before picking next game."""
config = Config()
state = State(current_app_id=1, current_game_name="G")
snap = [
_snap(2, "A Space for the Unbound", 10, 0, 0.56),
_snap(3, "Lacuna", 10, 0, 1.2),
]
seen: dict[int, float] = {}
def capture_pick(
games: list[GameInfo],
s: State,
_c: object,
**_kwargs: object,
) -> None:
for game in games:
seen[game.app_id] = game.completionist_hours
# Force early return path after pick_next_game.
s.current_app_id = None
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={2: 20.05}),
patch(
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
return_value={3: 18.81},
) as mock_fetch_hltb,
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=capture_pick),
patch.object(State, "save"),
):
_finalize_completion(config, state, "G", 1)
assert seen[2] == 20.05
assert seen[3] == 18.81
mock_fetch_hltb.assert_called_once_with([(3, "Lacuna")])
def test_retriggers_install_after_library_hide_if_still_missing(self) -> None:
"""Re-trigger install after hide step in case Steam restart drops it."""
config = Config(steam_id="sid")
state = State(current_app_id=1, current_game_name="DoneGame")
snap = [_snap(2, "Next", 10, 0, 5.0)]
def set_next(
_games: object,
s: State,
_c: object,
**_kwargs: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "Next"
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1),
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
patch(f"{CMD_DONE_PKG}.send_notification"),
patch.object(State, "save"),
):
_finalize_completion(config, state, "DoneGame", 1)
mock_install.assert_called_once_with(2, "Next", "sid", use_steam_protocol=True)
def test_skips_install_retry_when_assigned_game_already_installed(self) -> None:
"""Do not re-trigger install when assigned game is already present."""
config = Config(steam_id="sid")
state = State(current_app_id=1, current_game_name="DoneGame")
snap = [_snap(2, "Next", 10, 0, 5.0)]
def set_next(
_games: object,
s: State,
_c: object,
**_kwargs: object,
) -> None:
s.current_app_id = 2
s.current_game_name = "Next"
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_snapshot", return_value=snap),
patch(f"{CMD_DONE_PKG}.pick_next_game", side_effect=set_next),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1),
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
patch(f"{CMD_DONE_PKG}.send_notification"),
patch.object(State, "save"),
):
_finalize_completion(config, state, "DoneGame", 1)
mock_install.assert_not_called()
class TestEnforceOnDone:
"""Tests for _enforce_on_done."""
def test_no_current_game(self) -> None:
_enforce_on_done(Config(), State())
def test_kills_and_uninstalls(self) -> None:
config = Config(
kill_unauthorized_games=True,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(
f"{CMD_DONE_PKG}.enforce_allowed_game",
return_value=[(1234, 999)],
),
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=2),
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=1),
):
_enforce_on_done(config, state)
def test_no_violations_no_uninstalls(self) -> None:
config = Config(
kill_unauthorized_games=True,
uninstall_other_games=True,
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.enforce_allowed_game", return_value=[]),
patch(f"{CMD_DONE_PKG}.uninstall_other_games", return_value=0),
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=True),
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
):
_enforce_on_done(config, state)
def test_reinstall_when_not_installed(self) -> None:
config = Config(
kill_unauthorized_games=False,
uninstall_other_games=False,
steam_id="s1",
)
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.is_game_installed", return_value=False),
patch(f"{CMD_DONE_PKG}.install_game") as mock_install,
patch(f"{CMD_DONE_PKG}.get_all_owned_app_ids", return_value=[1, 2]),
patch(f"{CMD_DONE_PKG}.hide_other_games", return_value=0),
):
_enforce_on_done(config, state)
mock_install.assert_called_once_with(1, "G", "s1", use_steam_protocol=True)
"""Tests for cmd_done."""
def test_no_game_assigned(self) -> None:
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
cmd_done(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_fetch_fails(self) -> None:
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = None
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_not_complete_enforces(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_complete_finalizes(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
mock_final.assert_called_once()
def test_hltb_cache_miss_fetches(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
patch(
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
return_value={1: 15.0},
),
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_hltb_negative_no_display(self) -> None:
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_reassign_returns_true(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
patch(f"{CMD_DONE_PKG}._finalize_completion"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)

View File

@ -1,305 +0,0 @@
"""Tests for main CLI module — part 3 (cmd_done, main, cmd_pick)."""
from __future__ import annotations
import sys
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
from python_pkg.steam_backlog_enforcer._cmd_done import (
cmd_done,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.main import cmd_pick, main
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
CMD_DONE_PKG = "python_pkg.steam_backlog_enforcer._cmd_done"
PKG = "python_pkg.steam_backlog_enforcer.main"
def _snap(
app_id: int,
name: str,
total: int,
unlocked: int,
hours: float,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": 0,
"completionist_hours": hours,
"achievements": [],
}
class TestCmdDone:
"""Tests for cmd_done."""
def test_no_game_assigned(self) -> None:
with patch(f"{CMD_DONE_PKG}._echo") as mock_echo:
cmd_done(Config(), State())
assert any("No game" in str(c) for c in mock_echo.call_args_list)
def test_fetch_fails(self) -> None:
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = None
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_not_complete_enforces(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 20.0}),
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_complete_finalizes(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 10.0}),
patch(f"{CMD_DONE_PKG}._finalize_completion") as mock_final,
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
mock_final.assert_called_once()
def test_hltb_cache_miss_fetches(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={}),
patch(
f"{CMD_DONE_PKG}.fetch_hltb_times_cached",
return_value={1: 15.0},
),
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_hltb_negative_no_display(self) -> None:
"""Covers the hours <= 0 branch (no HLTB estimate display)."""
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: -1.0}),
patch(f"{CMD_DONE_PKG}._enforce_on_done"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
def test_reassign_returns_true(self) -> None:
game = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=60,
)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
state = State(current_app_id=1, current_game_name="G")
with (
patch(f"{CMD_DONE_PKG}.SteamAPIClient", return_value=mock_client),
patch(f"{CMD_DONE_PKG}._echo"),
patch(f"{CMD_DONE_PKG}.load_hltb_cache", return_value={1: 50.0}),
patch(f"{CMD_DONE_PKG}._finalize_completion"),
):
cmd_done(Config(steam_api_key="k", steam_id="i"), state)
class TestMain:
"""Tests for main CLI entry point."""
def test_no_args_exits(self) -> None:
with (
patch.object(sys, "argv", ["prog"]),
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
main()
def test_unknown_command_exits(self) -> None:
with (
patch.object(sys, "argv", ["prog", "bogus"]),
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
main()
def test_valid_command_runs(self) -> None:
mock_cmd = MagicMock()
with (
patch.object(sys, "argv", ["prog", "status"]),
patch(f"{PKG}.Config.load", return_value=Config(steam_api_key="k")),
patch(f"{PKG}.State.load", return_value=State()),
patch.dict(f"{PKG}.COMMANDS", {"status": ("s", mock_cmd)}),
):
main()
mock_cmd.assert_called_once()
def test_setup_no_key_required(self) -> None:
mock_cmd = MagicMock()
with (
patch.object(sys, "argv", ["prog", "setup"]),
patch(f"{PKG}.Config.load", return_value=Config()),
patch(f"{PKG}.State.load", return_value=State()),
patch.dict(f"{PKG}.COMMANDS", {"setup": ("s", mock_cmd)}),
):
main()
mock_cmd.assert_called_once()
def test_no_api_key_exits(self) -> None:
with (
patch.object(sys, "argv", ["prog", "status"]),
patch(f"{PKG}.Config.load", return_value=Config()),
patch(f"{PKG}._echo"),
pytest.raises(SystemExit, match="1"),
):
main()
class TestCmdPick:
"""Tests for cmd_pick."""
def test_no_snapshot_prints_message(self) -> None:
with (
patch(f"{PKG}.load_snapshot", return_value=[]),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
mock_echo.assert_called_once_with("No snapshot found. Run 'scan' first.")
def test_calls_pick_next_game(self) -> None:
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
patch(f"{PKG}.pick_next_game") as mock_pick,
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
):
config = Config(steam_api_key="k", steam_id="i")
state = State()
cmd_pick(config, state)
mock_pick.assert_called_once()
def test_hides_games_after_pick(self) -> None:
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
state = State(current_app_id=2, current_game_name="NewGame")
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.load_hltb_cache", return_value={2: 5.0}),
patch(f"{PKG}.pick_next_game"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{PKG}.hide_other_games", return_value=2) as mock_hide,
patch(f"{PKG}._echo"),
):
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
mock_hide.assert_called_once_with([1, 2, 3], 2)
def test_no_hide_message_when_none_hidden(self) -> None:
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
state = State(current_app_id=2, current_game_name="NewGame")
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.pick_next_game"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[1, 2, 3]),
patch(f"{PKG}.hide_other_games", return_value=0),
patch(f"{PKG}._echo") as mock_echo,
):
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
mock_echo.assert_not_called()
def test_no_hide_when_no_current_app(self) -> None:
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.pick_next_game"),
patch(f"{PKG}.get_all_owned_app_ids") as mock_owned,
):
cmd_pick(Config(steam_api_key="k", steam_id="i"), State())
mock_owned.assert_not_called()
def test_no_hide_when_owned_ids_empty(self) -> None:
snap = [_snap(2, "NewGame", 10, 0, 5.0)]
state = State(current_app_id=2, current_game_name="NewGame")
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.load_hltb_cache", return_value={}),
patch(f"{PKG}.pick_next_game"),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
patch(f"{PKG}.hide_other_games") as mock_hide,
):
cmd_pick(Config(steam_api_key="k", steam_id="i"), state)
mock_hide.assert_not_called()
def test_hltb_cache_applied_to_games(self) -> None:
snap = [_snap(2, "NewGame", 10, 0, -1.0)]
captured_games: list[list[GameInfo]] = []
config = Config(steam_api_key="k", steam_id="i")
state = State()
def capture_pick(games: list[GameInfo], *_args: object) -> None:
captured_games.append(list(games))
with (
patch(f"{PKG}.load_snapshot", return_value=snap),
patch(f"{PKG}.load_hltb_cache", return_value={2: 7.5}),
patch(f"{PKG}.pick_next_game", side_effect=capture_pick),
patch(f"{PKG}.get_all_owned_app_ids", return_value=[]),
):
cmd_pick(config, state)
assert len(captured_games) == 1
assert captured_games[0][0].completionist_hours == pytest.approx(7.5)

View File

@ -1,382 +0,0 @@
"""Tests for HLTB poll-count tracking, schema migration, and confidence display."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer import _cmd_done
from python_pkg.steam_backlog_enforcer._hltb_types import (
HLTBResult,
_HLTBExtras,
load_hltb_cache,
load_hltb_count_comp_cache,
load_hltb_game_id_cache,
load_hltb_polls_cache,
save_hltb_cache,
)
from python_pkg.steam_backlog_enforcer.config import State
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
if TYPE_CHECKING:
from pathlib import Path
_TYPES = "python_pkg.steam_backlog_enforcer._hltb_types"
_CMD = "python_pkg.steam_backlog_enforcer._cmd_done"
_SCAN = "python_pkg.steam_backlog_enforcer.scanning"
class TestCacheSchema:
"""Tests for the new cache schema and back-compat migration."""
def test_legacy_float_migrates(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(json.dumps({"440": 10.5}), encoding="utf-8")
with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file):
assert load_hltb_cache() == {440: 10.5}
assert load_hltb_polls_cache() == {440: 0}
assert load_hltb_count_comp_cache() == {440: 0}
def test_new_dict_schema(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"440": {"hours": 10.5, "polls": 7, "count_comp": 20}}),
encoding="utf-8",
)
with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file):
assert load_hltb_cache() == {440: 10.5}
assert load_hltb_polls_cache() == {440: 7}
assert load_hltb_count_comp_cache() == {440: 20}
def test_invalid_app_id_skipped(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"notanint": 1.0, "440": 5.0}), encoding="utf-8"
)
with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file):
assert load_hltb_cache() == {440: 5.0}
def test_unparseable_value_skipped(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(json.dumps({"440": "notafloat"}), encoding="utf-8")
with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file):
assert load_hltb_cache() == {}
def test_save_with_polls_roundtrip(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
):
save_hltb_cache({440: 10.5}, {440: 7}, _HLTBExtras(count_comp={440: 20}))
data = json.loads(cache_file.read_text(encoding="utf-8"))
assert data == {
"440": {
"hours": 10.5,
"polls": 7,
"count_comp": 20,
"rush_hours": -1,
"leisure_100h": -1,
"hltb_game_id": 0,
}
}
def test_save_without_polls_defaults_zero(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
):
save_hltb_cache({440: 10.5})
data = json.loads(cache_file.read_text(encoding="utf-8"))
assert data == {
"440": {
"hours": 10.5,
"polls": 0,
"count_comp": 0,
"rush_hours": -1,
"leisure_100h": -1,
"hltb_game_id": 0,
}
}
def test_load_game_id_cache(self, tmp_path: Path) -> None:
"""load_hltb_game_id_cache returns the hltb_game_id portion of the cache."""
cache_file = tmp_path / "hltb_cache.json"
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
):
save_hltb_cache({440: 10.5}, extras=_HLTBExtras(hltb_game_id={440: 99}))
assert load_hltb_game_id_cache() == {440: 99}
class TestHltbResultPolls:
def test_default_zero(self) -> None:
r = HLTBResult(app_id=1, game_name="x", completionist_hours=1.0, similarity=1)
assert r.comp_100_count == 0
assert r.count_comp == 0
def test_explicit(self) -> None:
r = HLTBResult(
app_id=1,
game_name="x",
completionist_hours=1.0,
similarity=1,
comp_100_count=42,
count_comp=100,
)
assert r.comp_100_count == 42
assert r.count_comp == 100
class TestGameInfoPolls:
def test_snapshot_roundtrip(self) -> None:
g = GameInfo(
app_id=1,
name="X",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=30,
comp_100_count=8,
count_comp=20,
)
snap = g.to_snapshot()
assert snap["comp_100_count"] == 8
assert snap["count_comp"] == 20
restored = GameInfo.from_snapshot(snap)
assert restored.comp_100_count == 8
assert restored.count_comp == 20
def test_snapshot_missing_field_defaults(self) -> None:
snap = {
"app_id": 1,
"name": "X",
"total_achievements": 0,
"unlocked_achievements": 0,
}
restored = GameInfo.from_snapshot(snap)
assert restored.comp_100_count == 0
assert restored.count_comp == 0
def _state(finished: list[int], current: int | None = None) -> State:
s = State()
s.finished_app_ids = list(finished)
s.current_app_id = current
s.current_game_name = ""
return s
class TestBackfillPollsForFinished:
def test_no_missing_returns_existing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"1": {"hours": 1.0, "polls": 5}}), encoding="utf-8"
)
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]),
):
result = _cmd_done._backfill_polls_for_finished(_state([1]))
assert result == {1: 5}
def test_no_snapshot_no_missing(self) -> None:
with (
patch(f"{_CMD}.load_hltb_polls_cache", return_value={}),
patch(f"{_CMD}.load_snapshot", return_value=None),
):
assert _cmd_done._backfill_polls_for_finished(_state([1])) == {}
def test_missing_triggers_fetch(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"1": {"hours": 2.0, "polls": 0}}), encoding="utf-8"
)
def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": 2.0, "polls": 9}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {aid: 2.0 for aid, _ in games}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 1, "name": "G"}]),
patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
patch(f"{_CMD}._echo"),
):
result = _cmd_done._backfill_polls_for_finished(_state([1]))
assert result == {1: 9}
def test_extra_app_id_with_zero_polls_added(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"7": {"hours": 1.0, "polls": 0}}), encoding="utf-8"
)
def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": 1.0, "polls": 4}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {aid: 1.0 for aid, _ in games}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 7, "name": "G"}]),
patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
patch(f"{_CMD}._echo"),
):
result = _cmd_done._backfill_polls_for_finished(
_state([], current=7), extra_app_id=7
)
assert result == {7: 4}
def test_preserves_prior_hours_on_miss(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"3": {"hours": 4.0, "polls": 0}}), encoding="utf-8"
)
def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
# Simulate a refetch returning a miss (hours -1, polls 0).
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": -1, "polls": 0}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {aid: -1 for aid, _ in games}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(f"{_CMD}.load_snapshot", return_value=[{"app_id": 3, "name": "G"}]),
patch(f"{_CMD}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
patch(f"{_CMD}._echo"),
):
_cmd_done._backfill_polls_for_finished(_state([3]))
# Prior hours should be preserved on miss.
final = json.loads(cache_file.read_text(encoding="utf-8"))
assert final["3"]["hours"] == 4.0
class TestReportAssignedConfidence:
def test_new_low_warning(self) -> None:
echoed: list[str] = []
with (
patch(
f"{_CMD}._backfill_polls_for_finished",
return_value={1: 1, 2: 5, 3: 10},
),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
{"app_id": 2, "name": "OldShortest"},
{"app_id": 3, "name": "Other"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([2, 3], current=1))
assert any("NEW LOW" in s for s in echoed)
assert any("Historical min" in s and "OldShortest" in s for s in echoed)
def test_zero_polls_warning_with_history(self) -> None:
echoed: list[str] = []
with (
patch(
f"{_CMD}._backfill_polls_for_finished",
return_value={1: 0, 2: 5},
),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
{"app_id": 2, "name": "Old"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([2], current=1))
assert any("no polls recorded" in s for s in echoed)
def test_zero_polls_warning_no_history(self) -> None:
echoed: list[str] = []
with (
patch(f"{_CMD}._backfill_polls_for_finished", return_value={1: 0}),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([], current=1))
assert any("no polls recorded" in s for s in echoed)
assert not any("Historical min" in s for s in echoed)
def test_healthy_no_warning(self) -> None:
echoed: list[str] = []
with (
patch(
f"{_CMD}._backfill_polls_for_finished",
return_value={1: 50, 2: 5},
),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
{"app_id": 2, "name": "Old"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([2], current=1))
assert not any("NEW LOW" in s for s in echoed)
assert not any("no polls recorded" in s for s in echoed)
assert any("HLTB confidence: 50" in s for s in echoed)
def test_unknown_finished_uses_appid_label(self) -> None:
echoed: list[str] = []
with (
patch(
f"{_CMD}._backfill_polls_for_finished",
return_value={1: 50, 99: 5},
),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([99], current=1))
assert any("AppID=99" in s for s in echoed)
def test_chosen_equals_min_no_warning(self) -> None:
# Edge case: chosen_polls == min_polls (not a new low).
echoed: list[str] = []
with (
patch(
f"{_CMD}._backfill_polls_for_finished",
return_value={1: 5, 2: 5},
),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
{"app_id": 2, "name": "Old"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([2], current=1))
assert not any("NEW LOW" in s for s in echoed)
assert not any("no polls recorded" in s for s in echoed)

View File

@ -1,417 +0,0 @@
"""Tests for HLTB poll-count tracking — scanning integration (part 2)."""
from __future__ import annotations
import json
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer import _cmd_done, _scanning_confidence, scanning
from python_pkg.steam_backlog_enforcer.config import State
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
if TYPE_CHECKING:
from pathlib import Path
_TYPES = "python_pkg.steam_backlog_enforcer._hltb_types"
_CMD = "python_pkg.steam_backlog_enforcer._cmd_done"
_SCAN = "python_pkg.steam_backlog_enforcer.scanning"
_SCANCONF = "python_pkg.steam_backlog_enforcer._scanning_confidence"
def _state(finished: list[int], current: int | None = None) -> State:
s = State()
s.finished_app_ids = list(finished)
s.current_app_id = current
s.current_game_name = ""
return s
class TestScanningPollsIntegration:
def test_do_scan_kept_assignment_reports(self) -> None:
# Targeted test for scanning's `else` branch that prints CURRENT.
echoed: list[str] = []
games = [
GameInfo(
app_id=1,
name="X",
total_achievements=10,
unlocked_achievements=2,
playtime_minutes=0,
completionist_hours=5.0,
comp_100_count=20,
)
]
state = _state([], current=1)
with (
patch(f"{_SCAN}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
patch(f"{_SCAN}._report_poll_confidence") as mock_report,
):
# Directly invoke just the kept-assignment branch.
current = next((g for g in games if g.app_id == state.current_app_id), None)
assert current is not None
scanning._echo(f"\n>>> CURRENT: {current.name} (AppID={current.app_id})")
scanning._report_poll_confidence(current, games, state)
assert any("CURRENT" in s for s in echoed)
mock_report.assert_called_once()
def test_report_poll_confidence_new_low(self) -> None:
echoed: list[str] = []
chosen = GameInfo(
app_id=1,
name="Chosen",
total_achievements=10,
unlocked_achievements=0,
playtime_minutes=0,
comp_100_count=0,
)
games = [
chosen,
GameInfo(
app_id=2,
name="Old",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=0,
),
]
with (
patch(
f"{_SCANCONF}._backfill_polls_for_finished",
return_value={1: 1, 2: 5},
),
patch(
f"{_SCANCONF}._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
):
scanning._report_poll_confidence(chosen, games, _state([2], current=1))
assert any("NEW LOW" in s for s in echoed)
assert chosen.comp_100_count == 1
def test_report_poll_confidence_no_history(self) -> None:
echoed: list[str] = []
chosen = GameInfo(
app_id=1,
name="Chosen",
total_achievements=10,
unlocked_achievements=0,
playtime_minutes=0,
comp_100_count=4,
)
with (
patch(f"{_SCANCONF}._backfill_polls_for_finished", return_value={1: 4}),
patch(
f"{_SCANCONF}._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
):
scanning._report_poll_confidence(chosen, [chosen], _state([], current=1))
# No "Historical min" line when no finished games have polls.
assert not any("Historical min" in s for s in echoed)
assert any("HLTB confidence: 4" in s for s in echoed)
def test_scanning_backfill_no_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"2": {"hours": 1.0, "polls": 5}}), encoding="utf-8"
)
with patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file):
result = _scanning_confidence._backfill_polls_for_finished(
_state([2]),
[
GameInfo(
app_id=2,
name="X",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
],
)
assert result == {2: 5}
def test_scanning_backfill_with_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"2": {"hours": 3.0, "polls": 0}}), encoding="utf-8"
)
def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": 3.0, "polls": 8}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {aid: 3.0 for aid, _ in games}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
):
result = _scanning_confidence._backfill_polls_for_finished(
_state([2]),
[
GameInfo(
app_id=2,
name="X",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
],
)
assert result == {2: 8}
def test_scanning_backfill_preserves_hours_on_miss(self, tmp_path: Path) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"2": {"hours": 9.0, "polls": 0}}), encoding="utf-8"
)
def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
data = json.loads(cache_file.read_text(encoding="utf-8"))
for aid, _name in games:
data[str(aid)] = {"hours": -1, "polls": 0}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {aid: -1 for aid, _ in games}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
):
_scanning_confidence._backfill_polls_for_finished(
_state([2]),
[
GameInfo(
app_id=2,
name="X",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
],
)
final = json.loads(cache_file.read_text(encoding="utf-8"))
assert final["2"]["hours"] == 9.0
def test_report_poll_confidence_chosen_zero_polls(self) -> None:
"""Covers scanning.py 301-302: 0-poll chosen with history yields warning."""
echoed: list[str] = []
chosen = GameInfo(
app_id=1,
name="Chosen",
total_achievements=10,
unlocked_achievements=0,
playtime_minutes=0,
comp_100_count=0,
)
old = GameInfo(
app_id=2,
name="Old",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=0,
)
with (
patch(
f"{_SCANCONF}._backfill_polls_for_finished",
return_value={1: 0, 2: 5},
),
patch(
f"{_SCANCONF}._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
):
scanning._report_poll_confidence(
chosen, [chosen, old], _state([2], current=1)
)
assert any("no polls recorded" in s for s in echoed)
def test_do_scan_kept_assignment_missing_game(self) -> None:
"""Covers scanning.py 110->116: current_app_id set but game absent."""
from python_pkg.steam_backlog_enforcer.config import Config
from python_pkg.steam_backlog_enforcer.scanning import do_scan
other = GameInfo(
app_id=999,
name="Other",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=0,
)
mock_client = MagicMock()
mock_client.build_game_list.return_value = [other]
with (
patch(f"{_SCAN}.SteamAPIClient", return_value=mock_client),
patch(f"{_SCAN}.fetch_hltb_times_cached", return_value={999: 10.0}),
patch(f"{_SCAN}.save_snapshot"),
patch(f"{_SCAN}.pick_next_game") as mock_pick,
patch(f"{_SCAN}._echo"),
patch(f"{_SCAN}._report_poll_confidence") as mock_report,
):
config = Config(steam_api_key="k", steam_id="i")
state = State(current_app_id=440) # not in games
do_scan(config, state)
mock_pick.assert_not_called()
mock_report.assert_not_called()
def test_cmd_done_no_finished_history_chosen_has_polls(self) -> None:
"""Covers _cmd_done.py 100->103: no finished history, chosen has >0 polls."""
echoed: list[str] = []
with (
patch(
f"{_CMD}._backfill_polls_for_finished",
return_value={1: 7},
),
patch(
f"{_CMD}.load_snapshot",
return_value=[
{"app_id": 1, "name": "Chosen"},
],
),
patch(f"{_CMD}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
_cmd_done._report_assigned_confidence(1, _state([], current=1))
assert any("HLTB confidence: 7" in s for s in echoed)
assert not any("NEW LOW" in s for s in echoed)
assert not any("no polls recorded" in s for s in echoed)
def test_report_poll_confidence_chosen_equals_min(self) -> None:
"""Covers scanning.py 301->304: chosen_polls >= min_polls, no warning."""
echoed: list[str] = []
chosen = GameInfo(
app_id=1,
name="Chosen",
total_achievements=10,
unlocked_achievements=0,
playtime_minutes=0,
comp_100_count=5,
)
old = GameInfo(
app_id=2,
name="Old",
total_achievements=10,
unlocked_achievements=10,
playtime_minutes=0,
)
with (
patch(
f"{_SCANCONF}._backfill_polls_for_finished",
return_value={1: 5, 2: 5},
),
patch(
f"{_SCANCONF}._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
):
scanning._report_poll_confidence(
chosen, [chosen, old], _state([2], current=1)
)
assert not any("NEW LOW" in s for s in echoed)
assert not any("no polls recorded" in s for s in echoed)
def test_refresh_candidate_confidence_noop_when_present(self) -> None:
game = GameInfo(
app_id=1,
name="Known",
total_achievements=10,
unlocked_achievements=1,
playtime_minutes=0,
comp_100_count=3,
count_comp=15,
)
with patch(f"{_SCANCONF}.fetch_hltb_confidence_cached") as mock_fetch:
_scanning_confidence._refresh_candidate_confidence(game)
mock_fetch.assert_not_called()
def test_refresh_candidate_confidence_backfills_zeroes(
self, tmp_path: Path
) -> None:
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps({"1": {"hours": 4.0, "polls": 0, "count_comp": 0}}),
encoding="utf-8",
)
game = GameInfo(
app_id=1,
name="NeedsRefresh",
total_achievements=10,
unlocked_achievements=1,
playtime_minutes=0,
comp_100_count=0,
count_comp=0,
)
def fake_fetch(_games: list[tuple[int, str]]) -> dict[int, float]:
data = json.loads(cache_file.read_text(encoding="utf-8"))
data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {1: 4.0}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch),
patch(f"{_SCANCONF}._echo"),
):
_scanning_confidence._refresh_candidate_confidence(game)
assert game.comp_100_count == 3
assert game.count_comp == 15
def test_filter_hltb_confidence_batches_refreshes(self, tmp_path: Path) -> None:
"""Filtering refreshes missing confidence in one batched cache lookup."""
cache_file = tmp_path / "hltb_cache.json"
cache_file.write_text(
json.dumps(
{
"1": {"hours": 4.0, "polls": 0, "count_comp": 0},
"2": {"hours": 5.0, "polls": 0, "count_comp": 0},
}
),
encoding="utf-8",
)
game_a = GameInfo(
app_id=1,
name="A",
total_achievements=10,
unlocked_achievements=1,
playtime_minutes=0,
comp_100_count=0,
count_comp=0,
)
game_b = GameInfo(
app_id=2,
name="B",
total_achievements=10,
unlocked_achievements=1,
playtime_minutes=0,
comp_100_count=0,
count_comp=0,
)
def fake_fetch(games: list[tuple[int, str]]) -> dict[int, float]:
assert sorted(games) == [(1, "A"), (2, "B")]
data = json.loads(cache_file.read_text(encoding="utf-8"))
data["1"] = {"hours": 4.0, "polls": 3, "count_comp": 15}
data["2"] = {"hours": 5.0, "polls": 3, "count_comp": 15}
cache_file.write_text(json.dumps(data), encoding="utf-8")
return {1: 4.0, 2: 5.0}
with (
patch(f"{_TYPES}.HLTB_CACHE_FILE", cache_file),
patch(f"{_TYPES}.CONFIG_DIR", tmp_path),
patch(
f"{_SCANCONF}.fetch_hltb_confidence_cached", side_effect=fake_fetch
) as mock_fetch,
patch(f"{_SCANCONF}._echo"),
):
kept = _scanning_confidence._filter_hltb_confident_candidates(
[game_a, game_b]
)
assert [game.app_id for game in kept] == [1, 2]
mock_fetch.assert_called_once()

View File

@ -1,291 +0,0 @@
"""Tests for protondb module."""
from __future__ import annotations
import asyncio
import json
from typing import TYPE_CHECKING, Any
from unittest.mock import AsyncMock, MagicMock, patch
import aiohttp
from python_pkg.steam_backlog_enforcer.protondb import (
HTTP_NOT_FOUND,
ProtonDBRating,
_fetch_batch,
_fetch_one,
_load_cache,
_rating_from_cache,
_rating_to_dict,
_save_cache,
fetch_protondb_ratings,
)
if TYPE_CHECKING:
from pathlib import Path
class TestProtonDBRating:
"""Tests for ProtonDBRating."""
def test_playable_native(self) -> None:
r = ProtonDBRating(app_id=1, tier="native")
assert r.is_playable is True
def test_playable_platinum(self) -> None:
r = ProtonDBRating(app_id=1, tier="platinum")
assert r.is_playable is True
def test_playable_gold(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold")
assert r.is_playable is True
def test_not_playable_silver(self) -> None:
r = ProtonDBRating(app_id=1, tier="silver")
assert r.is_playable is False
def test_not_playable_bronze(self) -> None:
r = ProtonDBRating(app_id=1, tier="bronze")
assert r.is_playable is False
def test_not_playable_borked(self) -> None:
r = ProtonDBRating(app_id=1, tier="borked")
assert r.is_playable is False
def test_playable_no_data(self) -> None:
r = ProtonDBRating(app_id=1, tier="")
assert r.is_playable is True
def test_playable_pending(self) -> None:
r = ProtonDBRating(app_id=1, tier="pending")
assert r.is_playable is True
def test_gold_trending_silver(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver")
assert r.is_playable is True
def test_gold_trending_gold(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold")
assert r.is_playable is True
def test_silver_trending_gold(self) -> None:
r = ProtonDBRating(app_id=1, tier="silver", trending_tier="gold")
assert r.is_playable is True
def test_gold_no_trending(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="")
assert r.is_playable is True
def test_gold_trending_platinum(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="platinum")
assert r.is_playable is True
def test_gold_trending_unknown(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="unknown")
assert r.is_playable is False
def test_gold_trending_bronze(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze")
assert r.is_playable is False
def test_unknown_tier(self) -> None:
r = ProtonDBRating(app_id=1, tier="unknown_tier")
assert r.is_playable is False
def test_unplayable_reason_no_trending_tier(self) -> None:
r = ProtonDBRating(app_id=1, tier="borked")
assert "tier<" in r.unplayable_reason
def test_unplayable_reason_for_silver_silver(self) -> None:
r = ProtonDBRating(app_id=1, tier="silver", trending_tier="silver")
assert "no gold tier" in r.unplayable_reason
def test_unplayable_reason_for_gold_bronze(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze")
assert "below silver" in r.unplayable_reason
def test_unplayable_reason_empty_when_playable(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold")
assert r.unplayable_reason == ""
class TestProtonDBCache:
"""Tests for cache I/O."""
def test_load_cache_exists(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
):
result = _load_cache()
assert result == {"440": {"tier": "gold"}}
def test_load_cache_missing(self, tmp_path: Path) -> None:
cache_file = tmp_path / "nonexistent.json"
with patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
):
assert _load_cache() == {}
def test_save_cache(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
config_dir = tmp_path
with (
patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
),
patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir),
):
_save_cache({"440": {"tier": "gold"}})
assert cache_file.exists()
class TestRatingConversion:
"""Tests for rating serialization."""
def test_to_dict(self) -> None:
r = ProtonDBRating(
app_id=1,
tier="gold",
trending_tier="platinum",
score=0.9,
confidence="high",
total_reports=100,
)
d = _rating_to_dict(r)
assert d["tier"] == "gold"
assert d["total_reports"] == 100
def test_from_cache(self) -> None:
data: dict[str, Any] = {
"tier": "silver",
"trending_tier": "bronze",
"score": 0.5,
}
r = _rating_from_cache(440, data)
assert r.app_id == 440
assert r.tier == "silver"
assert r.trending_tier == "bronze"
def test_from_cache_defaults(self) -> None:
r = _rating_from_cache(440, {})
assert r.tier == ""
assert r.total_reports == 0
class TestFetchOne:
"""Tests for _fetch_one."""
def test_success(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.raise_for_status = MagicMock()
mock_resp.json = AsyncMock(
return_value={"tier": "gold", "trendingTier": "platinum"}
)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
sem = asyncio.Semaphore(1)
result = asyncio.run(_fetch_one(mock_session, sem, 440))
assert result.tier == "gold"
def test_not_found(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = HTTP_NOT_FOUND
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
sem = asyncio.Semaphore(1)
result = asyncio.run(_fetch_one(mock_session, sem, 440))
assert result.tier == ""
def test_client_error(self) -> None:
mock_resp = AsyncMock()
mock_resp.status = 200
mock_resp.raise_for_status = MagicMock(side_effect=aiohttp.ClientError)
mock_resp.__aenter__ = AsyncMock(return_value=mock_resp)
mock_resp.__aexit__ = AsyncMock(return_value=False)
mock_session = MagicMock()
mock_session.get = MagicMock(return_value=mock_resp)
sem = asyncio.Semaphore(1)
result = asyncio.run(_fetch_one(mock_session, sem, 440))
assert result is None
class TestFetchBatch:
"""Tests for _fetch_batch."""
def test_returns_ratings(self) -> None:
rating = ProtonDBRating(app_id=440, tier="gold")
with patch(
"python_pkg.steam_backlog_enforcer.protondb._fetch_one",
new_callable=AsyncMock,
return_value=rating,
):
result = asyncio.run(_fetch_batch([440]))
assert len(result) == 1
assert result[0].tier == "gold"
def test_filters_none_results(self) -> None:
"""Network failures (None) are filtered out of the batch result."""
rating = ProtonDBRating(app_id=440, tier="gold")
async def mock_fetch_one(
_session: aiohttp.ClientSession,
_sem: asyncio.Semaphore,
app_id: int,
) -> ProtonDBRating | None:
return rating if app_id == 440 else None
with patch(
"python_pkg.steam_backlog_enforcer.protondb._fetch_one",
side_effect=mock_fetch_one,
):
result = asyncio.run(_fetch_batch([440, 999]))
assert len(result) == 1
assert result[0].app_id == 440
class TestFetchProtondbRatings:
"""Tests for fetch_protondb_ratings."""
def test_all_cached(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
cache_file.write_text(json.dumps({"440": {"tier": "gold"}}), encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
):
result = fetch_protondb_ratings([440])
assert 440 in result
assert result[440].tier == "gold"
def test_fetch_uncached(self, tmp_path: Path) -> None:
cache_file = tmp_path / "protondb_cache.json"
config_dir = tmp_path
with (
patch(
"python_pkg.steam_backlog_enforcer.protondb.PROTONDB_CACHE_FILE",
cache_file,
),
patch("python_pkg.steam_backlog_enforcer.protondb.CONFIG_DIR", config_dir),
patch(
"python_pkg.steam_backlog_enforcer.protondb._fetch_batch",
return_value=[ProtonDBRating(app_id=440, tier="platinum")],
),
):
result = fetch_protondb_ratings([440])
assert result[440].tier == "platinum"

View File

@ -1,446 +0,0 @@
"""Tests for scanning module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating
from python_pkg.steam_backlog_enforcer.scanning import (
_pick_playable_candidate,
do_scan,
pick_next_game,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
if TYPE_CHECKING:
from collections.abc import Callable
def _game(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> GameInfo:
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=60,
completionist_hours=hours,
comp_100_count=3,
count_comp=15,
)
class TestDoScan:
"""Tests for do_scan."""
def test_scans_and_picks(self) -> None:
game = _game(app_id=440, name="TF2", total=10, unlocked=5)
mock_client = MagicMock()
def build_game_list(
progress_callback: Callable[..., object] | None = None,
) -> list[GameInfo]:
# Trigger progress callback to cover those lines.
if progress_callback:
progress_callback(50, 100)
progress_callback(100, 100)
return [game]
mock_client.build_game_list.side_effect = build_game_list
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached",
side_effect=lambda _games, progress_cb=None: (
progress_cb(1, 1, 1, "TF2") if progress_cb else None,
{440: 20.0},
)[1],
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.save_snapshot",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
) as mock_pick,
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
),
):
config = Config(steam_api_key="k", steam_id="i")
state = State()
result = do_scan(config, state)
assert len(result) == 1
mock_pick.assert_called_once()
def test_scan_all_complete(self) -> None:
game = _game(app_id=440, name="TF2", total=10, unlocked=10)
mock_client = MagicMock()
def build_game_list(
progress_callback: Callable[..., object] | None = None,
) -> list[GameInfo]:
if progress_callback:
# current=1, total=2 → not %50 and not ==total → covers False branch
progress_callback(1, 2)
return [game]
mock_client.build_game_list.side_effect = build_game_list
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.save_snapshot",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
) as mock_pick,
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
config = Config(steam_api_key="k", steam_id="i")
state = State()
result = do_scan(config, state)
assert len(result) == 1
mock_pick.assert_called_once()
def test_scan_already_assigned(self) -> None:
game = _game(app_id=440, total=10, unlocked=5)
mock_client = MagicMock()
mock_client.build_game_list.return_value = [game]
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_hltb_times_cached",
return_value={440: 20.0},
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.save_snapshot",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
) as mock_pick,
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
config = Config(steam_api_key="k", steam_id="i")
state = State(current_app_id=440)
result = do_scan(config, state)
assert len(result) == 1
mock_pick.assert_not_called()
class TestPickPlayableCandidate:
"""Tests for _pick_playable_candidate."""
def test_finds_playable(self) -> None:
game = _game(app_id=440, name="TF2")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
440: ProtonDBRating(app_id=440, tier="gold"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
result = _pick_playable_candidate([game])
assert result is not None
assert result.app_id == 440
def test_skips_bad_rating(self) -> None:
bad = _game(app_id=1, name="Bad")
good = _game(app_id=2, name="Good")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
1: ProtonDBRating(app_id=1, tier="borked"),
2: ProtonDBRating(app_id=2, tier="platinum"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
result = _pick_playable_candidate([bad, good])
assert result is not None
assert result.app_id == 2
def test_all_unplayable(self) -> None:
game = _game(app_id=1, name="Bad")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
1: ProtonDBRating(app_id=1, tier="borked"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
assert _pick_playable_candidate([game]) is None
def test_empty_list(self) -> None:
assert _pick_playable_candidate([]) is None
def test_first_in_batch_playable(self) -> None:
"""First game in first batch is playable — no skip message."""
game = _game(app_id=440, name="TF2")
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.fetch_protondb_ratings",
return_value={
440: ProtonDBRating(app_id=440, tier="platinum"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
result = _pick_playable_candidate([game])
assert result is not None
class TestPickNextGame:
"""Tests for pick_next_game."""
def test_picks_shortest(self) -> None:
g1 = _game(app_id=1, name="Long", hours=100.0)
g2 = _game(app_id=2, name="Short", hours=10.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer._scanning_confidence._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_no_candidates(self) -> None:
g1 = _game(app_id=1, total=5, unlocked=5)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with patch("python_pkg.steam_backlog_enforcer.scanning._echo"):
pick_next_game([g1], state, config)
assert state.current_app_id is None
def test_skips_finished(self) -> None:
g1 = _game(app_id=1, name="G1", hours=10.0)
g2 = _game(app_id=2, name="G2", hours=20.0)
config = Config(steam_api_key="k", steam_id="i")
state = State(finished_app_ids=[1])
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_no_playable(self) -> None:
g1 = _game(app_id=1, name="G1")
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
return_value=None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
pick_next_game([g1], state, config)
assert state.current_app_id is None
def test_uninstalls_others(self) -> None:
g1 = _game(app_id=1, name="G1", hours=10.0)
config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=True)
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer._scanning_confidence._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=2,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([g1], state, config)
assert state.current_app_id == 1
def test_auto_installs(self) -> None:
g1 = _game(app_id=1, name="G1", hours=10.0)
config = Config(steam_api_key="k", steam_id="i", uninstall_other_games=False)
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer._scanning_confidence._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.install_game"
) as mock_install,
patch("builtins.input", return_value="1"),
):
pick_next_game([g1], state, config)
mock_install.assert_called_once()
def test_unknown_hours(self) -> None:
g1 = _game(app_id=1, name="G1", hours=-1)
g2 = _game(app_id=2, name="G2", hours=10.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_picks_game_no_hours(self) -> None:
"""Chosen game has no HLTB hours — covers no-hours output branch."""
g1 = _game(app_id=1, name="G1", hours=-1)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([g1], state, config)
assert state.current_app_id == 1
def test_skips_low_confidence_and_picks_next(self) -> None:
low = _game(app_id=1, name="LowConfidence", hours=1.0)
low.comp_100_count = 1
low.count_comp = 5
valid = _game(app_id=2, name="ValidConfidence", hours=2.0)
valid.comp_100_count = 3
valid.count_comp = 15
echoed: list[str] = []
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([low, valid], state, config)
assert state.current_app_id == 2
assert any("Skipping LowConfidence" in line for line in echoed)
assert any("comp_100 polls 1 < 3" in line for line in echoed)
def test_all_candidates_filtered_by_confidence(self) -> None:
low_a = _game(app_id=1, name="LowA", hours=1.0)
low_a.comp_100_count = 2
low_a.count_comp = 15
low_b = _game(app_id=2, name="LowB", hours=2.0)
low_b.comp_100_count = 3
low_b.count_comp = 14
echoed: list[str] = []
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
return_value=None,
) as mock_pick,
):
pick_next_game([low_a, low_b], state, config)
assert state.current_app_id is None
mock_pick.assert_not_called()
assert any("No assignable games found" in line for line in echoed)

View File

@ -1,134 +0,0 @@
"""Tests for scanning module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.scanning import (
_check_game_tampering,
detect_tampering,
)
PKG = "python_pkg.steam_backlog_enforcer.scanning"
def _entry(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 5,
playtime: int = 60,
) -> dict[str, Any]:
return {
"app_id": app_id,
"name": name,
"total_achievements": total,
"unlocked_achievements": unlocked,
"playtime_minutes": playtime,
}
class TestCheckGameTampering:
"""Tests for _check_game_tampering."""
def test_current_game_skipped(self) -> None:
state = State(current_app_id=1)
result = _check_game_tampering(MagicMock(), _entry(app_id=1), state)
assert result is None
def test_already_complete_skipped(self) -> None:
state = State()
result = _check_game_tampering(
MagicMock(),
_entry(unlocked=10, total=10),
state,
)
assert result is None
def test_zero_playtime_skipped(self) -> None:
state = State()
result = _check_game_tampering(
MagicMock(),
_entry(playtime=0),
state,
)
assert result is None
def test_no_new_achievements(self) -> None:
client = MagicMock()
game = MagicMock()
game.unlocked_achievements = 5
client.refresh_single_game.return_value = game
state = State()
result = _check_game_tampering(client, _entry(unlocked=5), state)
assert result is None
def test_tampering_detected(self) -> None:
client = MagicMock()
game = MagicMock()
game.unlocked_achievements = 8
client.refresh_single_game.return_value = game
state = State()
entry = _entry(app_id=99, name="Cheated", unlocked=5)
result = _check_game_tampering(client, entry, state)
assert result is not None
assert result == ("Cheated", 99, 3)
def test_refresh_returns_none(self) -> None:
client = MagicMock()
client.refresh_single_game.return_value = None
state = State()
result = _check_game_tampering(client, _entry(), state)
assert result is None
class TestDetectTampering:
"""Tests for detect_tampering."""
def test_no_snapshot(self) -> None:
with patch(f"{PKG}.load_snapshot", return_value=None):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
def test_no_tampering(self) -> None:
entries = [_entry(app_id=1)]
with (
patch(f"{PKG}.load_snapshot", return_value=entries),
patch(f"{PKG}.SteamAPIClient"),
patch(f"{PKG}._check_game_tampering", return_value=None),
patch(f"{PKG}._echo"),
):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
def test_tampering_found(self) -> None:
entries = [_entry(app_id=1, name="BadGame")]
with (
patch(f"{PKG}.load_snapshot", return_value=entries),
patch(f"{PKG}.SteamAPIClient"),
patch(
f"{PKG}._check_game_tampering",
return_value=("BadGame", 1, 3),
),
patch(f"{PKG}._echo") as mock_echo,
patch(f"{PKG}.send_notification"),
):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
assert any("TAMPERING" in str(c) for c in mock_echo.call_args_list)
def test_stops_at_limit(self) -> None:
"""Stops after _TAMPER_CHECK_LIMIT suspicious games."""
entries = [_entry(app_id=i, name=f"G{i}") for i in range(10)]
with (
patch(f"{PKG}.load_snapshot", return_value=entries),
patch(f"{PKG}.SteamAPIClient"),
patch(
f"{PKG}._check_game_tampering",
return_value=("Game", 1, 1),
) as mock_check,
patch(f"{PKG}._echo"),
patch(f"{PKG}.send_notification"),
):
detect_tampering(Config(steam_api_key="k", steam_id="i"), State())
# Should stop after 3 (_TAMPER_CHECK_LIMIT)
assert mock_check.call_count == 3

View File

@ -1,414 +0,0 @@
"""Tests for scanning module (part 3): TestPickNextGame continued."""
from __future__ import annotations
import contextlib
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.scanning import pick_next_game
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
def _game(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> GameInfo:
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=60,
completionist_hours=hours,
comp_100_count=3,
count_comp=15,
)
class TestPickNextGame:
"""Tests for pick_next_game (continued from test_scanning.py)."""
def test_zero_confidence_is_refreshed_before_skipping(self) -> None:
"""Missing confidence fields are refreshed once before final skip decision."""
stale = _game(app_id=1, name="Celeste", hours=1.0)
stale.comp_100_count = 0
stale.count_comp = 0
fallback = _game(app_id=2, name="Fallback", hours=2.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
def refresh_side_effect(game: GameInfo) -> None:
if game.app_id == 1:
game.comp_100_count = 899
game.count_comp = 14055
with (
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence",
side_effect=refresh_side_effect,
) as mock_refresh,
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([stale, fallback], state, config)
assert state.current_app_id == 1
mock_refresh.assert_called_once_with(stale)
assert not any("Skipping Celeste" in line for line in echoed)
def test_nonzero_low_confidence_does_not_force_refetch(self) -> None:
"""Non-zero low-confidence entries are skipped using cached values."""
low = _game(app_id=1, name="Low", hours=1.0)
low.comp_100_count = 1
low.count_comp = 8
fallback = _game(app_id=2, name="Fallback", hours=2.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch"
) as mock_refresh_batch,
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([low, fallback], state, config)
assert state.current_app_id == 2
mock_refresh_batch.assert_not_called()
def test_cached_confidence_overlay_avoids_refetch_for_zero_snapshot_fields(
self,
) -> None:
"""Use cached confidence before deciding whether refresh is needed."""
low = _game(app_id=1, name="Low", hours=1.0)
low.comp_100_count = 0
low.count_comp = 0
fallback = _game(app_id=2, name="Fallback", hours=2.0)
fallback.comp_100_count = 3
fallback.count_comp = 20
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache",
return_value={1: 1, 2: 3},
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache",
return_value={1: 8, 2: 20},
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch"
) as mock_refresh_batch,
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game([low, fallback], state, config)
assert state.current_app_id == 2
mock_refresh_batch.assert_not_called()
def test_stops_collecting_after_n_qualified(self) -> None:
"""Collection stops once _PICK_LIST_SIZE candidates are qualified."""
# Create 11 games that all pass filters; only the first 10 should be
# presented and the 11th should never trigger a ProtonDB call.
games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 12)]
protondb_call_count = 0
def playable_side_effect(c: list[GameInfo]) -> GameInfo | None:
nonlocal protondb_call_count
protondb_call_count += 1
return c[0] if c else None
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=playable_side_effect,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="1"),
):
pick_next_game(games, state, config)
assert state.current_app_id == 1
assert protondb_call_count == 10
def test_user_picks_second_candidate(self) -> None:
"""User can select a game other than the shortest one."""
g1 = _game(app_id=1, name="Short", hours=5.0)
g2 = _game(app_id=2, name="Medium", hours=15.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", return_value="2"),
):
pick_next_game([g1, g2], state, config)
assert state.current_app_id == 2
def test_invalid_input_then_valid(self) -> None:
"""Non-numeric input prints error and loops until valid input."""
g1 = _game(app_id=1, name="G1", hours=5.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", side_effect=["abc", "1"]),
):
pick_next_game([g1], state, config)
assert state.current_app_id == 1
assert any("Invalid input" in line for line in echoed)
def test_out_of_range_then_valid(self) -> None:
"""Out-of-range number prints error and loops until valid input."""
g1 = _game(app_id=1, name="G1", hours=5.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
),
patch("builtins.input", side_effect=["99", "1"]),
):
pick_next_game([g1], state, config)
assert state.current_app_id == 1
assert any("Out of range" in line for line in echoed)
class TestPickNextGameSequential:
"""Tests for the on_select sequential branch of pick_next_game."""
@staticmethod
def _common_patches(echoed: list[str]) -> contextlib.ExitStack:
stack = contextlib.ExitStack()
stack.enter_context(
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
)
)
stack.enter_context(
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
)
)
stack.enter_context(
patch(
"python_pkg.steam_backlog_enforcer.scanning.is_game_installed",
return_value=True,
)
)
stack.enter_context(
patch(
"python_pkg.steam_backlog_enforcer.scanning.uninstall_other_games",
return_value=0,
)
)
stack.enter_context(
patch("python_pkg.steam_backlog_enforcer.config._atomic_write")
)
return stack
def test_on_select_accepts_pick(self) -> None:
g1 = _game(app_id=1, name="G1", hours=5.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
with self._common_patches(echoed):
pick_next_game([g1], state, config, on_select=lambda _g: True)
assert state.current_app_id == 1
def test_on_select_rejection_records_skip_and_picks_next(self) -> None:
g1 = _game(app_id=1, name="G1", hours=5.0)
g2 = _game(app_id=2, name="G2", hours=6.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
calls: list[int] = []
def on_select(game: GameInfo) -> bool:
calls.append(game.app_id)
return game.app_id == 2 # reject g1, accept g2
with self._common_patches(echoed):
pick_next_game([g1, g2], state, config, on_select=on_select)
assert calls == [1, 2]
assert state.current_app_id == 2
assert "1" in state.skipped_until
assert any("Skipped G1 for 7 days" in line for line in echoed)
def test_on_select_no_candidates(self) -> None:
"""Sequential branch with no candidates clears state."""
complete = _game(app_id=1, hours=1.0, total=10, unlocked=10)
config = Config(steam_api_key="k", steam_id="i")
state = State(current_app_id=99, current_game_name="X")
echoed: list[str] = []
with self._common_patches(echoed):
pick_next_game([complete], state, config, on_select=lambda _g: True)
assert state.current_app_id is None
def test_on_select_no_playable_branch(self) -> None:
"""Sequential branch when all candidates lack Linux compatibility."""
g1 = _game(app_id=1, name="G1", hours=5.0)
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
return_value=None,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch("python_pkg.steam_backlog_enforcer.config._atomic_write"),
):
pick_next_game([g1], state, config, on_select=lambda _g: True)
assert state.current_app_id is None
assert any("No playable games" in line for line in echoed)
def test_on_select_no_confidence_branch(self) -> None:
"""Sequential branch when all candidates fail HLTB confidence."""
g1 = _game(app_id=1, name="G1", hours=5.0)
g1.comp_100_count = 0
g1.count_comp = 0
config = Config(steam_api_key="k", steam_id="i")
state = State()
echoed: list[str] = []
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning._echo",
side_effect=lambda *a, **_: echoed.append(a[0]),
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence",
),
patch("python_pkg.steam_backlog_enforcer.config._atomic_write"),
):
pick_next_game([g1], state, config, on_select=lambda _g: True)
assert state.current_app_id is None
assert any("No assignable games" in line for line in echoed)
def test_enforcement_started_at_not_overwritten_when_set(self) -> None:
"""enforcement_started_at is preserved when already populated."""
g1 = _game(app_id=1, name="G1", hours=5.0)
config = Config(steam_api_key="k", steam_id="i")
existing_ts = "2024-01-01T00:00:00+00:00"
state = State(enforcement_started_at=existing_ts)
with self._common_patches([]):
pick_next_game([g1], state, config, on_select=lambda _g: True)
assert state.current_app_id == 1
assert state.enforcement_started_at == existing_ts

View File

@ -1,328 +0,0 @@
"""Scanning tests (part 4): collect_top_candidates, do_check, confidence."""
from __future__ import annotations
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer._scanning_confidence import (
_filter_hltb_confident_candidates,
_force_refresh_candidate_confidence,
_refresh_candidate_confidence_batch,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.scanning import (
_collect_top_candidates,
_pick_next_shortest_candidate,
do_check,
)
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
def _game(
app_id: int = 1,
name: str = "G",
total: int = 10,
unlocked: int = 0,
hours: float = -1,
) -> GameInfo:
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=60,
completionist_hours=hours,
comp_100_count=3,
count_comp=15,
)
class TestCollectTopCandidates:
"""Tests for _collect_top_candidates."""
def test_collects_up_to_n(self) -> None:
"""Returns at most n qualified candidates."""
games = [_game(app_id=i, name=f"G{i}", hours=float(i)) for i in range(1, 6)]
with patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
):
qualified, conf_skip, linux_skip = _collect_top_candidates(games, n=3)
assert len(qualified) == 3
assert [g.app_id for g in qualified] == [1, 2, 3]
assert conf_skip == 0
assert linux_skip == 0
def test_skips_linux_incompatible(self) -> None:
"""Games failing ProtonDB are counted in linux_skipped."""
g1 = _game(app_id=1, name="Borked", hours=1.0)
g2 = _game(app_id=2, name="Good", hours=2.0)
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: None if c[0].app_id == 1 else c[0],
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
qualified, conf_skip, linux_skip = _collect_top_candidates([g1, g2], n=10)
assert [g.app_id for g in qualified] == [2]
assert linux_skip == 1
assert conf_skip == 0
def test_empty_candidates(self) -> None:
qualified, conf_skip, linux_skip = _collect_top_candidates([])
assert qualified == []
assert conf_skip == 0
assert linux_skip == 0
def test_no_linux_skip_message_when_zero(self) -> None:
"""No skip message is printed when linux_skipped is 0."""
g = _game(app_id=1, name="Good", hours=1.0)
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=lambda c: c[0] if c else None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo,
):
_collect_top_candidates([g], n=10)
mock_echo.assert_not_called()
class TestDoCheck:
"""Tests for do_check."""
def test_no_assignment(self) -> None:
with patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo:
do_check(Config(), State())
mock_echo.assert_called()
def test_fetch_fails(self) -> None:
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = None
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)
class TestConfidenceHelpers:
"""Coverage-focused tests for scanning confidence helper branches."""
def test_force_refresh_candidate_confidence_delegates(self) -> None:
game = _game(app_id=10, name="A")
with patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch",
) as mock_batch:
_force_refresh_candidate_confidence(game)
mock_batch.assert_called_once_with([game], force=True)
def test_refresh_candidate_confidence_batch_no_missing_skips_fetch(self) -> None:
game = _game(app_id=20, name="B", hours=12.0)
game.comp_100_count = 3
game.count_comp = 15
with patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached",
) as mock_fetch:
_refresh_candidate_confidence_batch([game], force=False)
mock_fetch.assert_not_called()
def test_refresh_candidate_confidence_batch_preserves_existing_hours(self) -> None:
game = _game(app_id=30, name="C", hours=9.5)
game.comp_100_count = 0
game.count_comp = 0
with (
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_cache",
side_effect=[{30: 9.5}, {30: -1.0}],
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_polls_cache",
return_value={30: 0},
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.load_hltb_count_comp_cache",
return_value={30: 0},
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.fetch_hltb_confidence_cached",
return_value={30: -1.0},
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence.save_hltb_cache",
) as mock_save,
):
_refresh_candidate_confidence_batch([game], force=True)
assert game.completionist_hours == 9.5
saved_cache = mock_save.call_args.args[0]
assert saved_cache[30] == 9.5
def test_filter_hltb_confident_candidates_skips_low_confidence(self) -> None:
low = _game(app_id=40, name="Low", hours=2.0)
low.comp_100_count = 1
low.count_comp = 2
with (
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence_batch",
),
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._echo"
) as mock_echo,
):
result = _filter_hltb_confident_candidates([low])
assert result == []
assert mock_echo.called
def test_pick_next_shortest_candidate_logs_skipped_unplayable_batches(self) -> None:
bad = _game(app_id=50, name="Bad", hours=1.0)
good = _game(app_id=51, name="Good", hours=2.0)
bad.comp_100_count = 3
bad.count_comp = 15
good.comp_100_count = 3
good.count_comp = 15
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
side_effect=[None, good],
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo,
):
picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate(
[bad, good],
)
assert picked is good
assert skipped_low_conf == 0
assert skipped_linux == 1
assert any(
"Skipped 1 game(s) with poor Linux compatibility" in str(call)
for call in mock_echo.call_args_list
)
def test_pick_next_shortest_candidate_no_echo_when_linux_skipped_zero(
self,
) -> None:
"""Covers 419->423: no echo printed when linux_skipped == 0."""
good = _game(app_id=51, name="Good", hours=2.0)
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
return_value=good,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo,
):
picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate(
[good],
)
assert picked is good
assert skipped_linux == 0
mock_echo.assert_not_called()
def test_pick_next_shortest_candidate_skips_low_confidence(self) -> None:
"""Covers lines 413-414: confidence_skipped += 1; continue."""
low_conf = _game(app_id=10, name="Low", hours=1.0)
low_conf.comp_100_count = 0
low_conf.count_comp = 0
with (
patch(
"python_pkg.steam_backlog_enforcer._scanning_confidence._refresh_candidate_confidence"
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
):
picked, skipped_low_conf, skipped_linux = _pick_next_shortest_candidate(
[low_conf],
)
assert picked is None
assert skipped_low_conf == 1
assert skipped_linux == 0
def test_pick_next_shortest_candidate_all_protondb_fail(self) -> None:
"""Covers lines 426-428: linux_skipped > 0 after loop, return None."""
g1 = _game(app_id=10, name="Borked", hours=1.0)
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning._pick_playable_candidate",
return_value=None,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo") as mock_echo,
):
picked, _skipped_low_conf, skipped_linux = _pick_next_shortest_candidate(
[g1],
)
assert picked is None
assert skipped_linux == 1
assert any(
"Skipped 1 game(s) with poor Linux compatibility" in str(call)
for call in mock_echo.call_args_list
)
game = _game(app_id=440, name="TF2", total=5, unlocked=5)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
snap = [game.to_snapshot()]
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.send_notification",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.load_snapshot",
return_value=snap,
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.pick_next_game",
),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)
assert 440 in state.finished_app_ids
def test_complete_no_snapshot(self) -> None:
game = _game(app_id=440, name="TF2", total=5, unlocked=5)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch(
"python_pkg.steam_backlog_enforcer.scanning.send_notification",
),
patch(
"python_pkg.steam_backlog_enforcer.scanning.load_snapshot",
return_value=None,
),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)
def test_not_complete(self) -> None:
game = _game(app_id=440, name="TF2", total=10, unlocked=5)
mock_client = MagicMock()
mock_client.refresh_single_game.return_value = game
with (
patch(
"python_pkg.steam_backlog_enforcer.scanning.SteamAPIClient",
return_value=mock_client,
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer.scanning.detect_tampering"),
):
state = State(current_app_id=440, current_game_name="TF2")
do_check(Config(steam_api_key="k", steam_id="i"), state)

View File

@ -1,701 +0,0 @@
"""Tests for _stats module — 100% branch coverage."""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from unittest.mock import patch
from python_pkg.steam_backlog_enforcer._stats import (
_ensure_rush_data,
_filter_qualifying_games,
_format_completion_date,
_GameTimes,
_print_pace_scenario,
_print_scenario,
_print_worst_example,
_sum_hours,
cmd_stats,
)
from python_pkg.steam_backlog_enforcer.config import Config, State
from python_pkg.steam_backlog_enforcer.protondb import ProtonDBRating
from python_pkg.steam_backlog_enforcer.steam_api import GameInfo
_PKG = "python_pkg.steam_backlog_enforcer._stats"
def _game(
app_id: int = 1,
name: str = "G",
hours: float = 10.0,
total: int = 10,
unlocked: int = 0,
) -> GameInfo:
return GameInfo(
app_id=app_id,
name=name,
total_achievements=total,
unlocked_achievements=unlocked,
playtime_minutes=60,
completionist_hours=hours,
comp_100_count=5,
count_comp=20,
)
def _unplayable_rating(app_id: int) -> ProtonDBRating:
return ProtonDBRating(app_id=app_id, tier="borked")
class TestFilterQualifyingGames:
"""Tests for _filter_qualifying_games."""
def _run(
self,
games: list[GameInfo],
state: State,
rush_cache: dict[int, float] | None = None,
leisure_cache: dict[int, float] | None = None,
game_id_cache: dict[int, int] | None = None,
) -> tuple[list[_GameTimes], int, int, int]:
with (
patch(f"{_PKG}.load_hltb_rush_cache", return_value=rush_cache or {}),
patch(
f"{_PKG}.load_hltb_leisure_100h_cache",
return_value=leisure_cache or {},
),
patch(
f"{_PKG}.load_hltb_game_id_cache",
return_value=game_id_cache or {},
),
patch(f"{_PKG}._apply_cached_confidence_to_candidates"),
patch(f"{_PKG}._refresh_candidate_confidence_batch"),
patch(f"{_PKG}._confidence_fail_reasons", return_value=[]),
patch(f"{_PKG}.fetch_protondb_ratings", return_value={}),
):
return _filter_qualifying_games(games, state)
def test_current_app_id_excluded(self) -> None:
state = State(current_app_id=1)
g1 = _game(app_id=1)
g2 = _game(app_id=2)
qualified, _, _, _ = self._run([g1, g2], state)
ids = [e.game.app_id for e in qualified]
assert 1 not in ids
assert 2 in ids
def test_no_current_app_id_branch(self) -> None:
"""current_app_id is None — the exclude.add branch is not taken."""
state = State(current_app_id=None)
g = _game(app_id=3)
qualified, _, _, _ = self._run([g], state)
assert len(qualified) == 1
def test_finished_app_ids_excluded(self) -> None:
state = State()
state.finished_app_ids = [1]
g1 = _game(app_id=1)
g2 = _game(app_id=2)
qualified, _, _, _ = self._run([g1, g2], state)
assert all(e.game.app_id != 1 for e in qualified)
def test_complete_games_excluded(self) -> None:
"""Games where is_complete is True are excluded from candidates."""
state = State()
complete = _game(app_id=1, total=5, unlocked=5)
incomplete = _game(app_id=2, total=5, unlocked=0)
qualified, _, _, _ = self._run([complete, incomplete], state)
assert len(qualified) == 1
assert qualified[0].game.app_id == 2
def test_low_confidence_counts_hltb_skipped(self) -> None:
state = State()
g = _game(app_id=1)
with (
patch(f"{_PKG}.load_hltb_rush_cache", return_value={}),
patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{_PKG}._apply_cached_confidence_to_candidates"),
patch(f"{_PKG}._refresh_candidate_confidence_batch"),
patch(f"{_PKG}._confidence_fail_reasons", return_value=["low"]),
patch(f"{_PKG}.fetch_protondb_ratings", return_value={}),
):
qualified, hltb_skip, _, _ = _filter_qualifying_games([g], state)
assert hltb_skip == 1
assert len(qualified) == 0
def test_no_candidates_skips_protondb_call(self) -> None:
"""When confidence filters all out, fetch_protondb_ratings is not called."""
state = State()
g = _game(app_id=1)
with (
patch(f"{_PKG}.load_hltb_rush_cache", return_value={}),
patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{_PKG}._apply_cached_confidence_to_candidates"),
patch(f"{_PKG}._refresh_candidate_confidence_batch"),
patch(f"{_PKG}._confidence_fail_reasons", return_value=["low"]),
patch(f"{_PKG}.fetch_protondb_ratings") as mock_proton,
):
_filter_qualifying_games([g], state)
mock_proton.assert_not_called()
def test_unplayable_rating_counts_linux_skipped(self) -> None:
state = State()
g = _game(app_id=1)
ratings = {1: _unplayable_rating(1)}
with (
patch(f"{_PKG}.load_hltb_rush_cache", return_value={}),
patch(f"{_PKG}.load_hltb_leisure_100h_cache", return_value={}),
patch(f"{_PKG}.load_hltb_game_id_cache", return_value={}),
patch(f"{_PKG}._apply_cached_confidence_to_candidates"),
patch(f"{_PKG}._refresh_candidate_confidence_batch"),
patch(f"{_PKG}._confidence_fail_reasons", return_value=[]),
patch(f"{_PKG}.fetch_protondb_ratings", return_value=ratings),
):
qualified, _, linux_skip, _ = _filter_qualifying_games([g], state)
assert linux_skip == 1
assert len(qualified) == 0
def test_no_data_counts_no_data_skipped(self) -> None:
"""Game with all -1 hours is counted as no_data_skipped."""
state = State()
g = _game(app_id=1, hours=-1.0)
qualified, _, _, no_data_skip = self._run([g], state)
assert no_data_skip == 1
assert len(qualified) == 0
def test_worst_hours_positive_when_completionist_hours_positive(self) -> None:
state = State()
g = _game(app_id=1, hours=25.0)
qualified, _, _, _ = self._run([g], state, rush_cache={1: 10.0})
assert qualified[0].worst_hours == 25.0
def test_worst_hours_from_leisure_when_completionist_zero(self) -> None:
"""worst_hours falls back to leisure_100h when completionist_hours is zero."""
state = State()
g = _game(app_id=1, hours=0.0)
qualified, _, _, _ = self._run(
[g], state, rush_cache={1: 5.0}, leisure_cache={1: 6.0}
)
assert qualified[0].worst_hours == 6.0
def test_worst_hours_is_max_when_leisure_exceeds_completionist(self) -> None:
"""worst_hours is max(completionist, leisure_100h) when leisure is higher."""
state = State()
g = _game(app_id=1, hours=25.0)
qualified, _, _, _ = self._run(
[g], state, rush_cache={1: 10.0}, leisure_cache={1: 40.0}
)
assert qualified[0].worst_hours == 40.0
def test_worst_hours_negative_when_all_zero(self) -> None:
"""worst_hours = -1 when both completionist_hours and leisure_100h are zero."""
state = State()
g = _game(app_id=1, hours=0.0)
qualified, _, _, _ = self._run([g], state, rush_cache={1: 5.0})
assert qualified[0].worst_hours == -1
def test_rush_and_leisure_from_cache(self) -> None:
state = State()
g = _game(app_id=1, hours=30.0)
qualified, _, _, _ = self._run(
[g], state, rush_cache={1: 12.0}, leisure_cache={1: 40.0}
)
assert qualified[0].rush_hours == 12.0
assert qualified[0].leisure_100h == 40.0
def test_missing_cache_entry_defaults_to_minus_one(self) -> None:
state = State()
g = _game(app_id=1, hours=20.0)
qualified, _, _, _ = self._run([g], state)
assert qualified[0].rush_hours == -1
assert qualified[0].leisure_100h == -1
def test_only_rush_nonzero_qualifies(self) -> None:
"""Game qualifies if only rush_hours is positive (worst <= 0, leisure <= 0)."""
state = State()
g = _game(app_id=1, hours=-1.0)
qualified, _, _, no_data_skip = self._run([g], state, rush_cache={1: 8.0})
assert no_data_skip == 0
assert len(qualified) == 1
def test_game_id_populated_from_cache(self) -> None:
"""hltb_game_id is taken from game_id_cache."""
state = State()
g = _game(app_id=1, hours=20.0)
qualified, _, _, _ = self._run([g], state, game_id_cache={1: 57514})
assert qualified[0].hltb_game_id == 57514
def test_game_id_defaults_to_zero_when_not_in_cache(self) -> None:
"""hltb_game_id defaults to 0 when not in cache."""
state = State()
g = _game(app_id=1, hours=20.0)
qualified, _, _, _ = self._run([g], state)
assert qualified[0].hltb_game_id == 0
class TestSumHours:
"""Tests for _sum_hours."""
def _make_entry(self, worst: float, rush: float, leisure: float) -> _GameTimes:
return _GameTimes(
game=_game(), worst_hours=worst, rush_hours=rush, leisure_100h=leisure
)
def test_empty_list(self) -> None:
total, missing = _sum_hours([], "worst_hours")
assert total == 0.0
assert missing == 0
def test_all_positive(self) -> None:
entries = [
self._make_entry(10.0, 8.0, 12.0),
self._make_entry(20.0, 15.0, 25.0),
]
total, missing = _sum_hours(entries, "worst_hours")
assert total == 30.0
assert missing == 0
def test_some_negative(self) -> None:
entries = [
self._make_entry(10.0, -1.0, 12.0),
self._make_entry(-1.0, 8.0, 25.0),
]
total, missing = _sum_hours(entries, "worst_hours")
assert total == 10.0
assert missing == 1
def test_all_negative(self) -> None:
entries = [self._make_entry(-1.0, -1.0, -1.0)]
total, missing = _sum_hours(entries, "rush_hours")
assert total == 0.0
assert missing == 1
class TestFormatCompletionDate:
"""Tests for _format_completion_date."""
def test_zero_hours_returns_na(self) -> None:
assert _format_completion_date(0.0, 4.0) == "N/A"
def test_negative_hours_returns_na(self) -> None:
assert _format_completion_date(-5.0, 4.0) == "N/A"
def test_zero_daily_hours_returns_na(self) -> None:
assert _format_completion_date(100.0, 0.0) == "N/A"
def test_negative_daily_hours_returns_na(self) -> None:
assert _format_completion_date(100.0, -1.0) == "N/A"
def test_normal_returns_days_and_date(self) -> None:
result = _format_completion_date(40.0, 4.0)
# 40 / 4 = 10 days
assert result.startswith("10 days (")
assert ")" in result
class TestPrintScenario:
"""Tests for _print_scenario."""
def test_no_data_prints_no_data_message(self) -> None:
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_scenario("2. RUSH", 0.0, 0, 5)
assert any("No data available" in s for s in echoed)
def test_with_data_no_missing(self) -> None:
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_scenario("2. RUSH", 100.0, 0, 5)
assert any("Total:" in s for s in echoed)
assert not any("had no data" in s for s in echoed)
def test_with_data_and_missing(self) -> None:
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_scenario("2. RUSH", 100.0, 2, 5)
assert any("had no data" in s for s in echoed)
class TestPrintPaceScenario:
"""Tests for _print_pace_scenario."""
def test_no_start_date(self) -> None:
state = State()
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_pace_scenario(state, 10, 0)
assert any("No start date recorded" in s for s in echoed)
def test_invalid_start_date(self) -> None:
state = State(enforcement_started_at="not-a-date")
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_pace_scenario(state, 10, 0)
assert any("Invalid enforcement_started_at" in s for s in echoed)
def test_no_games_finished(self) -> None:
started = datetime.now(timezone.utc) - timedelta(days=30)
state = State(enforcement_started_at=started.isoformat())
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_pace_scenario(state, 10, 0)
assert any("No games finished yet" in s for s in echoed)
def test_normal_pace(self) -> None:
started = datetime.now(timezone.utc) - timedelta(days=60)
state = State(enforcement_started_at=started.isoformat())
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_pace_scenario(state, 5, 3)
assert any("Pace:" in s for s in echoed)
assert any("Est. complete:" in s for s in echoed)
class TestCmdStats:
"""Tests for cmd_stats."""
def _config(self) -> Config:
return Config(steam_api_key="k", steam_id="i")
def test_no_snapshot(self) -> None:
echoed: list[str] = []
state = State()
with (
patch(f"{_PKG}.load_snapshot", return_value=None),
patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
):
cmd_stats(self._config(), state)
assert any("No snapshot found" in s for s in echoed)
def _snapshot_game(self, app_id: int = 1, hours: float = 20.0) -> dict[str, object]:
return {
"app_id": app_id,
"name": f"Game{app_id}",
"total_achievements": 10,
"unlocked_achievements": 0,
"playtime_minutes": 60,
"completionist_hours": hours,
"comp_100_count": 5,
"count_comp": 20,
}
def _run_cmd_stats(
self,
state: State,
hltb_skip: int = 0,
linux_skip: int = 0,
no_data_skip: int = 0,
) -> list[str]:
snapshot = [self._snapshot_game()]
game = GameInfo.from_snapshot(snapshot[0])
entry = _GameTimes(
game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0
)
echoed: list[str] = []
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(
f"{_PKG}._filter_qualifying_games",
return_value=([entry], hltb_skip, linux_skip, no_data_skip),
),
patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
patch(f"{_PKG}._print_pace_scenario"),
patch(f"{_PKG}._print_scenario"),
):
cmd_stats(self._config(), state)
return echoed
def test_with_no_current_game(self) -> None:
state = State()
echoed = self._run_cmd_stats(state)
assert any("Qualifying games" in s for s in echoed)
assert not any("Current game:" in s for s in echoed)
def test_with_current_game(self) -> None:
state = State(current_app_id=42, current_game_name="Hollow Knight")
echoed = self._run_cmd_stats(state)
assert any("Current game:" in s and "Hollow Knight" in s for s in echoed)
def test_hltb_skipped_shown(self) -> None:
state = State()
echoed = self._run_cmd_stats(state, hltb_skip=3)
assert any("HLTB-skipped" in s for s in echoed)
def test_linux_skipped_shown(self) -> None:
state = State()
echoed = self._run_cmd_stats(state, linux_skip=2)
assert any("Linux-skipped" in s for s in echoed)
def test_no_data_skipped_shown(self) -> None:
state = State()
echoed = self._run_cmd_stats(state, no_data_skip=1)
assert any("No-data-skipped" in s for s in echoed)
def test_zero_skips_not_shown(self) -> None:
state = State()
echoed = self._run_cmd_stats(state)
assert not any("HLTB-skipped" in s for s in echoed)
assert not any("Linux-skipped" in s for s in echoed)
assert not any("No-data-skipped" in s for s in echoed)
def test_finished_games_count_uses_snapshot_complete(self) -> None:
"""'Finished games' count uses snapshot is_complete, not finished_app_ids."""
state = State()
# finished_app_ids has 1 entry, but snapshot has 2 complete games — count = 2.
state.finished_app_ids = [99]
snapshot_complete = {
**self._snapshot_game(app_id=2),
"unlocked_achievements": 10,
}
snapshot = [self._snapshot_game(app_id=1), snapshot_complete]
game = GameInfo.from_snapshot(self._snapshot_game())
entry = _GameTimes(
game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0
)
echoed: list[str] = []
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(
f"{_PKG}._filter_qualifying_games",
return_value=([entry], 0, 0, 0),
),
patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
patch(f"{_PKG}._print_pace_scenario"),
patch(f"{_PKG}._print_scenario"),
):
cmd_stats(self._config(), state)
assert any("Finished games" in s and "1" in s for s in echoed)
def test_detail_data_complete_message_shown(self) -> None:
"""'Detail data: ...' shown when all qualifying games have rush hours."""
state = State()
echoed = self._run_cmd_stats(state)
# entry has rush_hours=15.0 > 0, so missing_rush_final == 0 and total_q == 1
assert any("Detail data" in s for s in echoed)
def test_note_missing_rush_shown_when_rush_absent(self) -> None:
"""'Note: X games still missing...' shown when rush_hours <= 0 after fetch."""
state = State()
snapshot = [self._snapshot_game()]
game = GameInfo.from_snapshot(snapshot[0])
entry = _GameTimes(
game=game, worst_hours=20.0, rush_hours=-1.0, leisure_100h=-1.0
)
echoed: list[str] = []
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(
f"{_PKG}._filter_qualifying_games",
return_value=([entry], 0, 0, 0),
),
patch(f"{_PKG}._ensure_rush_data", return_value=False),
patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
patch(f"{_PKG}._print_pace_scenario"),
patch(f"{_PKG}._print_scenario"),
patch(f"{_PKG}._print_worst_example"),
):
cmd_stats(self._config(), state)
assert any("still missing" in s for s in echoed)
def test_no_detail_message_when_no_qualifying_games(self) -> None:
"""Neither 'Note' nor 'Detail data' shown when qualified list is empty."""
state = State()
snapshot = [self._snapshot_game()]
echoed: list[str] = []
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(
f"{_PKG}._filter_qualifying_games",
return_value=([], 0, 0, 0),
),
patch(f"{_PKG}._ensure_rush_data", return_value=False),
patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])),
patch(f"{_PKG}._print_pace_scenario"),
patch(f"{_PKG}._print_scenario"),
patch(f"{_PKG}._print_worst_example"),
):
cmd_stats(self._config(), state)
assert not any("Detail data" in s for s in echoed)
assert not any("still missing" in s for s in echoed)
def test_refilter_called_when_ensure_rush_data_returns_true(self) -> None:
"""_filter_qualifying_games called twice when _ensure_rush_data returns True."""
state = State()
snapshot = [self._snapshot_game()]
game = GameInfo.from_snapshot(snapshot[0])
entry = _GameTimes(
game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0
)
filter_calls: list[int] = []
def count_filter(
_games: object, _state: object
) -> tuple[list[_GameTimes], int, int, int]:
filter_calls.append(1)
return [entry], 0, 0, 0
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(f"{_PKG}._filter_qualifying_games", side_effect=count_filter),
patch(f"{_PKG}._ensure_rush_data", return_value=True),
patch(f"{_PKG}._echo"),
patch(f"{_PKG}._print_pace_scenario"),
patch(f"{_PKG}._print_scenario"),
patch(f"{_PKG}._print_worst_example"),
):
cmd_stats(self._config(), state)
assert len(filter_calls) == 2
def test_games_done_passed_to_pace_from_snapshot_complete(self) -> None:
"""_print_pace_scenario receives is_complete count from snapshot."""
state = State()
# Snapshot: 1 complete game (unlocked=total=10), 1 incomplete.
snapshot_complete = {
**self._snapshot_game(app_id=2),
"unlocked_achievements": 10,
}
snapshot = [self._snapshot_game(app_id=1), snapshot_complete]
game = GameInfo.from_snapshot(self._snapshot_game())
entry = _GameTimes(
game=game, worst_hours=20.0, rush_hours=15.0, leisure_100h=25.0
)
captured: dict[str, int] = {}
def capture_pace(_state: object, _remaining: object, games_done: int) -> None:
captured["games_done"] = games_done
with (
patch(f"{_PKG}.load_snapshot", return_value=snapshot),
patch(
f"{_PKG}._filter_qualifying_games",
return_value=([entry], 0, 0, 0),
),
patch(f"{_PKG}._echo"),
patch(f"{_PKG}._print_pace_scenario", side_effect=capture_pace),
patch(f"{_PKG}._print_scenario"),
patch(f"{_PKG}._print_worst_example"),
):
cmd_stats(self._config(), state)
assert captured["games_done"] == 1
class TestEnsureRushData:
"""Tests for _ensure_rush_data."""
def _entry(self, rush: float) -> _GameTimes:
return _GameTimes(
game=_game(), worst_hours=10.0, rush_hours=rush, leisure_100h=5.0
)
def test_empty_qualified_returns_false(self) -> None:
with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch:
result = _ensure_rush_data([])
assert result is False
mock_fetch.assert_not_called()
def test_all_have_rush_returns_false(self) -> None:
entries = [self._entry(10.0), self._entry(5.0)]
with patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch:
result = _ensure_rush_data(entries)
assert result is False
mock_fetch.assert_not_called()
def test_missing_rush_fetches_and_returns_true(self) -> None:
entries = [self._entry(-1.0)]
with (
patch(f"{_PKG}.fetch_hltb_detail_missing") as mock_fetch,
patch(f"{_PKG}._echo"),
):
result = _ensure_rush_data(entries)
assert result is True
mock_fetch.assert_called_once()
class TestPrintWorstExample:
"""Tests for _print_worst_example."""
def test_empty_list_does_nothing(self) -> None:
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([])
assert echoed == []
def test_example_with_rush_and_leisure(self) -> None:
entry = _GameTimes(
game=_game(name="Portal"),
worst_hours=15.0,
rush_hours=5.0,
leisure_100h=20.0,
)
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([entry])
assert any("Portal" in s for s in echoed)
assert any("Rush" in s for s in echoed)
assert any("Leisure" in s for s in echoed)
def test_example_without_rush(self) -> None:
entry = _GameTimes(
game=_game(name="X"), worst_hours=15.0, rush_hours=-1.0, leisure_100h=20.0
)
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([entry])
assert not any("Rush" in s for s in echoed)
assert any("Leisure" in s for s in echoed)
def test_example_without_leisure(self) -> None:
entry = _GameTimes(
game=_game(name="Y"), worst_hours=15.0, rush_hours=5.0, leisure_100h=-1.0
)
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([entry])
assert any("Rush" in s for s in echoed)
assert not any("Leisure" in s for s in echoed)
def test_hltb_search_url_shown_when_no_game_id(self) -> None:
"""Falls back to search URL when hltb_game_id is 0."""
entry = _GameTimes(
game=_game(name="Portal 2"),
worst_hours=15.0,
rush_hours=-1.0,
leisure_100h=-1.0,
)
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([entry])
assert any("howlongtobeat.com" in s and "Portal+2" in s for s in echoed)
def test_hltb_direct_link_shown_when_game_id_known(self) -> None:
"""Direct HLTB game link shown when hltb_game_id is populated."""
entry = _GameTimes(
game=_game(name="Devil May Cry 5"),
worst_hours=186.0,
rush_hours=50.0,
leisure_100h=186.0,
hltb_game_id=57514,
)
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([entry])
assert any("howlongtobeat.com/game/57514" in s for s in echoed)
assert not any("?q=" in s for s in echoed)
def test_entries_with_zero_worst_hours_excluded_from_examples(self) -> None:
"""Games with worst_hours <= 0 are not selected as the example."""
bad = _GameTimes(
game=_game(name="Skip"), worst_hours=0.0, rush_hours=-1.0, leisure_100h=-1.0
)
good = _GameTimes(
game=_game(name="Pick"),
worst_hours=10.0,
rush_hours=-1.0,
leisure_100h=-1.0,
)
echoed: list[str] = []
with patch(f"{_PKG}._echo", side_effect=lambda *a, **_: echoed.append(a[0])):
_print_worst_example([bad, good])
assert any("Pick" in s for s in echoed)
assert not any("Skip" in s for s in echoed)

View File

@ -1,333 +0,0 @@
"""Tests for steam_api module."""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
import pytest
import requests
from python_pkg.steam_backlog_enforcer.steam_api import (
AchievementInfo,
GameInfo,
SteamAPIClient,
SteamAPIError,
)
class TestAchievementInfo:
"""Tests for AchievementInfo."""
def test_create(self) -> None:
a = AchievementInfo(
api_name="ACH_1", display_name="First", achieved=True, unlock_time=1000
)
assert a.api_name == "ACH_1"
assert a.achieved is True
class TestGameInfo:
"""Tests for GameInfo."""
def test_completion_pct_zero_achievements(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
assert g.completion_pct == 100.0
def test_completion_pct_partial(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=10,
unlocked_achievements=5,
playtime_minutes=0,
)
assert g.completion_pct == 50.0
def test_is_complete_true(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=5,
unlocked_achievements=5,
playtime_minutes=0,
)
assert g.is_complete is True
def test_is_complete_false(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=5,
unlocked_achievements=3,
playtime_minutes=0,
)
assert g.is_complete is False
def test_is_complete_zero(self) -> None:
g = GameInfo(
app_id=1,
name="G",
total_achievements=0,
unlocked_achievements=0,
playtime_minutes=0,
)
assert g.is_complete is False
def test_to_snapshot(self) -> None:
ach = AchievementInfo(
api_name="A1", display_name="Ach1", achieved=True, unlock_time=99
)
g = GameInfo(
app_id=1,
name="G",
total_achievements=1,
unlocked_achievements=1,
playtime_minutes=60,
achievements=[ach],
completionist_hours=5.0,
)
snap = g.to_snapshot()
assert snap["app_id"] == 1
assert snap["achievements"][0]["api_name"] == "A1"
assert snap["completionist_hours"] == 5.0
def test_from_snapshot(self) -> None:
data: dict[str, Any] = {
"app_id": 2,
"name": "G2",
"total_achievements": 3,
"unlocked_achievements": 1,
"playtime_minutes": 120,
"completionist_hours": 10.0,
"achievements": [
{
"api_name": "A1",
"display_name": "First",
"achieved": False,
"unlock_time": 0,
},
],
}
g = GameInfo.from_snapshot(data)
assert g.app_id == 2
assert g.completionist_hours == 10.0
assert len(g.achievements) == 1
def test_from_snapshot_defaults(self) -> None:
data: dict[str, Any] = {
"app_id": 3,
"name": "G3",
"total_achievements": 0,
"unlocked_achievements": 0,
}
g = GameInfo.from_snapshot(data)
assert g.playtime_minutes == 0
assert g.completionist_hours == -1
assert g.achievements == []
def test_from_snapshot_achievement_defaults(self) -> None:
data: dict[str, Any] = {
"app_id": 4,
"name": "G4",
"total_achievements": 1,
"unlocked_achievements": 0,
"achievements": [{"api_name": "X", "achieved": False}],
}
g = GameInfo.from_snapshot(data)
assert g.achievements[0].display_name == "X"
assert g.achievements[0].unlock_time == 0
class TestSteamAPIClient:
"""Tests for SteamAPIClient."""
def test_init(self) -> None:
client = SteamAPIClient("key", "id")
assert client.api_key == "key"
assert client.steam_id == "id"
def test_rate_limit(self) -> None:
client = SteamAPIClient("key", "id")
# Should not block on first call
client._rate_limit()
def test_rate_limit_throttle(self) -> None:
client = SteamAPIClient("key", "id")
# Fill up the rate limit window
client._request_times = [__import__("time").time()] * client._max_rps
with patch(
"python_pkg.steam_backlog_enforcer.steam_api.time.sleep",
) as mock_sleep:
# Next call should trigger sleep then succeed
client._rate_limit()
mock_sleep.assert_called()
def test_get_success(self) -> None:
client = SteamAPIClient("key", "id")
mock_resp = MagicMock()
mock_resp.json.return_value = {"data": "value"}
client.session.get = MagicMock(return_value=mock_resp)
result = client._get("https://example.com/api")
assert result == {"data": "value"}
def test_get_with_params(self) -> None:
client = SteamAPIClient("key", "id")
mock_resp = MagicMock()
mock_resp.json.return_value = {"data": "value"}
client.session.get = MagicMock(return_value=mock_resp)
result = client._get("https://example.com/api", params={"foo": "bar"})
assert result == {"data": "value"}
# Verify key was added to existing params dict
call_kwargs = client.session.get.call_args
assert call_kwargs[1]["params"]["foo"] == "bar"
assert call_kwargs[1]["params"]["key"] == "key"
def test_get_failure(self) -> None:
client = SteamAPIClient("key", "id")
client.session.get = MagicMock(side_effect=requests.RequestException("fail"))
with pytest.raises(SteamAPIError):
client._get("https://example.com/api")
def test_get_owned_games(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"_get",
return_value={"response": {"games": [{"appid": 440}]}},
):
games = client.get_owned_games()
assert len(games) == 1
assert games[0]["appid"] == 440
def test_get_owned_games_empty(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "_get", return_value={"response": {}}):
games = client.get_owned_games()
assert games == []
def test_get_achievement_details(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"_get",
return_value={
"playerstats": {
"success": True,
"achievements": [
{
"apiname": "ACH_1",
"name": "First",
"achieved": 1,
"unlocktime": 1000,
},
],
},
},
):
result = client.get_achievement_details(440)
assert len(result) == 1
assert result[0].achieved is True
def test_get_achievement_details_failure(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "_get", side_effect=SteamAPIError("fail")):
result = client.get_achievement_details(440)
assert result == []
def test_get_achievement_details_not_success(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(
client,
"_get",
return_value={"playerstats": {"success": False}},
):
result = client.get_achievement_details(440)
assert result == []
def test_fetch_one_game(self) -> None:
client = SteamAPIClient("key", "id")
ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100)
with patch.object(client, "get_achievement_details", return_value=[ach]):
result = client._fetch_one_game(
{"appid": 440, "name": "TF2", "playtime_forever": 60},
)
assert result is not None
assert result.app_id == 440
def test_fetch_one_game_no_achievements(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "get_achievement_details", return_value=[]):
result = client._fetch_one_game({"appid": 440})
assert result is None
def test_build_game_list(self) -> None:
client = SteamAPIClient("key", "id")
ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100)
with (
patch.object(
client,
"get_owned_games",
return_value=[{"appid": 440, "name": "TF2", "playtime_forever": 60}],
),
patch.object(client, "get_achievement_details", return_value=[ach]),
):
progress_calls: list[tuple[int, int]] = []
def progress(c: int, t: int) -> None:
progress_calls.append((c, t))
games = client.build_game_list(progress_callback=progress)
assert len(games) == 1
assert len(progress_calls) > 0
def test_build_game_list_no_achievements_excluded(self) -> None:
"""Games without achievements are excluded from results."""
client = SteamAPIClient("key", "id")
with (
patch.object(
client,
"get_owned_games",
return_value=[{"appid": 440, "name": "TF2"}],
),
patch.object(client, "get_achievement_details", return_value=[]),
):
games = client.build_game_list()
assert games == []
def test_build_game_list_exception_in_future(self) -> None:
client = SteamAPIClient("key", "id")
with (
patch.object(
client,
"get_owned_games",
return_value=[{"appid": 440, "name": "TF2"}],
),
patch.object(
client,
"get_achievement_details",
side_effect=SteamAPIError("err"),
),
):
games = client.build_game_list()
assert games == []
def test_refresh_single_game(self) -> None:
client = SteamAPIClient("key", "id")
ach = AchievementInfo("A1", "Ach1", achieved=True, unlock_time=100)
with patch.object(client, "get_achievement_details", return_value=[ach]):
result = client.refresh_single_game(440, "TF2", 60)
assert result is not None
assert result.unlocked_achievements == 1
def test_refresh_single_game_no_achievements(self) -> None:
client = SteamAPIClient("key", "id")
with patch.object(client, "get_achievement_details", return_value=[]):
result = client.refresh_single_game(440, "TF2")
assert result is None

View File

@ -1,470 +0,0 @@
"""Tests for store_blocker module."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.store_blocker import (
_block_store_iptables,
_block_via_hosts_install,
_is_iptables_blocked,
_unblock_store_iptables,
block_store,
is_store_blocked,
unblock_store,
)
if TYPE_CHECKING:
from pathlib import Path
class TestIsStoreBlocked:
"""Tests for is_store_blocked."""
def test_blocked_in_hosts(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("0.0.0.0 store.steampowered.com\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
):
assert is_store_blocked() is True
def test_commented_in_hosts(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("# 0.0.0.0 store.steampowered.com\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=False,
),
):
assert is_store_blocked() is False
def test_not_in_hosts_iptables_blocked(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=True,
),
):
assert is_store_blocked() is True
def test_hosts_read_error(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "nonexistent"
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=False,
),
):
assert is_store_blocked() is False
def test_wrong_redirect_ip(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 store.steampowered.com\n", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_FILE",
hosts_file,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._is_iptables_blocked",
return_value=False,
),
):
assert is_store_blocked() is False
class TestBlockStore:
"""Tests for block_store."""
def test_already_blocked(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=True,
):
assert block_store() is True
def test_reblock_succeeds(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, True],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert block_store() is True
def test_fallback_to_install_script(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, False],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert block_store() is True
def test_all_fail(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, False],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
return_value=False,
),
):
assert block_store() is False
def test_iptables_only_succeeds(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
side_effect=[False, False],
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._reblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_via_hosts_install",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._block_store_iptables",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert block_store() is True
class TestBlockViaHostsInstall:
"""Tests for _block_via_hosts_install."""
def test_already_blocked(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=True,
):
assert _block_via_hosts_install() is True
def test_script_missing(self, tmp_path: Path) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
tmp_path / "nonexistent.sh",
),
):
assert _block_via_hosts_install() is False
def test_script_succeeds(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
mock_result = MagicMock(returncode=0)
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
):
assert _block_via_hosts_install() is True
def test_script_fails(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
mock_result = MagicMock(returncode=1, stderr="error", stdout="")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
):
assert _block_via_hosts_install() is False
def test_script_fails_no_stderr(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
mock_result = MagicMock(returncode=1, stderr="", stdout="out")
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
):
assert _block_via_hosts_install() is False
def test_script_os_error(self, tmp_path: Path) -> None:
script = tmp_path / "install.sh"
script.touch()
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.is_store_blocked",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.HOSTS_INSTALL_SCRIPT",
script,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
),
):
assert _block_via_hosts_install() is False
class TestIsIptablesBlocked:
"""Tests for _is_iptables_blocked."""
def test_blocked(self) -> None:
mock_result = MagicMock(returncode=0, stdout="DROP blah")
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
):
assert _is_iptables_blocked() is True
def test_not_blocked_no_drop(self) -> None:
mock_result = MagicMock(returncode=0, stdout="ACCEPT")
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
):
assert _is_iptables_blocked() is False
def test_not_blocked_error(self) -> None:
mock_result = MagicMock(returncode=1, stdout="")
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
):
assert _is_iptables_blocked() is False
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
):
assert _is_iptables_blocked() is False
class TestBlockStoreIptables:
"""Tests for _block_store_iptables."""
def test_success(self) -> None:
mock_result = MagicMock(returncode=0)
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
return_value=[
(None, None, None, None, ("1.2.3.4", 443)),
],
),
):
assert _block_store_iptables() is True
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
):
assert _block_store_iptables() is False
def test_dns_resolution_fails(self) -> None:
import socket
mock_result = MagicMock(returncode=0)
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
return_value=mock_result,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
side_effect=socket.gaierror,
),
):
# Should succeed even if DNS fails (just no IPs to block)
assert _block_store_iptables() is True
def test_chain_hook_needed(self) -> None:
results = [
MagicMock(returncode=0), # -N
MagicMock(returncode=0), # -F
MagicMock(returncode=1), # -C OUTPUT (not hooked)
MagicMock(returncode=0), # -I OUTPUT
]
call_count = 0
def side_effect(*_args: object, **_kwargs: object) -> MagicMock:
nonlocal call_count
idx = min(call_count, len(results) - 1)
call_count += 1
return results[idx]
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=side_effect,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.socket.getaddrinfo",
side_effect=__import__("socket").gaierror,
),
):
assert _block_store_iptables() is True
class TestUnblockStore:
"""Tests for unblock_store."""
def test_both_succeed(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert unblock_store() is True
def test_iptables_fails(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts",
return_value=True,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert unblock_store() is True
def test_both_fail(self) -> None:
with (
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_store_iptables",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker._unblock_hosts",
return_value=False,
),
patch(
"python_pkg.steam_backlog_enforcer.store_blocker.flush_dns_cache",
),
):
assert unblock_store() is False
class TestUnblockStoreIptables:
"""Tests for _unblock_store_iptables."""
def test_success(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
):
assert _unblock_store_iptables() is True
def test_os_error(self) -> None:
with patch(
"python_pkg.steam_backlog_enforcer.store_blocker.subprocess.run",
side_effect=OSError,
):
assert _unblock_store_iptables() is False

View File

@ -1,200 +0,0 @@
"""Tests for store_blocker module — part 2 (missing coverage)."""
from __future__ import annotations
from typing import TYPE_CHECKING
from unittest.mock import MagicMock, patch
from python_pkg.steam_backlog_enforcer.store_blocker import (
_disable_hosts_protection,
_enable_hosts_protection,
_reblock_hosts,
_sudo_write_hosts,
_unblock_hosts,
flush_dns_cache,
)
if TYPE_CHECKING:
from pathlib import Path
PKG = "python_pkg.steam_backlog_enforcer.store_blocker"
class TestSudoWriteHosts:
"""Tests for _sudo_write_hosts."""
def test_writes_content(self) -> None:
with patch(f"{PKG}.subprocess.run") as mock_run:
_sudo_write_hosts("127.0.0.1 localhost\n")
mock_run.assert_called_once()
assert mock_run.call_args.kwargs["input"] == b"127.0.0.1 localhost\n"
class TestDisableHostsProtection:
"""Tests for _disable_hosts_protection."""
def test_stops_services_unmounts_chattr(self) -> None:
findmnt_found = MagicMock(returncode=0)
def run_side_effect(
cmd: list[str],
**_kwargs: object,
) -> MagicMock:
if any("findmnt" in str(c) for c in cmd):
return findmnt_found
return MagicMock(returncode=0)
with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect):
_disable_hosts_protection()
def test_no_bind_mount(self) -> None:
findmnt_missing = MagicMock(returncode=1)
def run_side_effect(
cmd: list[str],
**_kwargs: object,
) -> MagicMock:
if any("findmnt" in str(c) for c in cmd):
return findmnt_missing
return MagicMock(returncode=0)
with patch(f"{PKG}.subprocess.run", side_effect=run_side_effect):
_disable_hosts_protection()
class TestEnableHostsProtection:
"""Tests for _enable_hosts_protection."""
def test_with_locked_copy(self, tmp_path: Path) -> None:
locked_copy = tmp_path / "locked-hosts"
locked_copy.touch()
with (
patch(f"{PKG}.subprocess.run"),
patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy),
):
_enable_hosts_protection()
def test_without_locked_copy(self, tmp_path: Path) -> None:
locked_copy = tmp_path / "nonexistent"
with (
patch(f"{PKG}.subprocess.run"),
patch(f"{PKG}._LOCKED_HOSTS_COPY", locked_copy),
):
_enable_hosts_protection()
class TestUnblockHosts:
"""Tests for _unblock_hosts."""
def test_not_blocked(self) -> None:
with patch(f"{PKG}.is_store_blocked", return_value=False):
result = _unblock_hosts()
assert result is True
def test_comments_out_entries(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text(
"127.0.0.1 localhost\n"
"0.0.0.0 store.steampowered.com\n"
"0.0.0.0 checkout.steampowered.com\n",
encoding="utf-8",
)
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _unblock_hosts()
assert result is True
written = mock_write.call_args[0][0]
assert "# 0.0.0.0 store.steampowered.com" in written
def test_no_change_needed(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text(
"# 0.0.0.0 store.steampowered.com\n",
encoding="utf-8",
)
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _unblock_hosts()
assert result is True
mock_write.assert_not_called()
def test_os_error(self) -> None:
with (
patch(f"{PKG}.is_store_blocked", return_value=True),
patch(f"{PKG}._disable_hosts_protection", side_effect=OSError),
):
result = _unblock_hosts()
assert result is False
class TestReblockHosts:
"""Tests for _reblock_hosts."""
def test_uncomments_entries(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text(
"127.0.0.1 localhost\n"
"# 0.0.0.0 store.steampowered.com\n"
"# 0.0.0.0 checkout.steampowered.com\n",
encoding="utf-8",
)
with (
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _reblock_hosts()
assert result is True
written = mock_write.call_args[0][0]
# Should have uncommented lines
assert "0.0.0.0 store.steampowered.com" in written
assert "# 0.0.0.0 store.steampowered.com" not in written
def test_no_change(self, tmp_path: Path) -> None:
hosts_file = tmp_path / "hosts"
hosts_file.write_text("127.0.0.1 localhost\n", encoding="utf-8")
with (
patch(f"{PKG}.HOSTS_FILE", hosts_file),
patch(f"{PKG}._disable_hosts_protection"),
patch(f"{PKG}._enable_hosts_protection"),
patch(f"{PKG}._sudo_write_hosts") as mock_write,
):
result = _reblock_hosts()
assert result is True
mock_write.assert_not_called()
def test_os_error(self) -> None:
with patch(f"{PKG}._disable_hosts_protection", side_effect=OSError):
result = _reblock_hosts()
assert result is False
class TestFlushDnsCache:
"""Tests for flush_dns_cache."""
def test_runs_commands(self) -> None:
with patch(f"{PKG}.subprocess.run") as mock_run:
flush_dns_cache()
assert mock_run.call_count == 3
def test_file_not_found_suppressed(self) -> None:
with patch(
f"{PKG}.subprocess.run",
side_effect=FileNotFoundError,
):
flush_dns_cache()
def test_os_error_suppressed(self) -> None:
with patch(f"{PKG}.subprocess.run", side_effect=OSError):
flush_dns_cache()

View File

@ -1,496 +0,0 @@
"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr."""
from __future__ import annotations
import time
from typing import TYPE_CHECKING
from unittest.mock import patch
import pytest
from python_pkg.steam_backlog_enforcer._whitelist import (
WHITELIST_COOLDOWN_SECONDS,
_append_audit_log,
_load_approved,
_load_pending,
_save_approved,
_save_pending,
_shannon_entropy,
_try_set_immutable,
add_pending_exception,
get_approved_exception_ids,
list_pending_exceptions,
lock_enforcement_files,
promote_pending_exceptions,
unlock_for_write,
validate_reason,
)
if TYPE_CHECKING:
from pathlib import Path
# ──────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────
_VALID_REASON = "I need this game installed for a work presentation this week."
# ──────────────────────────────────────────────────────────────
# Shannon entropy
# ──────────────────────────────────────────────────────────────
class TestShannonEntropy:
def test_empty_string(self) -> None:
assert _shannon_entropy("") == 0.0
def test_all_whitespace(self) -> None:
assert _shannon_entropy(" ") == 0.0
def test_single_char(self) -> None:
# one unique char → entropy = 0
assert _shannon_entropy("aaaa") == 0.0
def test_high_entropy(self) -> None:
# natural English sentence has decent entropy
assert _shannon_entropy("The quick brown fox jumps") > 3.0
# ──────────────────────────────────────────────────────────────
# validate_reason
# ──────────────────────────────────────────────────────────────
class TestValidateReason:
def test_valid_reason_returns_none(self) -> None:
assert validate_reason(_VALID_REASON) is None
def test_too_short(self) -> None:
err = validate_reason("short")
assert err is not None
assert "too short" in err
def test_too_few_words(self) -> None:
# 25+ chars but only 4 words
err = validate_reason("word1 word2 word3 word4xxx")
assert err is not None
assert "words" in err
def test_low_entropy_rejected(self) -> None:
# repeating 'ab' has low entropy
err = validate_reason("ababababababababababababababab")
assert err is not None
# could be caught by entropy or alternating-pattern check
assert err is not None
def test_char_run_rejected(self) -> None:
err = validate_reason("I neeeeed this game to play it")
assert err is not None
assert "repeated characters" in err
def test_alternating_pattern_rejected(self) -> None:
# "ababababab..." repeated many times
err = validate_reason("abababababababababababababababababababababab")
assert err is not None
assert "repetitive" in err or "random" in err or err is not None
# ──────────────────────────────────────────────────────────────
# chattr helpers
# ──────────────────────────────────────────────────────────────
class TestTrySetImmutable:
def test_file_does_not_exist(self, tmp_path: Path) -> None:
# Should silently do nothing when the file doesn't exist
_try_set_immutable(tmp_path / "nonexistent.txt", immutable=True)
def test_chattr_not_available(self, tmp_path: Path) -> None:
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
with patch("shutil.which", return_value=None):
_try_set_immutable(target, immutable=True) # no-op, no crash
def test_chattr_called_set(self, tmp_path: Path) -> None:
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
fake_chattr = tmp_path / "chattr"
with (
patch("shutil.which", return_value=str(fake_chattr)),
patch("subprocess.run") as mock_run,
):
_try_set_immutable(target, immutable=True)
mock_run.assert_called_once()
args = mock_run.call_args[0][0]
assert "+i" in args
def test_chattr_called_clear(self, tmp_path: Path) -> None:
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
fake_chattr = tmp_path / "chattr"
with (
patch("shutil.which", return_value=str(fake_chattr)),
patch("subprocess.run") as mock_run,
):
_try_set_immutable(target, immutable=False)
args = mock_run.call_args[0][0]
assert "-i" in args
def test_oserror_swallowed(self, tmp_path: Path) -> None:
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
with (
patch("shutil.which", return_value="/usr/bin/chattr"),
patch("subprocess.run", side_effect=OSError("no permission")),
):
_try_set_immutable(target, immutable=True) # must not raise
def test_timeout_swallowed(self, tmp_path: Path) -> None:
import subprocess
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
with (
patch("shutil.which", return_value="/usr/bin/chattr"),
patch(
"subprocess.run",
side_effect=subprocess.TimeoutExpired(cmd="chattr", timeout=5),
),
):
_try_set_immutable(target, immutable=True) # must not raise
class TestLockAndUnlock:
def test_lock_enforcement_files(self, tmp_path: Path) -> None:
cfg = tmp_path / "config.json"
cfg.write_text("{}", encoding="utf-8")
approved = tmp_path / "approved.json"
approved.write_text("[]", encoding="utf-8")
with (
patch(
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
approved,
),
patch("shutil.which", return_value="/usr/bin/chattr"),
patch("subprocess.run") as mock_run,
):
lock_enforcement_files(cfg)
assert mock_run.call_count == 2
all_calls = [c[0][0] for c in mock_run.call_args_list]
assert all("+i" in c for c in all_calls)
def test_unlock_for_write(self, tmp_path: Path) -> None:
target = tmp_path / "file.txt"
target.write_text("data", encoding="utf-8")
with (
patch("shutil.which", return_value="/usr/bin/chattr"),
patch("subprocess.run") as mock_run,
):
unlock_for_write(target)
args = mock_run.call_args[0][0]
assert "-i" in args
# ──────────────────────────────────────────────────────────────
# Persistence helpers (_load_pending, _save_pending, etc.)
# ──────────────────────────────────────────────────────────────
class TestPersistence:
def test_load_pending_missing_file(self) -> None:
assert _load_pending() == []
def test_load_pending_corrupt_file(self, tmp_path: Path) -> None:
bad = tmp_path / "pending.json"
bad.write_text("not json{{", encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE",
bad,
):
assert _load_pending() == []
def test_load_pending_non_list(self, tmp_path: Path) -> None:
bad = tmp_path / "pending.json"
bad.write_text('{"key": "value"}', encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE",
bad,
):
assert _load_pending() == []
def test_save_and_load_pending_roundtrip(self) -> None:
entries: list[dict[str, object]] = [
{"app_id": 440, "reason": "test", "requested_at": 12345.0}
]
_save_pending(entries)
assert _load_pending() == entries
def test_load_approved_missing_file(self) -> None:
assert _load_approved() == []
def test_load_approved_corrupt_file(self, tmp_path: Path) -> None:
bad = tmp_path / "approved.json"
bad.write_text("{{broken", encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
bad,
):
assert _load_approved() == []
def test_load_approved_non_list(self, tmp_path: Path) -> None:
bad = tmp_path / "approved.json"
bad.write_text('"just a string"', encoding="utf-8")
with patch(
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
bad,
):
assert _load_approved() == []
def test_save_approved_roundtrip(self) -> None:
entries: list[dict[str, object]] = [
{"app_id": 730, "reason": "cs2", "approved_at": 99999.0}
]
with (
patch("shutil.which", return_value=None), # skip chattr
):
_save_approved(entries)
assert _load_approved() == entries
# ──────────────────────────────────────────────────────────────
# Audit log
# ──────────────────────────────────────────────────────────────
class TestAppendAuditLog:
def test_audit_log_written(self, tmp_path: Path) -> None:
log_file = tmp_path / "audit.log"
with patch(
"python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG",
log_file,
):
_append_audit_log(440, "some reason", "REQUESTED")
content = log_file.read_text(encoding="utf-8")
assert "REQUESTED" in content
assert "app_id=440" in content
assert "some reason" in content
def test_audit_log_appends(self, tmp_path: Path) -> None:
log_file = tmp_path / "audit.log"
with patch(
"python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG",
log_file,
):
_append_audit_log(440, "first", "REQUESTED")
_append_audit_log(730, "second", "APPROVED")
content = log_file.read_text(encoding="utf-8")
assert "app_id=440" in content
assert "app_id=730" in content
# ──────────────────────────────────────────────────────────────
# add_pending_exception
# ──────────────────────────────────────────────────────────────
class TestAddPendingException:
def test_add_new_exception(self) -> None:
with patch("shutil.which", return_value=None):
msg = add_pending_exception(440, _VALID_REASON)
assert "440" in msg
assert "24h" in msg or "hours" in msg or "active" in msg.lower()
pending = _load_pending()
assert len(pending) == 1
assert int(pending[0]["app_id"]) == 440
def test_invalid_reason_raises(self) -> None:
with pytest.raises(
ValueError, match=r"short|words|entropy|repeated|repetitive"
):
add_pending_exception(440, "too short")
def test_already_approved_raises(self) -> None:
approved: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0}
]
_save_approved(approved)
with (
patch("shutil.which", return_value=None),
pytest.raises(ValueError, match="already in the approved"),
):
add_pending_exception(440, _VALID_REASON)
def test_already_pending_cooldown_remaining(self) -> None:
existing: list[dict[str, object]] = [
{
"app_id": 440,
"reason": _VALID_REASON,
"requested_at": time.time(), # just now → full cooldown remaining
}
]
_save_pending(existing)
with patch("shutil.which", return_value=None):
msg = add_pending_exception(440, _VALID_REASON)
assert "already pending" in msg
def test_already_pending_cooldown_elapsed_promotes(self) -> None:
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
existing: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past}
]
_save_pending(existing)
with patch("shutil.which", return_value=None):
msg = add_pending_exception(440, _VALID_REASON)
# The elapsed entry is broken out of the pending-check loop via break,
# then a new entry is appended → still gets the "Will become active" msg.
assert "440" in msg
# ──────────────────────────────────────────────────────────────
# promote_pending_exceptions
# ──────────────────────────────────────────────────────────────
class TestPromotePendingExceptions:
def test_no_entries(self) -> None:
result = promote_pending_exceptions()
assert result == []
def test_cooldown_not_elapsed(self) -> None:
entries: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "requested_at": time.time()}
]
_save_pending(entries)
with patch("shutil.which", return_value=None):
result = promote_pending_exceptions()
assert result == []
# Still pending
assert len(_load_pending()) == 1
def test_cooldown_elapsed_promotes(self) -> None:
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
entries: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past}
]
_save_pending(entries)
with patch("shutil.which", return_value=None):
result = promote_pending_exceptions()
assert 440 in result
assert _load_pending() == []
approved_ids = get_approved_exception_ids()
assert 440 in approved_ids
def test_already_in_approved_not_duplicated(self) -> None:
"""If somehow already approved, skip duplicating it."""
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
entries: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past}
]
_save_pending(entries)
existing_approved: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0}
]
_save_approved(existing_approved)
with patch("shutil.which", return_value=None):
result = promote_pending_exceptions()
# Not added again
assert 440 not in result
approved = _load_approved()
assert sum(1 for e in approved if int(e["app_id"]) == 440) == 1
def test_pending_list_saved_when_entries_removed(self) -> None:
"""_save_pending is called when the pending list shrinks."""
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
future = time.time()
entries: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past},
{"app_id": 730, "reason": _VALID_REASON, "requested_at": future},
]
_save_pending(entries)
with patch("shutil.which", return_value=None):
result = promote_pending_exceptions()
assert 440 in result
remaining = _load_pending()
assert len(remaining) == 1
assert int(remaining[0]["app_id"]) == 730
# ──────────────────────────────────────────────────────────────
# get_approved_exception_ids
# ──────────────────────────────────────────────────────────────
class TestGetApprovedExceptionIds:
def test_empty(self) -> None:
result = get_approved_exception_ids()
assert result == frozenset()
def test_populated(self) -> None:
approved: list[dict[str, object]] = [
{"app_id": 440, "reason": "r", "approved_at": 0.0},
{"app_id": 730, "reason": "r", "approved_at": 0.0},
]
_save_approved(approved)
with patch("shutil.which", return_value=None):
pass
result = get_approved_exception_ids()
assert result == frozenset({440, 730})
# ──────────────────────────────────────────────────────────────
# list_pending_exceptions
# ──────────────────────────────────────────────────────────────
class TestListPendingExceptions:
def test_returns_copy(self) -> None:
"""Mutating the result must not affect stored state."""
entries: list[dict[str, object]] = [
{"app_id": 440, "reason": "r", "requested_at": 1.0}
]
_save_pending(entries)
result = list_pending_exceptions()
result.clear()
# Still present on next load
assert len(_load_pending()) == 1
def test_empty_when_no_pending(self) -> None:
assert list_pending_exceptions() == []
# ──────────────────────────────────────────────────────────────
# Extra coverage for validate_reason branches 94 & 106
# ──────────────────────────────────────────────────────────────
class TestValidateReasonExtraBranches:
"""Cover lines 94 and 106 that need multi-word, long, low-entropy inputs."""
def test_low_entropy_multi_word(self) -> None:
# 8 words, 31+ chars, entropy ≈ 2.0 (< 3.0), no char run, no alt pattern
err = validate_reason("the the the the the the the the")
assert err is not None
assert "entropy" in err
def test_alternating_pattern_multi_word(self) -> None:
# "abababab" satisfies (..)(\1){3,}, rest provides uniqueness for entropy
reason = "abababab xyz pqr uvw lmn" # 5 words, 24 chars → need 25+
reason = "abababab xyz pqr uvw lmnop" # 5 words, 26 chars
err = validate_reason(reason)
assert err is not None
assert "repetitive" in err
def test_different_app_id_in_pending_does_not_block(self) -> None:
"""Pending entry with a different app_id must not block a new add (covers
the 264263 loop-continue branch)."""
other: list[dict[str, object]] = [
{"app_id": 440, "reason": _VALID_REASON, "requested_at": 1.0}
]
_save_pending(other)
with patch("shutil.which", return_value=None):
msg = add_pending_exception(730, _VALID_REASON)
assert "730" in msg
pending = _load_pending()
assert len(pending) == 2 # original + new