fix: accept ProtonDB gold+silver combinations; add explicit skip reasons

This commit is contained in:
Krzysztof kuhy Rudnicki 2026-05-08 20:31:16 +02:00
parent e4f398e8fd
commit 7f7a5b68fb
4 changed files with 74 additions and 12 deletions

View File

@ -59,25 +59,49 @@ class ProtonDBRating:
A game is considered unplayable when:
- Its tier is silver, bronze, or borked.
- Its tier is gold but trending to silver or worse.
- No data exists (unknown compatibility).
- 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 tier_rank > min_rank:
# Silver, bronze, borked → skip.
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
if tier_rank == min_rank and self.trending_tier:
# Gold but trending silver/bronze/borked → skip.
trend_rank = TIER_ORDER.get(self.trending_tier, 99)
if trend_rank > min_rank:
return False
# At least one rating must still be gold-or-better.
return not (tier_rank > min_rank and trend_rank > min_rank)
return True
@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)
min_rank = 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})"
if tier_rank > min_rank and trend_rank > min_rank:
return f"no gold tier ({self.tier}/{self.trending_tier})"
return "fails ProtonDB rule"
def _load_cache() -> dict[str, Any]:

View File

@ -151,11 +151,12 @@ def _pick_playable_candidate(
)
return game
logger.info(
"Skipping %s (AppID=%d): ProtonDB %s (trending %s)",
"Skipping %s (AppID=%d): ProtonDB %s (trending %s)%s",
game.name,
game.app_id,
rating.tier,
rating.trending_tier,
rating.unplayable_reason,
)
offset += _PROTONDB_BATCH_SIZE

View File

@ -62,12 +62,16 @@ class TestProtonDBRating:
def test_gold_trending_silver(self) -> None:
r = ProtonDBRating(app_id=1, tier="gold", trending_tier="silver")
assert r.is_playable is False
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
@ -80,10 +84,22 @@ class TestProtonDBRating:
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_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
class TestProtonDBCache:
"""Tests for cache I/O."""

View File

@ -215,6 +215,27 @@ class TestPickPlayableCandidate:
result = _pick_playable_candidate([game])
assert result is not None
def test_logs_explicit_protondb_skip_reason(self) -> None:
"""Unplayable candidate logs concrete reason, not just raw tiers."""
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="silver", trending_tier="silver"),
2: ProtonDBRating(app_id=2, tier="platinum"),
},
),
patch("python_pkg.steam_backlog_enforcer.scanning._echo"),
patch("python_pkg.steam_backlog_enforcer.scanning.logger.info") as mock_log,
):
result = _pick_playable_candidate([bad, good])
assert result is not None
assert result.app_id == 2
assert any("no gold tier" in str(call) for call in mock_log.call_args_list)
class TestPickNextGame:
"""Tests for pick_next_game."""