mirror of
https://github.com/kuhyx/testsAndMisc.git
synced 2026-07-04 11:43:10 +02:00
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:
parent
a7cd5ca336
commit
acfb1c48a0
26
CLAUDE.md
26
CLAUDE.md
@ -3,8 +3,11 @@
|
||||
## Project Overview
|
||||
|
||||
A mixed-language monorepo containing Python packages, Bash scripts, and misc automation. Actively-developed
|
||||
components span personal productivity tools: Steam backlog management, alarm/shutdown scheduling, screen
|
||||
locking, Linux system configuration, and Android phone focus enforcement.
|
||||
components span personal productivity tools: alarm/shutdown scheduling, screen locking, Linux system
|
||||
configuration, and Android phone focus enforcement.
|
||||
|
||||
Steam backlog enforcer has been extracted to its own repo:
|
||||
[`steam-backlog-enforcer`](https://github.com/kuhyx/steam-backlog-enforcer).
|
||||
|
||||
Archived / unmaintained projects live in the sibling repository
|
||||
[`testsAndMisc-archive`](https://github.com/kuhyx/testsAndMisc-archive).
|
||||
@ -28,17 +31,6 @@ Archived / unmaintained projects live in the sibling repository
|
||||
|
||||
### Python Packages (`python_pkg/`)
|
||||
|
||||
- **steam_backlog_enforcer/** — Steam game backlog enforcer with HLTB hour tracking, game library hider,
|
||||
ProtonDB compatibility checks, and installation automation
|
||||
- `main.py` — entry point and enforce loop
|
||||
- `enforcer.py` — core enforcement logic
|
||||
- `hltb.py` / `_hltb_search.py` / `_hltb_detail.py` — HowLongToBeat API integration
|
||||
- `steam_api.py` — Steam library and install API
|
||||
- `library_hider.py` — hides non-current games from Steam
|
||||
- `protondb.py` — ProtonDB compatibility rating lookup
|
||||
- `scanning.py` — scan and select next game
|
||||
- `tests/` — pytest tests; requires real Steam state; `conftest.py` redirects all paths to tmp_path
|
||||
|
||||
- **screen_locker/** — Tkinter/systemd screen locker with workout tracking and sick-day management
|
||||
- `screen_lock.py` — main locker UI
|
||||
- `_early_bird.py` — early-bird workout check
|
||||
@ -156,7 +148,6 @@ before committing. The `ai-evidence-contract` hook will reject commits without i
|
||||
python -m pytest python_pkg/ --cov=python_pkg --cov-branch --cov-fail-under=100
|
||||
|
||||
# Run a single package
|
||||
python -m pytest python_pkg/steam_backlog_enforcer/ --cov=python_pkg.steam_backlog_enforcer --cov-branch --cov-fail-under=100
|
||||
python -m pytest python_pkg/screen_locker/ --cov=python_pkg.screen_locker --cov-branch --cov-fail-under=100
|
||||
python -m pytest python_pkg/wake_alarm/ --cov=python_pkg.wake_alarm --cov-branch --cov-fail-under=100
|
||||
python -m pytest python_pkg/brother_printer/ --cov=python_pkg.brother_printer --cov-branch --cov-fail-under=100
|
||||
@ -235,15 +226,12 @@ For every commit that touches `.py`, `.sh`, `.c`, `.go`, `.ts`, etc.:
|
||||
# Type aliases for test dicts (keeps mypy happy)
|
||||
Event = dict[str, Any]
|
||||
|
||||
# Mock external calls — never hit real APIs/filesystem/Steam
|
||||
with patch("python_pkg.steam_backlog_enforcer.main.some_func") as mock:
|
||||
# Mock external calls — never hit real APIs/filesystem
|
||||
with patch("python_pkg.screen_locker.screen_lock.some_func") as mock:
|
||||
...
|
||||
|
||||
# Use PropertyMock for property exceptions
|
||||
type(mock_obj).property_name = PropertyMock(side_effect=TypeError())
|
||||
|
||||
# steam_backlog_enforcer: conftest.py redirects ALL paths to tmp_path
|
||||
# Never assume real state.json or steamapps path is safe to touch in tests
|
||||
```
|
||||
|
||||
### Branch Coverage Tips
|
||||
|
||||
@ -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.*)"
|
||||
]
|
||||
}
|
||||
@ -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
|
||||
```
|
||||
@ -1 +0,0 @@
|
||||
"""Steam Backlog Enforcer - forces you to finish your Steam games."""
|
||||
@ -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)
|
||||
@ -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.")
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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")
|
||||
@ -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})")
|
||||
@ -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")
|
||||
@ -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())
|
||||
@ -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
|
||||
@ -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.")
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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,
|
||||
)
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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}"
|
||||
@ -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!",
|
||||
)
|
||||
@ -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
|
||||
@ -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,
|
||||
)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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"
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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 == []
|
||||
@ -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."""
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -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
|
||||
@ -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])
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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()
|
||||
@ -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"
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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)
|
||||
@ -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)
|
||||
@ -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
|
||||
@ -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
|
||||
@ -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()
|
||||
@ -1,496 +0,0 @@
|
||||
"""Tests for _whitelist.py: time-locked exceptions, reason validation, chattr."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from python_pkg.steam_backlog_enforcer._whitelist import (
|
||||
WHITELIST_COOLDOWN_SECONDS,
|
||||
_append_audit_log,
|
||||
_load_approved,
|
||||
_load_pending,
|
||||
_save_approved,
|
||||
_save_pending,
|
||||
_shannon_entropy,
|
||||
_try_set_immutable,
|
||||
add_pending_exception,
|
||||
get_approved_exception_ids,
|
||||
list_pending_exceptions,
|
||||
lock_enforcement_files,
|
||||
promote_pending_exceptions,
|
||||
unlock_for_write,
|
||||
validate_reason,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
_VALID_REASON = "I need this game installed for a work presentation this week."
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Shannon entropy
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestShannonEntropy:
|
||||
def test_empty_string(self) -> None:
|
||||
assert _shannon_entropy("") == 0.0
|
||||
|
||||
def test_all_whitespace(self) -> None:
|
||||
assert _shannon_entropy(" ") == 0.0
|
||||
|
||||
def test_single_char(self) -> None:
|
||||
# one unique char → entropy = 0
|
||||
assert _shannon_entropy("aaaa") == 0.0
|
||||
|
||||
def test_high_entropy(self) -> None:
|
||||
# natural English sentence has decent entropy
|
||||
assert _shannon_entropy("The quick brown fox jumps") > 3.0
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# validate_reason
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateReason:
|
||||
def test_valid_reason_returns_none(self) -> None:
|
||||
assert validate_reason(_VALID_REASON) is None
|
||||
|
||||
def test_too_short(self) -> None:
|
||||
err = validate_reason("short")
|
||||
assert err is not None
|
||||
assert "too short" in err
|
||||
|
||||
def test_too_few_words(self) -> None:
|
||||
# 25+ chars but only 4 words
|
||||
err = validate_reason("word1 word2 word3 word4xxx")
|
||||
assert err is not None
|
||||
assert "words" in err
|
||||
|
||||
def test_low_entropy_rejected(self) -> None:
|
||||
# repeating 'ab' has low entropy
|
||||
err = validate_reason("ababababababababababababababab")
|
||||
assert err is not None
|
||||
# could be caught by entropy or alternating-pattern check
|
||||
assert err is not None
|
||||
|
||||
def test_char_run_rejected(self) -> None:
|
||||
err = validate_reason("I neeeeed this game to play it")
|
||||
assert err is not None
|
||||
assert "repeated characters" in err
|
||||
|
||||
def test_alternating_pattern_rejected(self) -> None:
|
||||
# "ababababab..." repeated many times
|
||||
err = validate_reason("abababababababababababababababababababababab")
|
||||
assert err is not None
|
||||
assert "repetitive" in err or "random" in err or err is not None
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# chattr helpers
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestTrySetImmutable:
|
||||
def test_file_does_not_exist(self, tmp_path: Path) -> None:
|
||||
# Should silently do nothing when the file doesn't exist
|
||||
_try_set_immutable(tmp_path / "nonexistent.txt", immutable=True)
|
||||
|
||||
def test_chattr_not_available(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
with patch("shutil.which", return_value=None):
|
||||
_try_set_immutable(target, immutable=True) # no-op, no crash
|
||||
|
||||
def test_chattr_called_set(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
fake_chattr = tmp_path / "chattr"
|
||||
with (
|
||||
patch("shutil.which", return_value=str(fake_chattr)),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
_try_set_immutable(target, immutable=True)
|
||||
mock_run.assert_called_once()
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "+i" in args
|
||||
|
||||
def test_chattr_called_clear(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
fake_chattr = tmp_path / "chattr"
|
||||
with (
|
||||
patch("shutil.which", return_value=str(fake_chattr)),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
_try_set_immutable(target, immutable=False)
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "-i" in args
|
||||
|
||||
def test_oserror_swallowed(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/chattr"),
|
||||
patch("subprocess.run", side_effect=OSError("no permission")),
|
||||
):
|
||||
_try_set_immutable(target, immutable=True) # must not raise
|
||||
|
||||
def test_timeout_swallowed(self, tmp_path: Path) -> None:
|
||||
import subprocess
|
||||
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/chattr"),
|
||||
patch(
|
||||
"subprocess.run",
|
||||
side_effect=subprocess.TimeoutExpired(cmd="chattr", timeout=5),
|
||||
),
|
||||
):
|
||||
_try_set_immutable(target, immutable=True) # must not raise
|
||||
|
||||
|
||||
class TestLockAndUnlock:
|
||||
def test_lock_enforcement_files(self, tmp_path: Path) -> None:
|
||||
cfg = tmp_path / "config.json"
|
||||
cfg.write_text("{}", encoding="utf-8")
|
||||
approved = tmp_path / "approved.json"
|
||||
approved.write_text("[]", encoding="utf-8")
|
||||
|
||||
with (
|
||||
patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
|
||||
approved,
|
||||
),
|
||||
patch("shutil.which", return_value="/usr/bin/chattr"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
lock_enforcement_files(cfg)
|
||||
assert mock_run.call_count == 2
|
||||
all_calls = [c[0][0] for c in mock_run.call_args_list]
|
||||
assert all("+i" in c for c in all_calls)
|
||||
|
||||
def test_unlock_for_write(self, tmp_path: Path) -> None:
|
||||
target = tmp_path / "file.txt"
|
||||
target.write_text("data", encoding="utf-8")
|
||||
with (
|
||||
patch("shutil.which", return_value="/usr/bin/chattr"),
|
||||
patch("subprocess.run") as mock_run,
|
||||
):
|
||||
unlock_for_write(target)
|
||||
args = mock_run.call_args[0][0]
|
||||
assert "-i" in args
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Persistence helpers (_load_pending, _save_pending, etc.)
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPersistence:
|
||||
def test_load_pending_missing_file(self) -> None:
|
||||
assert _load_pending() == []
|
||||
|
||||
def test_load_pending_corrupt_file(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "pending.json"
|
||||
bad.write_text("not json{{", encoding="utf-8")
|
||||
with patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE",
|
||||
bad,
|
||||
):
|
||||
assert _load_pending() == []
|
||||
|
||||
def test_load_pending_non_list(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "pending.json"
|
||||
bad.write_text('{"key": "value"}', encoding="utf-8")
|
||||
with patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.PENDING_EXCEPTIONS_FILE",
|
||||
bad,
|
||||
):
|
||||
assert _load_pending() == []
|
||||
|
||||
def test_save_and_load_pending_roundtrip(self) -> None:
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": "test", "requested_at": 12345.0}
|
||||
]
|
||||
_save_pending(entries)
|
||||
assert _load_pending() == entries
|
||||
|
||||
def test_load_approved_missing_file(self) -> None:
|
||||
assert _load_approved() == []
|
||||
|
||||
def test_load_approved_corrupt_file(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "approved.json"
|
||||
bad.write_text("{{broken", encoding="utf-8")
|
||||
with patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
|
||||
bad,
|
||||
):
|
||||
assert _load_approved() == []
|
||||
|
||||
def test_load_approved_non_list(self, tmp_path: Path) -> None:
|
||||
bad = tmp_path / "approved.json"
|
||||
bad.write_text('"just a string"', encoding="utf-8")
|
||||
with patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.APPROVED_EXCEPTIONS_FILE",
|
||||
bad,
|
||||
):
|
||||
assert _load_approved() == []
|
||||
|
||||
def test_save_approved_roundtrip(self) -> None:
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 730, "reason": "cs2", "approved_at": 99999.0}
|
||||
]
|
||||
with (
|
||||
patch("shutil.which", return_value=None), # skip chattr
|
||||
):
|
||||
_save_approved(entries)
|
||||
assert _load_approved() == entries
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Audit log
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAppendAuditLog:
|
||||
def test_audit_log_written(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "audit.log"
|
||||
with patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG",
|
||||
log_file,
|
||||
):
|
||||
_append_audit_log(440, "some reason", "REQUESTED")
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
assert "REQUESTED" in content
|
||||
assert "app_id=440" in content
|
||||
assert "some reason" in content
|
||||
|
||||
def test_audit_log_appends(self, tmp_path: Path) -> None:
|
||||
log_file = tmp_path / "audit.log"
|
||||
with patch(
|
||||
"python_pkg.steam_backlog_enforcer._whitelist.EXCEPTION_AUDIT_LOG",
|
||||
log_file,
|
||||
):
|
||||
_append_audit_log(440, "first", "REQUESTED")
|
||||
_append_audit_log(730, "second", "APPROVED")
|
||||
content = log_file.read_text(encoding="utf-8")
|
||||
assert "app_id=440" in content
|
||||
assert "app_id=730" in content
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# add_pending_exception
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestAddPendingException:
|
||||
def test_add_new_exception(self) -> None:
|
||||
with patch("shutil.which", return_value=None):
|
||||
msg = add_pending_exception(440, _VALID_REASON)
|
||||
assert "440" in msg
|
||||
assert "24h" in msg or "hours" in msg or "active" in msg.lower()
|
||||
pending = _load_pending()
|
||||
assert len(pending) == 1
|
||||
assert int(pending[0]["app_id"]) == 440
|
||||
|
||||
def test_invalid_reason_raises(self) -> None:
|
||||
with pytest.raises(
|
||||
ValueError, match=r"short|words|entropy|repeated|repetitive"
|
||||
):
|
||||
add_pending_exception(440, "too short")
|
||||
|
||||
def test_already_approved_raises(self) -> None:
|
||||
approved: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0}
|
||||
]
|
||||
_save_approved(approved)
|
||||
with (
|
||||
patch("shutil.which", return_value=None),
|
||||
pytest.raises(ValueError, match="already in the approved"),
|
||||
):
|
||||
add_pending_exception(440, _VALID_REASON)
|
||||
|
||||
def test_already_pending_cooldown_remaining(self) -> None:
|
||||
existing: list[dict[str, object]] = [
|
||||
{
|
||||
"app_id": 440,
|
||||
"reason": _VALID_REASON,
|
||||
"requested_at": time.time(), # just now → full cooldown remaining
|
||||
}
|
||||
]
|
||||
_save_pending(existing)
|
||||
with patch("shutil.which", return_value=None):
|
||||
msg = add_pending_exception(440, _VALID_REASON)
|
||||
assert "already pending" in msg
|
||||
|
||||
def test_already_pending_cooldown_elapsed_promotes(self) -> None:
|
||||
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
|
||||
existing: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past}
|
||||
]
|
||||
_save_pending(existing)
|
||||
with patch("shutil.which", return_value=None):
|
||||
msg = add_pending_exception(440, _VALID_REASON)
|
||||
# The elapsed entry is broken out of the pending-check loop via break,
|
||||
# then a new entry is appended → still gets the "Will become active" msg.
|
||||
assert "440" in msg
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# promote_pending_exceptions
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestPromotePendingExceptions:
|
||||
def test_no_entries(self) -> None:
|
||||
result = promote_pending_exceptions()
|
||||
assert result == []
|
||||
|
||||
def test_cooldown_not_elapsed(self) -> None:
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "requested_at": time.time()}
|
||||
]
|
||||
_save_pending(entries)
|
||||
with patch("shutil.which", return_value=None):
|
||||
result = promote_pending_exceptions()
|
||||
assert result == []
|
||||
# Still pending
|
||||
assert len(_load_pending()) == 1
|
||||
|
||||
def test_cooldown_elapsed_promotes(self) -> None:
|
||||
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past}
|
||||
]
|
||||
_save_pending(entries)
|
||||
with patch("shutil.which", return_value=None):
|
||||
result = promote_pending_exceptions()
|
||||
assert 440 in result
|
||||
assert _load_pending() == []
|
||||
approved_ids = get_approved_exception_ids()
|
||||
assert 440 in approved_ids
|
||||
|
||||
def test_already_in_approved_not_duplicated(self) -> None:
|
||||
"""If somehow already approved, skip duplicating it."""
|
||||
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past}
|
||||
]
|
||||
_save_pending(entries)
|
||||
existing_approved: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "approved_at": 0.0}
|
||||
]
|
||||
_save_approved(existing_approved)
|
||||
with patch("shutil.which", return_value=None):
|
||||
result = promote_pending_exceptions()
|
||||
# Not added again
|
||||
assert 440 not in result
|
||||
approved = _load_approved()
|
||||
assert sum(1 for e in approved if int(e["app_id"]) == 440) == 1
|
||||
|
||||
def test_pending_list_saved_when_entries_removed(self) -> None:
|
||||
"""_save_pending is called when the pending list shrinks."""
|
||||
past = time.time() - WHITELIST_COOLDOWN_SECONDS - 1
|
||||
future = time.time()
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "requested_at": past},
|
||||
{"app_id": 730, "reason": _VALID_REASON, "requested_at": future},
|
||||
]
|
||||
_save_pending(entries)
|
||||
with patch("shutil.which", return_value=None):
|
||||
result = promote_pending_exceptions()
|
||||
assert 440 in result
|
||||
remaining = _load_pending()
|
||||
assert len(remaining) == 1
|
||||
assert int(remaining[0]["app_id"]) == 730
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# get_approved_exception_ids
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestGetApprovedExceptionIds:
|
||||
def test_empty(self) -> None:
|
||||
result = get_approved_exception_ids()
|
||||
assert result == frozenset()
|
||||
|
||||
def test_populated(self) -> None:
|
||||
approved: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": "r", "approved_at": 0.0},
|
||||
{"app_id": 730, "reason": "r", "approved_at": 0.0},
|
||||
]
|
||||
_save_approved(approved)
|
||||
with patch("shutil.which", return_value=None):
|
||||
pass
|
||||
result = get_approved_exception_ids()
|
||||
assert result == frozenset({440, 730})
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# list_pending_exceptions
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestListPendingExceptions:
|
||||
def test_returns_copy(self) -> None:
|
||||
"""Mutating the result must not affect stored state."""
|
||||
entries: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": "r", "requested_at": 1.0}
|
||||
]
|
||||
_save_pending(entries)
|
||||
result = list_pending_exceptions()
|
||||
result.clear()
|
||||
# Still present on next load
|
||||
assert len(_load_pending()) == 1
|
||||
|
||||
def test_empty_when_no_pending(self) -> None:
|
||||
assert list_pending_exceptions() == []
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Extra coverage for validate_reason branches 94 & 106
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestValidateReasonExtraBranches:
|
||||
"""Cover lines 94 and 106 that need multi-word, long, low-entropy inputs."""
|
||||
|
||||
def test_low_entropy_multi_word(self) -> None:
|
||||
# 8 words, 31+ chars, entropy ≈ 2.0 (< 3.0), no char run, no alt pattern
|
||||
err = validate_reason("the the the the the the the the")
|
||||
assert err is not None
|
||||
assert "entropy" in err
|
||||
|
||||
def test_alternating_pattern_multi_word(self) -> None:
|
||||
# "abababab" satisfies (..)(\1){3,}, rest provides uniqueness for entropy
|
||||
reason = "abababab xyz pqr uvw lmn" # 5 words, 24 chars → need 25+
|
||||
reason = "abababab xyz pqr uvw lmnop" # 5 words, 26 chars
|
||||
err = validate_reason(reason)
|
||||
assert err is not None
|
||||
assert "repetitive" in err
|
||||
|
||||
def test_different_app_id_in_pending_does_not_block(self) -> None:
|
||||
"""Pending entry with a different app_id must not block a new add (covers
|
||||
the 264→263 loop-continue branch)."""
|
||||
other: list[dict[str, object]] = [
|
||||
{"app_id": 440, "reason": _VALID_REASON, "requested_at": 1.0}
|
||||
]
|
||||
_save_pending(other)
|
||||
with patch("shutil.which", return_value=None):
|
||||
msg = add_pending_exception(730, _VALID_REASON)
|
||||
assert "730" in msg
|
||||
pending = _load_pending()
|
||||
assert len(pending) == 2 # original + new
|
||||
Loading…
Reference in New Issue
Block a user