2026-03-21 17:51:36 +01:00
|
|
|
"""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")
|
2026-05-08 20:31:16 +02:00
|
|
|
assert r.is_playable is True
|
2026-03-21 17:51:36 +01:00
|
|
|
|
|
|
|
|
def test_gold_trending_gold(self) -> None:
|
|
|
|
|
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="gold")
|
|
|
|
|
assert r.is_playable is True
|
|
|
|
|
|
2026-05-08 20:31:16 +02:00
|
|
|
def test_silver_trending_gold(self) -> None:
|
|
|
|
|
r = ProtonDBRating(app_id=1, tier="silver", trending_tier="gold")
|
|
|
|
|
assert r.is_playable is True
|
|
|
|
|
|
2026-03-21 17:51:36 +01:00
|
|
|
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
|
|
|
|
|
|
2026-05-08 20:31:16 +02:00
|
|
|
def test_gold_trending_bronze(self) -> None:
|
|
|
|
|
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="bronze")
|
|
|
|
|
assert r.is_playable is False
|
|
|
|
|
|
2026-03-21 17:51:36 +01:00
|
|
|
def test_unknown_tier(self) -> None:
|
|
|
|
|
r = ProtonDBRating(app_id=1, tier="unknown_tier")
|
|
|
|
|
assert r.is_playable is False
|
|
|
|
|
|
2026-05-16 15:46:02 +02:00
|
|
|
def test_unplayable_reason_no_trending_tier(self) -> None:
|
|
|
|
|
r = ProtonDBRating(app_id=1, tier="borked")
|
|
|
|
|
assert "tier<" in r.unplayable_reason
|
|
|
|
|
|
2026-05-08 20:31:16 +02:00
|
|
|
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
|
|
|
|
|
|
2026-05-08 20:34:29 +02:00
|
|
|
def test_unplayable_reason_empty_when_playable(self) -> None:
|
|
|
|
|
r = ProtonDBRating(app_id=1, tier="gold")
|
|
|
|
|
assert r.unplayable_reason == ""
|
|
|
|
|
|
2026-03-21 17:51:36 +01:00
|
|
|
|
|
|
|
|
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))
|
steam_backlog_enforcer: fix stats command — show real Rush/Leisure/Worst data
Four bugs fixed:
- HLTB search returned 0 results for ~87 games with special chars (™, ®, &,
standalone -, (Legacy), RHCP, etc.) — add _sanitize_search_name() and
extend _build_search_variants() with Steam-suffix and edition stripping
- fetch_hltb_detail_missing returned immediately because `app_id not in rush`
was always False (all keys present with -1) — fix to `rush.get(id,-1) <= 0`
- save_hltb_cache overwrote rush/leisure on confidence-only partial saves —
now reads existing cache and preserves data when extras dicts are empty
- _filter_qualifying_games excluded 57 games with stale snapshot hours (-1)
even though HLTB hours cache had valid data — add cache fallback
Result: stats shows Rush 64,670h / Leisure 136,807h / Worst 228,594h for
all 785 qualifying games with full rush+leisure detail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:02:48 +02:00
|
|
|
assert result is None
|
2026-03-21 17:51:36 +01:00
|
|
|
|
|
|
|
|
|
|
|
|
|
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"
|
|
|
|
|
|
steam_backlog_enforcer: fix stats command — show real Rush/Leisure/Worst data
Four bugs fixed:
- HLTB search returned 0 results for ~87 games with special chars (™, ®, &,
standalone -, (Legacy), RHCP, etc.) — add _sanitize_search_name() and
extend _build_search_variants() with Steam-suffix and edition stripping
- fetch_hltb_detail_missing returned immediately because `app_id not in rush`
was always False (all keys present with -1) — fix to `rush.get(id,-1) <= 0`
- save_hltb_cache overwrote rush/leisure on confidence-only partial saves —
now reads existing cache and preserves data when extras dicts are empty
- _filter_qualifying_games excluded 57 games with stale snapshot hours (-1)
even though HLTB hours cache had valid data — add cache fallback
Result: stats shows Rush 64,670h / Leisure 136,807h / Worst 228,594h for
all 785 qualifying games with full rush+leisure detail.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-28 07:02:48 +02:00
|
|
|
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
|
|
|
|
|
|
2026-03-21 17:51:36 +01:00
|
|
|
|
|
|
|
|
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"
|